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 = Int64This 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 Vector{Union{Nothing, Some{Any}}}:
Some(summer)
Some([1, 2, 5])
nothing
nothing
nothingThese 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 Vector{Any}:
Int64The 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 Vector{Any}:
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undefSince 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
1Let's try executing the first statement:
julia> JuliaInterpreter.step_expr!(frame)
2This 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 Vector{Union{Nothing, Some{Any}}}:
Some(summer)
Some([1, 2, 5])
Some(0)
nothing
nothingYou 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 Vector{Any}:
#undef
[1, 2, 5]
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undef
#undefOne 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 Testjulia> 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 structs, new methods, or new modules requires special handling. Calling finish_and_return! on a frame that defines and then calls new objects can trigger a world age error, in which a newly defined method is considered too new to be called by currently running code.
Here's a demonstration of the problem:
julia> ex = :(map(x->x^2, [1, 2, 3]));
julia> frame = Frame(Main, ex);
julia> JuliaInterpreter.finish_and_return!(frame)
ERROR: this frame needs to be run at top levelThe reason for this error becomes clearer if we 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"#1#2" │ const var"#1#2" │ %3 = Core._structtype(Main, Symbol("#1#2"), Core.svec(), Core.svec(), Core.svec(), false, 0) │ Core._setsuper!(%3, Core.Function) │ var"#1#2" = %3 │ Core._typebody!(%3, Core.svec()) └── return nothing ))) │ %2 = Core.svec(var"#1#2", Core.Any) │ %3 = Core.svec() │ %4 = Core.svec(%2, %3, $(QuoteNode(:(#= string:1 =#)))) │ $(Expr(:method, false, :(%4), CodeInfo( @ string:1 within `none` 1 ─ %1 = ^ │ %2 = Core.apply_type(Base.Val, 2) │ %3 = (%2)() │ %4 = Base.literal_pow(%1, x, %3) └── return %4 ))) │ #1 = %new(var"#1#2") │ %7 = #1 │ %8 = Base.vect(1, 2, 3) │ %9 = map(%7, %8) └── return %9 ))))
The code first defines the anonymous function x->x^2 (creating an anonymous type and a call method for it) and then calls map. The problem is that the newly defined call method is in a world newer than the one in which finish_and_return! is running.
This can be fixed by indicating that we want to run this frame at top level:
julia> JuliaInterpreter.finish_and_return!(frame, true)
3-element Vector{Int64}:
1
4
9Interpreting multi-statement programs
When interpreting code with multiple statements — such as the contents of a source file — later statements often call methods or use types defined by earlier ones. Frame handles this automatically for :toplevel expressions (the representation used when parsing multiple statements) and :module expressions:
julia> ex = Meta.parseall(""" f_new(x) = 2x g_new() = f_new(21) g_new() """);julia> ex.head:topleveljulia> frame = Frame(Main, ex);julia> JuliaInterpreter.finish_and_return!(frame)42
Meta.parseall (as opposed to Meta.parse) parses the entire string as a sequence of top-level statements, returning a :toplevel expression. Frame recognizes :toplevel and :module expressions and creates a driver frame that steps through the unlowered surface statements one at a time, raising the world age between each so that each statement sees all definitions from earlier ones.
A :module expression is evaluated as native evaluation does: when the driver frame reaches it, a fresh module is created (replacing any existing module of the same name), its body is interpreted, and its __init__ runs (natively, so it cannot be stepped into). The statement evaluates to the module.
ExprSplitter remains available for applications that need fine-grained control over when each sub-expression is evaluated; Revise.jl uses it for this purpose. Unlike Frame, it re-enters existing modules, as suits re-evaluating revised source, and it never runs __init__.
(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.)
Interpreting Core.eval
With RecursiveInterpreter(), calls to Core.eval(mod, expr) interpret expr in linked top-level frames in mod. This also applies to Base.eval, @eval, and entry through @interpret Core.eval(mod, expr). Calls made by the evaluated code can hit breakpoints and be stepped through; resuming a paused eval does not require setting rootistoplevel=true when the root is an ordinary method frame.
Evaluation starts in the latest world, but the calling method keeps its original world age after eval returns. Definitions and other side effects still affect the real target module, and :module expressions are evaluated as described above. Lowering and top-level declarations that cannot be interpreted continue to use Julia's native machinery.
Since include evaluates each expression of the file with Core.eval, an include called from interpreted code interprets the whole file.
To retain native evaluation, use NonRecursiveInterpreter() or add which(Core.eval, Tuple{Module,Any}) to JuliaInterpreter.compiled_methods before constructing the calling frames (clear existing interpreter caches if necessary).
World-age threading
Each Frame captures the current world age at construction time. For method frames, this world is held fixed throughout execution, so stepping sees a consistent view of the method table even if new methods are defined mid-session. Toplevel frames follow native evaluation of top-level code instead: they start in the latest world, and they see the definitions of their earlier statements by advancing the world at :latestworld statements on Julia 1.12 and later, and before each statement on earlier versions. A nested Core.eval therefore does not by itself advance the world of the enclosing top-level frame. Code that evaluates top-level frames selectively, skipping statements, must still advance the world at the :latestworld statements it skips (as LoweredCodeUtils.next_or_nothing! does).