EspyInsideFunction.jl
Extracting internal variables from a function.
Package features
This package provides functionality to extract internal variables from a function. "Internal" refers here to variables that are neither parameters nor outputs of the function.
The need for EspyInsideFunction
arises when there is a difference between
- what the rest of the software needs to exchange with the function, in order to carry out the software's task, and
- what the user may want to know about intermediate results internal to the function.
An example is the extraction of results in a finite element software. The code for an element type must include a function that takes in the degrees of freedom (in mechanics: nodal displacements) and output the element's contributions to the residuals (forces). The user is interested in intermediate results such as stresses and strains.
Writing the function to explicitly export intermediate results clutters the element code, the element API, and the rest of the software.
EspyInsideFunction
's approach to this problem is to use metaprogramming to generate two versions of the function's code
- The fast version, that does nothing to save or export intermediate results. This is then used in e.g. the finite element solution process.
- The exporting version. In it receives additional parameters
- a vector
out
, to be filled with the requested results. - a
key
describing which internal results are wanted and where inout
to store which result.
- a vector
A complete usage example can be found in EspyDemo.jl
Code markup
The following is an example of annotated code
using EspyInsideFunction
struct Material
E :: Float64
end
@espy function material(m::Material,ε)
:σ = m.E*ε
return σ
end
requestable(m::Material) = (σ=scalar,)
struct Element
L₀ :: Float64
A :: Float64
ρg :: Float64
mat :: Material
end
const ngp = 1
@espy function force(e::Element,ΔX)
:w = e.ρg*e.A*e.L₀
:R = [w/2,w/2]
for igp = 1:ngp
:ε = (ΔX[2]-ΔX[1])/e.L₀
σ = :material(e.mat,ε)
:T = e.A*σ
R = +[T,-T]
end
return R
end
requestable(e::Element) = (w=scalar, R=(2,), gp=
forloop(ngp, (ε=scalar, T=scalar, material=requestable(e.mat) )))
# output
requestable (generic function with 2 methods)
The code of each function is pre-pended with @espy
. The name of variables of interest is annotated with a :
( for example :ε
and :σ
). These variable names must appear on the left of an assignment, and can not be expressions that would otherwise be acceptable at the left of an assignment (writing :a[igp] = ...
or :a.b
will not work). Calls to sub-functions which are themselves annotated with @espy must be annotated with a :
(as in σ = :material(e.mat,ε)
).
The last line of code (requestable
) provides the obtainable intermediate results and their sizes. See Section Requestable for more details.
The macro @espy
generates two versions of the code: first a clean code (for example)
function material(m::Material,ε)
σ = m.E*ε
return σ
end
and second, a version of the code to be used for result extraction. Its interface is
function material(out,key,m::Material,εz)
...
end
The variables out
and key
are discussed in the following.
One can replace the @espy
annotation with @espydbg
to examine the code that is generated:
@espydbg function material(m::Material,ε)
...
end
The generated code itself contains macros. To see the final code, one can type
@macroexpand @espy function material(m::Material,ε)
...
end
Requestable variables
The programmer of the annotated function must provide a description of the requestable variables and their size, as well as loops with their length, and sub-functions.
requestable_from_element_ = (w=scalar, R=(2,),
gp=forloop(ngp, (ε=scalar, T=scalar, material=requestable(e.mat)) ) )
In at last code line of the code example in Section Code markup, this is done as
requestable(e::Element) = (w=scalar, R=(2,),
gp=forloop(ngp, (ε=scalar, T=scalar, material=requestable(e.mat)) ) )
the choice being made made to provide this as a method associated to Element
.
forloop
and scalar
are respectively a constructor and a constant exported by EspyInsideFunction.jl
. The line will be interpreted by the function makekey
(Section Output access key) as stating that within the body of the espied function (here residual
), there is a loop exactly of the form
for igp = 1:ngp
where the letters gp
refer to the expression gp=forloop(...)
. The correct version of EspyInsideFunction.jl
is not flexible on this point: it must be a for
loop (not a while
loop or comprehension), the index variable must be igp
(gp
from gp=forloop(...)
prefixed by i
), and the upper bound must be ngp
.
Where some of the variables are arrays, their size must be described, using Tuples
:
using EspyInsideFunction
ndof = 16
nx = 2
ngp = 4
requestable = (X=(ndof),gp=forloop(ngp,(F=(nx,nx),material=(σ=(nx,nx),ε=(nx,nx)))))
# output
(X = 16, gp = forloop(4, (F = (2, 2), material = (σ = (2, 2), ε = (2, 2)))))
A development aim is to make it unnecessary to provide a list of requestable variable. Until then, one should be meticulous in writing these lists, as any mistake leads to error message that are difficult to interpret. A drawback is that this will require requestable variables to have a size that is part of the type.
Creating a request
In order to extract results from a function annotated with @espy
and :
, the user of the function needs to define a request. For example
request = @request w,gp[].(ε,material.(σ))
# output
:((w, (gp[]).(ε, material.(σ))))
For the above request to be valid, the espied function (force
, Section Code markup) must contain a variable w
outside of any loop. In the function there is a for
-loop over variable igp
taking values from 1
to ngp
. Within this loop, variable ε
must appear (and be annotated, and defined as requestable
). Within the same loop, a function material
must be called (and be annotated). Within this function, material
, variable σ
must appear and be annotated, and defined as requestable
.
Output access key
An espy-key is a data structure containing indices into the out
vector. It is generated using makekey
which takes as inputs
- A request (Section Creating a request)
- A description of the requestable variables (Section Requestable variables)
In the example provided in Section Code markup, methods force
and requestable
are associated to the type Element
, so in this example we need to create an Element
variable.
m = Material(2.1e11)
e = Element(1.,1e-4,8000*9.81,m)
key,nkey = makekey(request,requestable(e))
# output
((w = 1, gp = NamedTuple{(:ε, :material), Tuple{Int64, NamedTuple{(:σ,), Tuple{Int64}}}}[(ε = 2, material = (σ = 3,))]), 3)
This produces key
such that
key.w == 1
key.gp[1].ε == 2
key.gp[1].material.σ == 3
# output
true
and
nkey == 3
# output
true
where nkey
is the largest index found in key
.
If requestable variables are themselves arrays, key
will contain arrays of indices:
using EspyInsideFunction
ngp = 8
requestable = (gp=forloop(ngp,(material=(σ=(2,2),),)),)
request = @request gp[].(material.(σ,),)
key,nkey = makekey(request,requestable)
# Output
key.gp[8].material.σ == [29 31;30 32]
Obtaining and accessing the outputs
The following example shows how the user of an espy-annotated function can obtain and access the out
variable. Continuing with the example from Section Code markup, we assume that we have the results ΔX
for which we want to extract intermediate results. Typically ΔX
has been obtained in a numerical procedure that made use of the fast code generated from the annotated code. For a simple example:
nel = 10
elementconnectivity = [[i,i+1] for i = 1:nel]
ΔX = [1/2*e.ρg/m.E*((iel*e.L₀)^2-(nel*e.L₀)^2) for iel = 0:nel]
iel = 4
igp = 1
# output
1
In this case, the code extracts and aggregates results from multiple calls to force
:
out = Matrix{Float64}(undef,nkey,nel)
for (iel,ec) ∈ enumerate(elementconnectivity)
_ = force(@view(out[:,iel]),key, e,ΔX[ec])
end
σ = out[key.gp[igp].material.σ,iel]
ε = out[key.gp[igp].ε ,iel]
# output
1.3080000000000017e-6
nkey
(obtained from makekey
) is used to allocate out
. out
and key
are passed to the exporting version of force
. In this example where results are aggregated to multiple calls to force
, care must be taken not to pass out[:,iel]
, but @view(out[:,iel])
so that force
can modify the content of the slice.
In the two last lines of the code, key
is used to access specific outputs.
Which variables types can be exported in this way?
EspyInsideFunction
is made to store all results from a function inside a single array (e.g. out
). This allows to aggregate large amounts of data, often produced in multiple calls to the same function, with only a single allocation.
If var
is a scalar, the code inserted by @espy
to capture an intermediate result is of the form out[key.var] = var
, so var
must convert to the eltype
of out
.
If var
is a container, key.var
is an array of integers, and the inserted code is out[key.var] .= var
. This assignment works for array-like containers, including Array
, StaticArray
and ntuple
. A normal Array
can be used, but its size must be defined as a constant, in requestable
.
Reference
EspyInsideFunction.@request
— Macroreq = @request expr
Create a request of internal results wanted from a function. Considering the function presented as example for @espy
, examples of possible syntax include
req = @request gp[].(s,z,material.(a,b))
req = @request gp[].(s)
req = @request gp[].(material.(a))
The first expression can be read as follows: "In the function, there is a for
loop over variable igp
, and the results are wanted as a vector (one element for each cycle of the loop). Each element of the vector shall be a type
(a structure) with a field material
, because a function of that name is called in the for loop. Within that function, a variable a
is to be retrieved.
Note the need to use parentheses also for single-element lists, as in (s)
.
EspyInsideFunction.makekey
— Functionkey = makekey(requested,requestable)
Create a "key" i.e. a data structure of indices into an array out
of internal results, returned by the code generated by @espy
.
Inputs are
requested
a data structure defining a request. This input is provided by the user of the code to specify what results are to be extracted.requestable
a named tuple defining the names and sizes of intermediate results that can be requested from a given function: this input is provided
Example
requestable = (gp=forloop(2, (z=scalar,s=scalar, material=(a=scalar,b=scalar))),)
requested = @request gp[].(s,z,material.(a,b))
key,nkey = makekey(requested,requestable)
returns key
such that
key.gp[1] == (s=1, z=2, material = (a=3, b=4))
key.gp[2] == (s=5, z=6, material = (a=7, b=8))
key.gp[2].material.a == 7
nkey == 8
EspyInsideFunction.@espy
— Macro@espy function residual(x,y)
ngp=2
r = 0
for igp=1:ngp
:z = x[igp]+y[igp]
:s,dum = :material(z)
r += s
end
return r
end
@espy function material(z)
:a = z+1
:b = a*z
return b,3.
end
Transform the code of a function in which variables and function calls have been annotated with :
in order to allow the extraction of intermediate results.
The above annotated code will result in the generation of "clean" code in which the :
annotations have been taken out
function residual(x,y)
ngp=2
r = 0
for igp=1:ngp
z = x[igp]+y[igp]
s,dum = material(z)
r += s
end
return r
end
function material(z)
a = z+1
b = a*z
return b,3.
end
The macro will also generate code with additional out
and key
arguments:
function residual(out,key,x,y)
ngp = 2
r = 0
for igp = 1:ngp
@espy_loop key gp # key_gp = key.gp[igp]
z = x[igp]+y[igp]
@espy_record out key_gp z # out[key_gp.z] = z
s = @espy_call out key_gp material(z) # s = material(out,key_gp.material,z)
@espy_record out key_gp s # out[key_gp.s] = s
r += s
end
return r
end
function material(out,key,z)
a = z+1
@espy_record out key a # out[key.a] = a
b = a*z
@espy_record out key b # out[key.b] = b
return b
end
The above code contains more macros, which in turn evaluate as shown in the comments. More precisely,
@espy_record out key a
evaluates to
if haskey(key,a)
out[key.a] = a
end
key
is a data structure generated by makekey
based on a @request
.
When the version of residual
with additional parameter out
has been called, the content of this output is accessed using key
:
requestable = (gp=forloop(2, (z=scalar,s=scalar, material=(a=scalar,b=scalar))),)
requested = @request gp[].(s,z,material.(a,b))
key,nkey = makekey(requested,requestable)
residual(out,key,x,y)
igp = 2
z = out[key.gp[igp].z]
EspyInsideFunction.@espydbg
— MacroEspyInsideFunction.forloop
— TypeEspyInsideFunction.scalar
— Constant