|
| 1 | +# Introduction to Bytecode DSL |
| 2 | + |
| 3 | +Bytecode DSL is a DSL and runtime support component of Truffle that makes it easier to implement bytecode interpreters in Truffle. Just as Truffle DSL abstracts away the tricky and tedious details of AST interpreters, the goal of Bytecode DSL is to abstract away the tricky and tedious details of a bytecode interpreter – the bytecode format, control flow, quickening, and so on – leaving only the language-specific semantics for the language to implement. |
| 4 | + |
| 5 | +Note: At the moment, Bytecode DSL is an **experimental feature**. We encourage you to give it a try, but be forewarned that its APIs are susceptible to change. |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +## Why a bytecode interpreter? |
| 10 | + |
| 11 | +Though Truffle AST interpreters enjoy excellent peak performance, they can struggle in terms of: |
| 12 | + |
| 13 | +- *Memory footprint*. Trees are not compact data structures. A root node's entire AST, with all of its state (e.g., `@Cached` parameters) must be allocated before it can execute. This allocation is especially detrimental for code that is only executed a handful of times (e.g., bootstrap code). |
| 14 | +- *Interpreted performance*. AST interpreters contain many highly polymorphic `execute` call sites that are difficult for the JVM to optimize. These sites pose no problem for runtime-compiled code (where partial evaluation can eliminate the polymorphism), but cold code that runs in the interpreter suffers from poor performance. |
| 15 | + |
| 16 | +Bytecode interpreters enjoy the same peak performance as ASTs, but they can also be encoded with less memory and are more amenable to optimization (e.g., via [host compilation](HostCompilation.md)). Unfortunately, these benefits come at a cost: bytecode interpreters are more difficult and tedious to implement properly. Bytecode DSL simplifies the implementation effort for bytecode interpreters by generating them automatically from AST node-like specifications called "operations". |
| 17 | + |
| 18 | +## Operations |
| 19 | + |
| 20 | +An operation in Bytecode DSL is an atomic unit of language semantics. Each operation can be executed, performing some computation and optionally returning a value. Operations can be nested together to form a program. As an example, the following pseudocode |
| 21 | +```python |
| 22 | +if 1 == 2: |
| 23 | + print("what") |
| 24 | +``` |
| 25 | +could be represented as an `IfThen` operation with two nested "children": an `Equals` operation and a `CallFunction` operation. The `Equals` operation would have two `LoadConstant` child operations, with different constant values attributed to each. If we represent our operations as S-expressions, the whole program might look something like: |
| 26 | +```lisp |
| 27 | +(IfThen |
| 28 | + (Equals |
| 29 | + (LoadConstant 1) |
| 30 | + (LoadConstant 2)) |
| 31 | + (CallFunction |
| 32 | + (LoadGlobal (LoadConstant "print")) |
| 33 | + (LoadConstant "what"))) |
| 34 | +``` |
| 35 | + |
| 36 | +Each of these operations has its own execution semantics. For example, the `IfThen` operation executes its first child, and if the result is `true`, it executes its second child. |
| 37 | + |
| 38 | +### Built-in vs custom operations |
| 39 | + |
| 40 | +The operations in Bytecode DSL are divided into two groups: built-in and custom. |
| 41 | + |
| 42 | +- Built-in operations come with the DSL itself, and their semantics cannot be changed. They model behaviour that is common across languages, such as control flow (`IfThen`, `While`, etc.), constant accesses (`LoadConstant`) and local variable manipulation (`LoadLocal`, `StoreLocal`). We describe the precise semantics of the built-in operations later in [Built-in Operations](#built-in-operations). |
| 43 | + |
| 44 | +- Custom operations are provided by the language. They model language-specific behaviour, such as the semantics of operators, value conversions, calls, etc. In our previous example, `Equals`, `CallFunction` and `LoadGlobal` are custom operations. There are two kinds of custom operations: regular (eager) operations and short-circuiting operations. |
| 45 | + |
| 46 | +## Simple example |
| 47 | + |
| 48 | +As an example, let us implement a Bytecode DSL interpreter for a simple language that can only add integers and concatenate strings using its singular operator `+`. Some code examples and their results are given below: |
| 49 | + |
| 50 | +``` |
| 51 | +1 + 2 |
| 52 | +=> 3 |
| 53 | +
|
| 54 | +"a" + "b" |
| 55 | +=> "ab" |
| 56 | +
|
| 57 | +1 + "a" |
| 58 | +=> throws exception |
| 59 | +``` |
| 60 | + |
| 61 | +### Defining the Bytecode class |
| 62 | + |
| 63 | +The entry-point to a Bytecode DSL interpreter is the `@GenerateBytecode` annotation. This annotation must be attached to a class that `extends RootNode` and `implements BytecodeRootNode`: |
| 64 | + |
| 65 | +```java |
| 66 | +@GenerateBytecode |
| 67 | +public abstract class ExampleBytecodeRootNode extends RootNode implements BytecodeRootNode { |
| 68 | + public ExampleBytecodeRootNode(TruffleLanguage<?> language, FrameDescriptor frameDescriptor) { |
| 69 | + ... |
| 70 | + } |
| 71 | +} |
| 72 | +`````` |
| 73 | +The class must have a two-argument constructor that takes a `TruffleLanguage<?>` and a `FrameDescriptor` (or `FrameDescriptor.Builder`). This constructor is used by the generated code to instantiate root nodes, so any other instance fields must be initialized separately. |
| 74 | + |
| 75 | +Inside the bytecode class we define custom operations. Each operation is structured similarly to a Truffle DSL node, except it does not need to be a subclass of `Node` and all of its specializations should be `static`. In our example language, the `+` operator can be expressed with its own operation: |
| 76 | + |
| 77 | +```java |
| 78 | +// place inside ExampleBytecodeRootNode |
| 79 | +@Operation |
| 80 | +public static final class Add { |
| 81 | + @Specialization |
| 82 | + public static int doInt(int lhs, int rhs) { |
| 83 | + return lhs + rhs; |
| 84 | + } |
| 85 | + |
| 86 | + @Specialization |
| 87 | + public static String doString(String lhs, String rhs) { |
| 88 | + return lhs.toString() + rhs.toString(); |
| 89 | + } |
| 90 | + |
| 91 | + // fallback omitted |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +Within operations, we can use most of the Truffle DSL, including `@Cached` and `@Bind` parameters, guards, and specialization limits. We cannot use features that require node instances, such as `@NodeChild`, `@NodeField`, nor any instance fields or methods. |
| 96 | + |
| 97 | +One limitation of custom operations is that they eagerly evaluate all of their operands. They cannot perform conditional execution, loops, etc. For those use-cases, we have to use the built-in operations or define custom short-circuiting operations. |
| 98 | + |
| 99 | +From this simple description, the DSL will generate a `ExampleBytecodeRootNodeGen` class that contains a full bytecode interpreter definition. |
| 100 | + |
| 101 | +### Converting a program to bytecode |
| 102 | + |
| 103 | +In order to execute a guest program, we need to convert it to the bytecode defined by the generated interpreter. |
| 104 | +We refer to this process as "parsing" the bytecode root node. |
| 105 | +<!-- We refer to the process of converting a guest program to bytecode (and thereby creating a `BytecodeRootNode`) as parsing. --> |
| 106 | + |
| 107 | +To parse a program to a bytecode root node, we encode the program in terms of operations. |
| 108 | +We invoke methods on the generated `Builder` class to construct these operations; the builder translates these method calls to a sequence of bytecodes that can be executed by the generated interpreter. |
| 109 | + |
| 110 | + |
| 111 | +For this example, let's assume the guest program has already been parsed to an AST as follows: |
| 112 | + |
| 113 | +```java |
| 114 | +class Expr { } |
| 115 | +class AddExpr extends Expr { Expr left; Expr right; } |
| 116 | +class IntExpr extends Expr { int value; } |
| 117 | +class StringExpr extends Expr { String value; } |
| 118 | +``` |
| 119 | +Let's also assume there is a simple visitor pattern implemented over the AST. |
| 120 | + |
| 121 | +The expression `1 + 2` can be expressed as operations `(Add (LoadConstant 1) (LoadConstant 2))`. It can be parsed using the following sequence of builder calls: |
| 122 | + |
| 123 | +```java |
| 124 | +b.beginAdd(); |
| 125 | +b.emitLoadConstant(1); |
| 126 | +b.emitLoadConstant(2); |
| 127 | +b.endAdd(); |
| 128 | +``` |
| 129 | + |
| 130 | +You can think of the `beginX` and `endX` as opening and closing `<X>` and `</X>` XML tags, while `emitX` is the empty tag `<X/>` used when the operation does not take children. Each operation has either `beginX` and `endX` methods or an `emitX` method. |
| 131 | + |
| 132 | +We can then write a visitor to construct bytecode from the AST representation: |
| 133 | + |
| 134 | +```java |
| 135 | +class ExampleBytecodeVisitor implements ExprVisitor { |
| 136 | + ExampleBytecodeRootNodeGen.Builder b; |
| 137 | + |
| 138 | + public ExampleBytecodeVisitor(ExampleBytecodeRootNodeGen.Builder b) { |
| 139 | + this.b = b; |
| 140 | + } |
| 141 | + |
| 142 | + public void visitAdd(AddExpr ex) { |
| 143 | + b.beginAdd(); |
| 144 | + ex.left.accept(this); // visitor pattern `accept` |
| 145 | + ex.right.accept(this); |
| 146 | + b.endAdd(); |
| 147 | + } |
| 148 | + |
| 149 | + public void visitInt(IntExpr ex) { |
| 150 | + b.emitLoadConstant(ex.value); |
| 151 | + } |
| 152 | + |
| 153 | + public void visitString(StringExpr ex) { |
| 154 | + b.emitLoadConstant(ex.value); |
| 155 | + } |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +Now that we have a visitor, we can define a `parse` method. This method converts an AST to a `ExampleBytecodeRootNode`, which can then be executed by the language runtime: |
| 160 | + |
| 161 | +```java |
| 162 | +public static ExampleBytecodeRootNode parseExample(ExampleLanguage language, Expr program) { |
| 163 | + var nodes = ExampleBytecodeRootNodeGen.create( |
| 164 | + BytecodeConfig.DEFAULT, |
| 165 | + builder -> { |
| 166 | + // Root operation must enclose each function. It is further explained later. |
| 167 | + builder.beginRoot(language); |
| 168 | + |
| 169 | + // This root node returns the result of executing the expression, |
| 170 | + // so wrap the result in a Return operation. |
| 171 | + builder.beginReturn(); |
| 172 | + |
| 173 | + // Invoke the visitor |
| 174 | + program.accept(new ExampleBytecodeVisitor(builder)); |
| 175 | + |
| 176 | + // End the Return and Root operations |
| 177 | + builder.endReturn(); |
| 178 | + builder.endRoot(); |
| 179 | + } |
| 180 | + ); |
| 181 | + |
| 182 | + // Return the root node. If there were multiple Root operations, there would be multiple root nodes. |
| 183 | + return nodes.getNode(0); |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +We first invoke the `ExampleBytecodeRootNodeGen#create` function, which is the entry-point for parsing. Its first argument is a `BytecodeConfig`, which defines a parsing mode. `BytecodeConfig.DEFAULT` will suffice for our purposes (there are other modes that include source positions and/or instrumentation info; see [Reparsing](#reparsing)). |
| 188 | + |
| 189 | +The second argument is the parser. The parser is an implementation of the `BytecodeParser` functional interface, which is responsible for parsing a program using a given `Builder` parameter. |
| 190 | +In this example, the parser uses the visitor to parse `program`, wrapping the operations within `Root` and `Return` operations. |
| 191 | +The parser must be deterministic (i.e., if invoked multiple times, it should invoke the same sequence of `Builder` methods), since it may be called more than once to implement reparsing (see [Reparsing](#reparsing)). |
| 192 | + |
| 193 | +The result is a `BytecodeNodes` instance, which acts as a wrapper class for the `BytecodeRootNode`s produced by the parse (along with other shared information). The nodes can be extracted using the `getNode()` or `getNodes()`. |
| 194 | + |
| 195 | +And that's it! During parsing, the builder generates a sequence of bytecode for each root node. The generated bytecode interpreter executes this bytecode sequence when a root node is executed. |
| 196 | + |
0 commit comments