Internals
Basic usage
The process of executing code in the interpreter is to prepare a frame
and then evaluate these statements one-by-one, branching via the goto
statements as appropriate. Using the summer
example described in Lowered representation, let's build a frame:
julia> frame = JuliaInterpreter.enter_call(summer, A)
Frame for summer(A::AbstractArray{T,N} where N) where T in Main at REPL[2]:2
1* 2 1 ─ s = (zero)($(Expr(:static_parameter, 1)))
2 3 │ %2 = A
3 3 │ #temp# = (iterate)(%2)
⋮
A = [1, 2, 5]
T = Int64
This is a Frame
. Only a portion of the CodeInfo
is shown, a small region surrounding the current statement (marked with *
or in yellow text). The full CodeInfo
can be extracted as code = frame.framecode.src
. (It's a slightly modified form of one returned by @code_lowered
, in that it has been processed by JuliaInterpreter.optimize!
to speed up run-time execution.)
frame
has another field, framedata
, that holds values needed for or generated by execution. The input arguments and local variables are in locals
:
julia> frame.framedata.locals
5-element Array{Union{Nothing, Some{Any}},1}:
Some(summer)
Some([1, 2, 5])
nothing
nothing
nothing
These correspond to the code.slotnames
; the first is the #self#
argument and the second is the input array. The remaining local variables (e.g., s
and a
), have not yet been assigned–-we've only built the frame, but we haven't yet begun to execute it. The static parameter, T
, is stored in frame.framedata.sparams
:
julia> frame.framedata.sparams
1-element Array{Any,1}:
Int64
The Expr(:static_parameter, 1)
statement refers to this value.
The other main storage is for the generated SSA values:
julia> frame.framedata.ssavalues
16-element Array{Any,1}:
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
Since we haven't executed any statements yet, these are all undefined.
The other main entity is the so-called program counter, which just indicates the next statement to be executed:
julia> frame.pc
1
Let's try executing the first statement:
julia> JuliaInterpreter.step_expr!(frame)
2
This indicates that it ran statement 1 and is prepared to run statement 2. (It's worth noting that the first line included a call
to zero
, so behind the scenes JuliaInterpreter created a new frame for zero
, executed all the statements, and then popped back to frame
.) Since the first statement is an assignment of a local variable, let's check the locals again:
julia> frame.framedata.locals
5-element Array{Union{Nothing, Some{Any}},1}:
Some(summer)
Some([1, 2, 5])
Some(0)
nothing
nothing
You can see that the entry corresponding to s
has been initialized.
The next statement just retrieves one of the slots (the input argument A
) and stores it in an SSA value:
julia> JuliaInterpreter.step_expr!(frame)
3
julia> frame.framedata.ssavalues
16-element Array{Any,1}:
#undef
[1, 2, 5]
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
One can easily continue this until execution completes, which is indicated when step_expr!
returns nothing
. Alternatively, use the higher-level JuliaInterpreter.finish!(frame)
to step through the entire frame, or JuliaInterpreter.finish_and_return!(frame)
to also obtain the return value.
More complex expressions
Sometimes you might have a whole sequence of expressions you want to run. In such cases, your first thought should be to construct the Frame
manually. Here's a demonstration:
julia> using Test
julia> ex = quote x, y = 1, 2 @test x + y == 3 end;
julia> frame = Frame(Main, ex);
julia> JuliaInterpreter.finish_and_return!(frame)
Test Passed
Toplevel code and world age
Code that defines new struct
s, new methods, or new modules is a bit more complicated and requires special handling. In such cases, calling finish_and_return!
on a frame that defines these new objects and then calls them can trigger a world age error, in which the method is considered to be too new to be run by the currently compiled code. While one can resolve this by using Base.invokelatest
, we'd have to use that strategy throughout the entire package. This would cause a major reduction in performance. To resolve this issue without leading to performance problems, care is required to return to "top level" after defining such objects. This leads to altered syntax for executing such expressions.
Here's a demonstration of the problem:
ex = :(map(x->x^2, [1, 2, 3]))
frame = Frame(Main, ex)
julia> JuliaInterpreter.finish_and_return!(frame)
ERROR: this frame needs to be run a top level
The reason for this error becomes clearer if we examine frame
or look directly at the lowered code:
julia> Meta.lower(Main, ex)
:($(Expr(:thunk, CodeInfo(
@ none within `top-level scope`
1 ─ $(Expr(:thunk, CodeInfo(
@ none within `top-level scope`
1 ─ global var"#3#4"
│ const var"#3#4"
│ %3 = Core._structtype(Main, Symbol("#3#4"), Core.svec(), Core.svec(), Core.svec(), false, 0)
│ var"#3#4" = %3
│ Core._setsuper!(var"#3#4", Core.Function)
│ Core._typebody!(var"#3#4", Core.svec())
└── return nothing
)))
│ %2 = Core.svec(var"#3#4", Core.Any)
│ %3 = Core.svec()
│ %4 = Core.svec(%2, %3, $(QuoteNode(:(#= REPL[18]:1 =#))))
│ $(Expr(:method, false, :(%4), CodeInfo(
@ REPL[18]:1 within `none`
1 ─ %1 = Core.apply_type(Base.Val, 2)
│ %2 = (%1)()
│ %3 = Base.literal_pow(^, x, %2)
└── return %3
)))
│ #3 = %new(var"#3#4")
│ %7 = #3
│ %8 = Base.vect(1, 2, 3)
│ %9 = map(%7, %8)
└── return %9
))))
All of the code before the %7
line is devoted to defining the anonymous function x->x^2
: it creates a new "anonymous type" (here written as var"#3#4"
), and then defines a "call function" for this type, equivalent to (var"#3#4")(x) = x^2
.
In some cases one can fix this simply by indicating that we want to run this frame at top level:
julia> JuliaInterpreter.finish_and_return!(frame, true)
3-element Array{Int64,1}:
1
4
9
In other cases, such as nested calls of new methods, you may need to allow the world age to update between evaluations. In such cases you want to use ExprSplitter
:
for (mod, e) in ExprSplitter(Main, ex)
frame = Frame(mod, e)
while true
JuliaInterpreter.through_methoddef_or_done!(frame) === nothing && break
end
JuliaInterpreter.get_return(frame)
end
This splits the expression into a sequence of frames (here just one, but more complex blocks may be split up into many). Then, each frame is executed until it finishes defining a new method, then returns to top level. The return to top level causes an update in the world age. If the frame hasn't been finished yet (if the return value wasn't nothing
), this continues executing where it left off.
(Incidentally, JuliaInterpreter.enter_call(map, x->x^2, [1, 2, 3])
works fine on its own, because the anonymous function is defined by the caller–-you'll see that the created frame is very simple.)