Skip to content

Commit 0ce9bc2

Browse files
committed
Continue to rework docs
1 parent 809febc commit 0ce9bc2

File tree

3 files changed

+296
-279
lines changed

3 files changed

+296
-279
lines changed
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Optimization
2+
3+
Bytecode interpreters commonly employ [quickening](https://dl.acm.org/doi/10.1145/1869631.1869633) and [superinstructions](https://dl.acm.org/doi/abs/10.1145/1059579.1059583) to achieve better interpreted performance. This section discusses how to employ these optimizations in Bytecode DSL interpreters.
4+
5+
## Quickening
6+
7+
**TODO**: talk about how quickening works, how it connects with BE, @ForceQuickening, and tracing
8+
9+
## Superinstructions
10+
11+
**Note: Superinstructions are not yet supported**.
12+
13+
## Tracing
14+
15+
**Note: Tracing is not yet supported**.
16+
17+
Determining which instructions are worth optimizing (via quickening or superinstructions) typically requires manual profiling and benchmarking.
18+
Bytecode DSL can automatically infer optimization opportunities using *tracing*.
19+
20+
First, the DSL allows you to generate a *tracing interpreter* to collect data about the executed bytecode (e.g., common instruction sequences).
21+
Then, executed on a representative corpus of programs, the interpreter collects tracing data and infers a set of optimization decisions (e.g., "create a superinstruction with instructions X, Y, and Z").
22+
Finally, the interpreter can be rebuilt with these decisions, and the optimized instructions will be automatically included in the generated interpreter.
23+
24+
The following sections describe the tracing process in more detail.
25+
26+
### Step 1: Build the tracing interpreter
27+
28+
Tracing is built around the concept of a *decisions file*.
29+
The decisions file encodes a set of optimization decisions (e.g., quickenings or superinstructions).
30+
31+
To prepare your Bytecode DSL interpreter for tracing, first specify a path for the decisions file using the `decisionsFile = "..."` attribute of the top-level `@GenerateBytecode` annotation.
32+
The path is relative to the current file.
33+
It is recommended to store decisions in a file named `"decisions.json"`.
34+
It is also recommended to check the decisions file in to version control and to update it whenever significant changes to the interpreter specification are made.
35+
36+
**TODO**: does the file need to exist already?
37+
38+
39+
<!-- After it finishes executing a corpus program, the tracing interpreter persists the collected data (encoded as optimization decisions) to disk in the decisions file.
40+
When it traces subsequent corpus programs, the interpreter combines the tracing data; the resulting decisions file comprises tracing metrics from the entire corpus. -->
41+
42+
Then we can recompile the Bytecode DSL interpreter for tracing. This will create a modified version of the interpreter that traces bytecode execution at run time.
43+
To do this, recompile the project with the `truffle.dsl.BytecodeEnableTracing=true` annotation processor flag. This can be done in `mx` using:
44+
45+
```sh
46+
mx build -f -A-Atruffle.dsl.BytecodeEnableTracing=true
47+
```
48+
49+
### Step 2: Collect tracing data
50+
51+
When the tracing interpreter is run on one or more programs (the tracing *corpus*), it collects tracing data that is used to infer optimization decisions.
52+
Though tracing is automated, selecting the corpus should be an intentional process:
53+
54+
* The corpus should be representative of actual code written in the guest language. Ideally, the corpus should not be a suite of micro-benchmarks, but should instead be composed of real-world applications.
55+
* Bytecode DSL will try to optimize for specific patterns found in the corpus. For this reason, if guest language code is typically written in multiple different styles/paradigms, they should all be represented in the corpus.
56+
* In general, Bytecode DSL uses heuristics to make *the corpus* run as best as it can. It infers optimization decisions that may not generalize to other guest programs. You should use external benchmarks (that do not belong to the corpus) to validate the efficacy of the optimization decisions.
57+
58+
**TODO: can we avoid the state file?**
59+
60+
To run the corpus with tracing enabled, you must first create a *state file*, which is used to persist tracing data across executions.
61+
Here, we will store it in `/tmp`:
62+
63+
```
64+
touch /tmp/state.json
65+
```
66+
67+
Then, run the tracing interpreter on each program in the corpus, specifying the state path via the as `engine.BytecodeTracingState` Polyglot option.
68+
Each program in the corpus should be run serially (locking the state file is used to prevent concurrent runs).
69+
Each program may use internally multithreading, but any non-determinism is discouraged, as it may make the optimization decisions non-deterministic as well.
70+
71+
If you want to see a summary of optimization decisions, you can also set the `engine.BytecodeDumpDecisions` Polyglot option to `true`. This will print the resulting decisions to the Polyglot log.
72+
73+
After each program in the corpus is executed, the decisions file specified with `decisionsFile` is automatically updated with the current set of optimization decisions.
74+
75+
### Step 3: Apply optimization decisions
76+
77+
To apply the optimization decisions, simply recompile the interpreter without the tracing enabled. For example, with `mx`, just run:
78+
79+
```sh
80+
mx build -f
81+
```
82+
83+
This will regenerate the interpreter without the tracing calls. Bytecode DSL will take the decisions (stored in the decisions file) into account when generating the bytecode interpreter.
84+
85+
### (Optional) Manually overriding the decisions
86+
87+
In addition to the decisions automatically inferred with tracing, you may wish to manually to specify additional optimization decisions to Bytecode DSL.
88+
The `@GenerateBytecode` annotation has a second attribute, `decisionOverrideFiles`, whereby you can specify additional `json` files with these manually-encoded decisions. The format for the decisions is described below.
89+
90+
#### Decisions file format
91+
92+
**TODO**

0 commit comments

Comments
 (0)