This document explains what you can do in a Bytecode DSL interpreter and how to do it. It should be treated as a reference. If you haven't already, we recommend reading the Introduction and Getting Started guide before this one.
This guide presents the conceptual details of the Bytecode DSL; for more concrete technical information, consult the DSL's Javadoc, the generated Javadoc for your interpreter, and the provided code tutorials.
- The Bytecode DSL from 10,000 feet
- Operations
- Locals
- Control flow
- Exception handling
- Advanced features
At a high level, there are three phases in the development lifecycle of a Bytecode DSL interpreter. As a developer, it is helpful to keep these phases separate in your mind.
The interpreter is generated automatically from a Bytecode DSL specification.
This specification takes the form of a class definition annotated with @GenerateBytecode
with some custom operation definitions.
Operations are the semantic building blocks for Bytecode DSL programs (they are discussed in more detail later). Common behaviour is implemented by built-in operations, and language-specific behaviour is implemented by user-defined custom operations.
Below is a very simple example of an interpreter specification with a single custom Add
operation:
@GenerateBytecode(...)
public abstract static class SampleInterpreter extends RootNode implements BytecodeRootNode {
@Operation
public static final class Add {
@Specialization
public static int doInts(int a, int b) {
return a + b;
}
}
}
When this class is compiled, the Bytecode DSL annotation processor parses the specification and uses it to generate a SampleInterpreterGen
class.
This class defines an instruction set (including one or more instructions for each operation), a Builder
to generate bytecode, an interpreter to execute bytecode, and various supporting code.
You may find it useful to read through the generated code (it is intended to be human-readable).
Now that we have an interpreter, we need to generate some concrete bytecode for it to execute.
We refer to the the process of converting a source program to bytecode as parsing.
Each method/function in the guest language is parsed to its own bytecode using a BytecodeParser
.
A BytecodeParser
specifies a tree of operations by calling a sequence of methods defined on the generated Builder
class.
The Builder
class is responsible for validating the well-formedness of these operations and converting them to low-level bytecodes that implement their behaviour.
Below is a simple BytecodeParser
that generates bytecode to add two integer arguments together and return the result:
BytecodeParser<SampleInterpreterGen.Builder> parser = (SampleInterpreterGen.Builder b) -> {
b.beginRoot();
b.beginReturn();
b.beginAdd();
b.emitLoadArgument(0);
b.emitLoadArgument(1);
b.endAdd();
b.endReturn();
b.endRoot();
}
BytecodeRootNodes<SampleInterpreter> rootNodes = SampleInterpreterGen.create(getLanguage(), BytecodeConfig.DEFAULT, parser);
This BytecodeParser
hard-codes a sequence of builder calls.
A real parser will typically be implemented using an AST visitor; see the Parsing tutorial for an example.
The result of parsing is a BytecodeRootNodes
object containing one or more parsed root nodes.
Each root node has bytecode that can be executed by calling the root node:
SampleInterpreter rootNode = rootNodes.getNode(0);
rootNode.getCallTarget().call(40, 2); // produces 42
Custom operations and runtime code sometimes need to access information about the program and its execution state at run time.
The current state is encapsulated in a BytecodeNode
, which contains the bytecode, supporting metadata, and any profiling data.
BytecodeNode
defines helper methods for accessing local variables, computing source information, introspecting bytecode, and many other use cases.
It is worth familiarizing yourself with its APIs.
The BytecodeNode
(and the bytecode itself) can change over the execution of a program for various reasons (e.g., transitioning from uncached to cached, reparsing metadata, quickening), so you should use BytecodeRootNode#getBytecodeNode()
to obtain the up-to-date bytecode node each time you need it.
Custom operations can also @Bind BytecodeNode
in their specializations (more about special Bind parameters here).
Because the bytecode may change, a bytecode index (obtained using @Bind("$bytecodeIndex")
) must be paired with a BytecodeNode
to meaningfully identify a program location.
You can also instantiate a BytecodeLocation
, which logically represents the bytecode node and index, using BytecodeNode#getBytecodeLocation(int)
or @Bind BytecodeLocation
.
Operations are the basic unit of language semantics in the Bytecode DSL.
Each operation performs some computation and can produce a value.
For example, the LoadArgument
operation produces the value of a given argument.
An operation can have children that produce inputs to the operation.
For example, an Equals
operation may have two child operations that produce the operands to compare for equality.
Usually, child operations execute before their parent, and their results are passed as arguments to the parent.
We specify the semantics for a Bytecode DSL program by building a tree of operations. Consider the following pseudocode:
if x == 42:
print("success")
This code could be represented with the following operation tree:
(IfThen
(Equals
(LoadLocal x)
(LoadConstant 42))
(CallFunction
(LoadGlobal (LoadConstant "print"))
(LoadConstant "success")))
Note that while we describe a program as a tree of operations, Bytecode DSL interpreters do not construct or execute ASTs.
The bytecode builder takes an operation tree specification via a sequence of method calls (e.g., beginIfThen()
, endIfThen()
) and automatically synthesizes a bytecode program that implements the operation tree.
Bytecode DSL interpreters have two kinds of operations: built-in and custom.
Every Bytecode DSL interpreter comes with a predefined set of built-in operations.
They model common language primitives, such as constant accesses (LoadConstant
), local variable manipulation (LoadLocal
, StoreLocal
), and control flow (IfThen
, While
, etc.).
The built-in operations are:
Root
: Defines a root node.Return
: Returns a value from the root node.Block
: Sequences multiple operations, producing the value of the last operation.Block
operations are a build-time construct (unlike TruffleBlockNode
s, they do not affect run time behaviour).- Value producers
LoadConstant
: produces a non-null
constant valueLoadNull
: producesnull
LoadArgument
: produces the value of an argument
- Local variable operations (see Locals)
LoadLocal
StoreLocal
LoadLocalMaterialized
StoreLocalMaterialized
- Control flow operations (see Control flow)
IfThen
IfThenElse
Conditional
While
Label
,Branch
(see Unstructured control flow)
- Exception handler operations (see Exception handling)
TryCatch
TryFinally
TryCatchOtherwise
LoadException
- Source operations (see Source information)
Source
SourceSection
- Instrumentation operations (see Instrumentation)
Tag
- Continuation operations (see Continuations)
Yield
The built-in operations are described here for discoverability.
Please refer to the Javadoc of the generated Builder
methods (e.g., Builder#beginIfThen
, Builder#emitLoadConstant
) for their precise semantics.
Custom operations are provided by the language. They model language-specific behaviour, such as arithmetic operations, value conversions, or function calls. Here, we discuss regular custom operations that eagerly evaluate their children; the Bytecode DSL also supports short circuit operations.
Custom operations are defined using Java classes in one of two ways:
- Typically, operations are defined as inner classes of the root class annotated with
@Operation
. - To support migration from an AST interpreter, custom operations can also be proxies of existing existing Truffle node classes. To define an operation proxy, the root class should have an
@OperationProxy
annotation referencing the node class, and the node class itself should be marked@OperationProxy.Proxyable
. Proxied nodes have additional restrictions compared to regular Truffle AST nodes, so making a node proxyable can require some (minimal) refactoring.
The example below defines two custom operations, OperationA
and OperationB
:
@GenerateBytecode(...)
@OperationProxy(OperationB.class)
public abstract class MyBytecodeRootNode extends RootNode implements BytecodeRootNode {
...
@Operation
public static final OperationA {
@Specialization
public static int doInt(VirtualFrame frame, int num) { ... }
@Specialization
public static Object doObject(Object obj) { ... }
}
}
@OperationProxy.Proxyable
public abstract OperationB extends Node {
@Specialization
public static void doInts(int a, int b) { ... }
@Specialization
public static void doStrings(String a, String b) { ... }
}
Operation classes define their semantics using @Specialization
s just like Truffle DSL nodes.
These specializations can use the same expressive conveniences (caches, bind expressions, etc.).
Specializations can declare an optional VirtualFrame
parameter as the first parameter, and they may declare Truffle DSL parameters (@Cached
, @Bind
, etc.).
The rest of the parameters are called dynamic operands.
All specializations must have the same number of dynamic operands and must all be void
or non-void
; these attributes make up the signature for an operation.
The value of each dynamic operand is supplied by a child operation; thus, the number of dynamic operands defines the number of child operations.
For example, OperationA
above has one dynamic operand, so it requires one child operation; OperationB
has two dynamic operands, so it requires two children.
Like Truffle nodes, Bytecode DSL operations encode the behaviour of specialization guards, @Cached
initializers, @Bind
expressions, and more using Truffle DSL expressions.
Expressions used in operations can access special variables that capture the current interpreter state:
$rootNode
evaluates to the bytecode root node$bytecode
evaluates to the currentBytecodeNode
$bytecodeIndex
evaluates to the current bytecode index (as anint
)
When using @Bind
to bind interpreter state, you can often omit the expressions and rely on default bind expressions:
@Bind MyBytecodeRootNode
binds the bytecode root node@Bind BytecodeNode
binds the currentBytecodeNode
@Bind BytecodeLocation
binds the currentBytecodeLocation
(constructing it from the current bytecode index andBytecodeNode
).@Bind Instruction
binds theInstruction
introspection object for the current instruction.@Bind BytecodeTier
binds the currentBytecodeTier
.
These values are partial evaluation constants.
It is also possible to access additional helper methods/fields from expressions using @ImportStatic
.
These static imports can be declared on the root node and on individual operations (operation imports take precedence over root node imports).
This section discussed regular operations. There are also short circuit operations to implement short-circuit behaviour, and special @Prolog
, @EpilogReturn
, and @EpilogExceptional
operations to guarantee certain behaviour happens on entry/exit.
An operation can take zero or more values for its last dynamic operand by declaring the last dynamic operand @Variadic
.
The builder will emit code to collect these values into an Object[]
.
An operation can also define constant operands, which are embedded in the bytecode and produce partial evaluation constant values, by declaring @ConstantOperand
s.
An operation may need to produce more than one result, or to modify local variables. For either case, the operation can use LocalAccessor
or LocalRangeAccessor
.
The Bytecode DSL supports local variables using its BytecodeLocal
abstraction.
You can allocate a BytecodeLocal
in the current frame using the builder's createLocal
method.
In bytecode, you can use LoadLocal
and StoreLocal
operations to access the local.
The following code allocates a local, stores a value into it, and later loads the value back:
b.beginBlock();
BytecodeLocal local = b.createLocal();
b.beginStoreLocal(local);
// ...
b.endStoreLocal();
// ...
b.emitLoadLocal(local);
b.endBlock();
All local accesses must be (directly or indirectly) nested within the operation that created the local.
The LoadLocal
and StoreLocal
operations are the preferred way to access locals because they are efficient and can be quickened to avoid boxing.
Some behaviour cannot be easily implemented using only these operations, in which case an operation can declare a LocalAccessor
or LocalRangeAccessor
operand to perform local accesses.
For example, an operation producing multiple values cannot "return" both values, and may instead use a local accessor to write one of the values back to a local.
The BytecodeNode
class also declares a variety of helper methods for accessing locals.
These helpers often have extra indirection, so the built-in operations and accessors are preferred.
Local reads/writes should always use these abstractions; you should not directly read from or write to the frame.
Loading a local before a value is stored into it throws a FrameSlotTypeException
.
You can specify a defaultLocalValue
in @GenerateBytecode
to instead give uninitialized locals a default value.
By default, interpreters use block scoping, in which locals are scoped to the enclosing Block
/Root
operation.
When exiting the enclosing Block
operation, locals are cleared and their frame slots are automatically reused (locals are not cleared when exiting the Root
).
Since the set of live locals depends on the location in the code, most of the local accessor methods on BytecodeNode
are parameterized by the current bytecodeIndex
.
Interpreters can alternatively opt to use root scoping, in which all locals get a unique position in the frame and live for the entire extent of the root.
The setting is controlled by the enableBlockScoping
flag in @GenerateBytecode
.
The plain LoadLocal
and StoreLocal
operations access locals from the current frame.
In some cases, you may need to access locals from a different frame; for example, if root nodes are nested, an inner root may need to access locals of the outer root.
Materialized local accesses are intended for such use cases (see the enableMaterializedLocalAccesses
flag in @GenerateBytecode
).
When materialized local accesses are enabled, the interpreter defines LoadLocalMaterialized
and StoreLocalMaterialized
operations that behave analogously to LoadLocal
and StoreLocal
.
They can only access locals of the current root or an enclosing root.
When materialized accesses are enabled, you can also use MaterializedLocalAccessor
to access locals of a materialized frame from a custom operation.
Below is a simple example where the inner root reads the outer local from the outer root's frame.
b.beginRoot(); // outer root
b.beginBlock();
var outerLocal = b.createLocal();
// ...
b.beginRoot(); // inner root
b.beginLoadLocalMaterialized(outerLocal);
b.emitGetOuterFrame(); // produces materialized frame of outer root
b.endLoadLocalMaterialized();
b.endRoot();
b.endBlock();
b.endRoot();
When using materialized accesses with outer locals, you should be careful to only call the inner root when the outer local is live; otherwise, the access could produce unexpected values.
The bytecode builder statically checks that the local is in scope when emitting a materialized access, but the interpreter cannot easily check that the access occurs at that same point in execution.
The interpreter will validate the access when it is configured to store the bytecode index in the frame (see the storeBytecodeIndexInFrame
flag in @GenerateBytecode
), but for performance reasons this flag is false
by default.
Consider enabling the flag temporarily if you encounter unexpected behaviour with materialized local values.
The IfThen
, IfThenElse
, Conditional
, and While
operations can be used for structured control flow.
They take a boolean
condition for their first child, and conditionally execute their other child operation(s) as you would expect.
Conditional
produces a value; the rest do not.
For example, the following if-then block:
if arg0:
return 42
else:
return 123
can be implemented using an IfThenElse
operation:
b.beginIfThenElse();
b.emitLoadArgument(0); // first child: condition
b.beginReturn(); // second child: positive branch
b.emitLoadConstant(42);
b.endReturn();
b.beginReturn(); // third child: negative branch
b.emitLoadConstant(123);
b.endReturn();
b.endIfThenElse();
A limited form of unstructured control flow is also possible in Bytecode DSL interpreters using labels and forward branches.
Parsers can allocate a BytecodeLabel
using the builder's createLabel
method when inside a Root
or Block
operation.
The label should be emitted at some location in the same Root
or Block
using emitLabel
, and can be branched to using emitBranch
.
The following code allocates a label, emits a branch to it, and then emits the label at the location to branch to:
b.beginBlock();
BytecodeLabel label = b.createLabel();
// ...
b.emitBranch(label);
// ...
b.emitLabel(label);
b.endBlock();
When executed, control will jump from the branch location to the label location.
There are some restrictions on the kinds of branches allowed:
- Any branch must be (directly or indirectly) nested in the
Root
orBlock
wherecreateLabel
was called. That is, you cannot branch into an operation, only across or out of it. - Only forward branches are supported. For backward branches, use
While
operations.
Unstructured control flow is useful for implementing loop breaks, continues, and other more advanced control flow (like switch
).
Bytecode DSL interpreters have three built-in exception handler operations.
The first handler operation, TryCatch
, executes a try
operation (its first child), and if a Truffle exception is thrown, executes a catch
operation (its second child).
For example, the following try-catch block:
try:
A
catch:
B
can be implemented using a TryCatch
operation:
b.beginTryCatch();
b.emitA(); // first child (try block)
b.emitB(); // second child (catch block)
b.endTryCatch();
The second handler operation, TryFinally
, executes a try
operation (its first child), and ensures a finally
operation is always executed, even if a Truffle exception is thrown or the try
returns/branches out.
If an exception was thrown, it rethrows the exception afterward.
The bytecode for finally
is emitted multiple times (once for each exit point of try
, including at early returns), so it is specified using a Runnable
generator that can be repeatedly invoked.
This generator must be idempotent.
For example, the following try-finally block:
try:
A
finally:
B
can be implemented using a TryFinally
operation:
b.beginTryFinally(() -> b.emitB() /* finally block */);
b.emitA(); // first child (try block)
b.endTryCatch();
As another example, the following try-catch-finally block:
try:
A
catch:
B
finally:
C
can be implemented with a combination of TryFinally
and TryCatch
operations:
b.beginTryFinally(() -> b.emitC() /* finally block */);
b.beginTryCatch(); // first child of TryFinally (try block)
b.emitA(); // first child of TryCatch (try block)
b.emitB(); // second child of TryCatch (catch block)
b.endTryCatch();
b.endTryCatch();
The last handler operation, TryCatchOtherwise
, is a combination of the previous two.
It executes a try
operation (its first child); if an exception is thrown, it then executes its catch
operation (its second child), otherwise it executes its otherwise
operation (even if try
returns/branches out).
Effectively, it implements TryFinally
with a specialized handler for when an exception is thrown.
The bytecode for otherwise
is emitted multiple times (once for each non-exceptional exit point of try
), so it is specified using a Runnable
generator that can be repeatedly invoked.
This generator must be idempotent.
Note that TryCatchOtherwise
has different semantics from a Java try-catch-finally block.
Whereas a try-catch-finally always executes the finally
operation even if the catch
block executes, the TryCatchOtherwise
operation executes either its catch
or otherwise
operation (not both).
It is typically useful to implement try-finally semantics with different behaviour for exceptional exits.
For example, the following try-finally block:
try:
A
finally:
if exception was thrown:
B
else:
C
can be implemented with a TryCatchOtherwise
operation:
b.beginTryCatchOtherwise(() -> b.emitC() /* otherwise block */);
b.emitA(); // first child (try block)
b.emitB(); // second child (catch block)
b.endTryCatch();
The LoadException
operation can be used within the catch
operation of a TryCatch
or TryCatchOtherwise
to read the current exception.
Before an exception handler executes, you may wish to intercept the exception for a variety of reasons, like handling control flow exceptions, converting internal host exceptions (e.g., stack overflows) to guest exceptions, or adding metadata to exceptions.
BytecodeRootNode
defines interceptControlFlowException
, interceptInternalException
, and interceptTruffleException
hooks that can be overridden.
When an exception is thrown, the interpreter will invoke the appropriate hook(s) before dispatching to a bytecode exception handler.
The hooks are invoked at most once for each throw, and may be invoked sequentially (in the order listed above); for example a control flow exception gets intercepted by interceptControlFlowException
, which could produce an internal exception that gets intercepted by interceptInternalException
, which could produce a Truffle exception that gets intercepted by interceptTruffleException
.
This section describes some of the more advanced features supported by Bytecode DSL interpreters.
By default, Bytecode DSL interpreters execute cached, allocating memory to profile conditional branches, operation specializations, and more. These profiles allow Truffle compilation to produce highly optimized code. However, for cold code that will not be compiled (e.g., because it only runs once or twice), the extra memory allocated is wasteful.
Bytecode DSL interpreters support an uncached execution mode that allocates no memory for profiling (see the enableUncachedInterpreter
flag in @GenerateBytecode
).
When uncached execution is enabled, an interpreter starts executing as uncached.
No profiling data is collected, and each custom operation executes uncached (i.e., no specialization data is recorded).
After a predefined number of calls or loop iterations (see the defaultUncachedThreshold
attribute in @GenerateBytecode
), an uncached interpreter will transition to cached, allocating profiling data and preparing the interpreter for compilation.
It is strongly recommended to enable uncached execution, because it can reduce the footprint of your language and improve start-up times.
To support uncached execution, all operations must support uncached execution.
When enableUncachedInterpreter
is set to true
, the Bytecode DSL processor will verify that each operation supports uncached, and it will emit descriptive error messages if there are changes that need to be made.
If an operation cannot easily support uncached execution, it can instead force the interpreter to transition to cached before it executes (see the forceCached
field of @Operation
and the other operation annotations).
Bear in mind that declaring an operation with forceCached
may limit the usefulness of the uncached interpreter, depending on how common the operation is.
The Source
and SourceSection
operations associate source ranges with each operation in a program.
There are several getSourceLocation
methods defined by BytecodeNode
that can be used to compute source information for a particular bytecode index, frame instance, etc.
It is recommended to enclose the Root
operation in appropriate Source
and SourceSection
operations in order to provide accurate source information for the root node.
The generated root node will override Node#getSourceSection
to return this information.
Source information is designed to have no performance overhead until it is requested (see Reparsing metadata). Take extra care if accessing source information in compiled code.
The behaviour of a Bytecode DSL interpreter can be non-intrusively observed (and modified) using instrumentation. For example, you can instrument your code to trace each guest language statement, or add instrumentation to log return values.
Instrumentations are specified during parsing, but disabled by default. They incur no overhead until they are enabled at a later time (see Reparsing metadata).
The Bytecode DSL supports two forms of instrumentation:
@Instrumentation
operations, which are emitted and behave just like custom@Operation
s. These operations can perform special actions like logging or modifying the value produced by another operation.@Instrumentation
operations must have no stack effects, so they can either have no children and produce no value, or have one child and produce a value (which allows you to modify the result of an instrumented operation).- Tag-based instrumentation associates operations with particular instrumentation
Tag
s usingTag
operations. If these instrumentations are enabled, the bytecode will include instructions that invoke the various event callbacks on any attachedExecutionEventNode
s (e.g.,onEnter
,onReturnValue
) when executing the enclosed operation. Tag-based instrumentation can be enabled using theenableTagInstrumentation
flag in@GenerateBytecode
.
Note: once instrumentation instructions are added, they cannot be removed from the bytecode. However, in tag-based instrumentation you can still disable the instruments so that the instrumentation instructions have no effect.
Bytecode parsing does not materialize metadata or instructions for Source
, SourceSection
, Tag
, and @Instrumentation
operations by default.
Instead, the Bytecode DSL will reparse nodes to materialize the metadata/instructions when it is requested.
Reparsing allows Bytecode DSL interpreters to reduce their footprint for metadata that is infrequently used, and also allows you to dynamically enable instrumentation.
To specify what metadata/instructions to materialize, parse and reparse requests take a BytecodeConfig
parameter.
There are some pre-defined configurations for convenience (BytecodeConfig.DEFAULT
, BytecodeConfig.WITH_SOURCE
, and BytecodeConfig.COMPLETE
), or you can use the static newConfigBuilder
method on the generated class to build a specific configuration.
It may make sense to request some metadata (e.g., source information) on first parse if it is frequently used.
Note that metadata/instructions are only added; there is no way to "clear" them by requesting less information in a reparse.
To support reparsing, BytecodeParser
s must be deterministic and idempotent.
When a reparse is requested, the parser is invoked again and is expected to perform the same series of builder calls.
Since the parser is retained for reparsing, any data structures (e.g., parse trees) captured by the parser will be kept alive in the heap. To reduce footprint, it is recommended for the parser to parse directly from source code instead of keeping these data structures alive (see the SimpleLanguage parser for an example).
Reparsing updates the BytecodeNode
for a given root node.
When the bytecode instructions change, any compiled code for the root node is invalidated, and the old bytecode is invalidated in order to transition active (on-stack) invocations to the new bytecode.
Note that source information updates do not invalidate compiled code.
Bytecode DSL interpreters have various APIs that allow you to introspect the bytecode.
These methods, defined on the BytecodeNode
, include:
getInstructions
, which returns the bytecodeInstruction
s for the node.getLocals
, which returns a list ofLocalVariable
table entries.getExceptionHandlers
, which returns a list ofExceptionHandler
table entries.getSourceInformation
, which returns a list ofSourceInformation
table entries. There is alsogetSourceInformationTree
, which encodes the entries as aSourceInformationTree
.
Note that the bytecode encoding is an implementation detail, so the APIs and their outputs are subject to change, and introspection should only be used for debugging purposes.
The Bytecode DSL performs some basic reachability analysis to avoid emitting bytecode when it can guarantee a location is not reachable, for example, after an explicit Return
operation. The reachability analysis is confounded by features like branching, exception handling, and instrumentation, so reachability cannot always be precisely determined; in such cases, the builder conservatively assumes a given point in the program is reachable.
The Bytecode DSL supports techniques like quickening and boxing elimination to improve interpreted (non-compiled) performance. Refer to the Optimization guide for more details.
Like Truffle AST interpreters, Bytecode DSL interpreters use partial evaluation (PE) to implement runtime compilation. Runtime compilation is automatically supported, but there are some subtle details to know when implementing your interpreter. See the Runtime compilation guide for more details.
Bytecode DSL interpreters can support serialization, which allows a language to implement bytecode caching (like Python's .pyc
files). See the Serialization tutorial for more details.
The Bytecode DSL supports single-method continuations, whereby a root node is suspended and can be resumed at a later point in time. Continuations can be used to implement language features like coroutines and generators that suspend the state of the current method. See the Continuations tutorial for more details.
Guest language builtins integrate easily with the Bytecode DSL. The Builtins tutorial describes a few different approaches you may wish to use to define your language builtins within the Bytecode DSL.