Viscosity
Review
Part V · Inside real JavaScript engines · Day 18

70. GraalJS and Truffle

What if you never wrote a JavaScript compiler at all? Truffle lets you write a tree-walking interpreter whose nodes specialize themselves, and then derives the compiled code from that interpreter. GraalJS is a whole JavaScript engine built this way.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain how a self-optimizing AST node specializes itself and generalizes when a guess fails.
  • Partially evaluate a small interpreter by hand, step by step, down to its residual program.
  • Say what Truffle treats as constant during compilation, and what happens when such a value changes.
  • Compare Truffle’s approach with V8’s separate interpreter and compilers.

The question your tree-walker keeps asking

Your Part III interpreter evaluates a + b by asking the same questions every single time: what kind of node is this? Are both values numbers? Strings? In a loop that runs a million times, the answers never change, and you pay for them a million times. That is the interpreter overhead you measured in chapter 44.

V8’s answer is separate machinery: bytecode for Ignition, then compilers that rewrite hot code using what the interpreter observed[9]. Truffle, from Oracle and the Johannes Kepler University Linz, takes another road. You write an interpreter for “self-modifying Abstract Syntax Trees”, and the Graal compiler derives fast machine code from that interpreter itself[1]. GraalJS, “a JavaScript engine implemented in Java on top of GraalVM”, is built this way[5].

Two ideas make it work: nodes that specialize themselves, and partial evaluation.

Nodes that rewrite themselves

Intuition

A post-room clerk notices that every letter for a month has been local. She puts up a “local only” tray and stops checking addresses. The day a foreign letter turns up, she takes the tray down for good and goes back to checking, a little slower but always right.

A + node starts with no idea what it will see. After the first few executions it narrows itself to the case it saw, and it widens again when something else turns up:

A plus node moves from uninitialized to int, double and generic, and never backuninitializedseen nothingintint32 + int32doubleany numbersgenericfull JS +intsoverflow ora doublea string,an object…strings first: straight to generic
Specialize on what you have seen; widen when it stops being true. A self-specializing + node. Each arrow is a rewrite. Arrows only go one way, so a node can't flip back and forth forever.

This is GraalJS’s real +, trimmed. Each @Specialization is one case. The integer case uses Math.addExact, which throws on overflow:

// JSAddNormalNode.java (GraalJS)
@Specialization(rewriteOn =
    ArithmeticException.class)
static int doInt(int a, int b) {
  return Math.addExact(a, b);
}
// JSAddNode.java (GraalJS)
@Specialization
static double doDouble(double a,
    double b) {
  return a + b;
}

rewriteOn means: if this exception is thrown even once, this node never uses that case again, and the next one runs instead[4]. Further down, a generic case that first converts its operands replaces all the specific ones[5]. At the lowest level, any node can swap itself for another in the tree with replace, which also invalidates compiled code that relied on the old node[3].

Definition

A self-optimizing AST interpreter is a tree-walking interpreter whose nodes record what they have seen and rewrite themselves (or swap in a replacement node) into a version specialized for those cases, with a check that falls back and generalizes when a case fails. The GraalVM team traces the design to a 2012 paper on self-optimizing, self-specializing interpreters applied to JavaScript[6][7].

Partial evaluation

Intuition

A recipe says “if a guest is vegetarian, use beans, otherwise use beef”. Once you know tonight’s guests, you can cross out the branch that can’t happen. You are left with a shorter recipe that only works for tonight, and needs no more thinking.

Truffle’s documentation starts with exactly this kind of example[2], here in JavaScript:

function round(v, up) {
  return up ? Math.ceil(v)
            : Math.floor(v);
}
// specialized for up = true:
function roundUp(v) {
  return Math.ceil(v);
}

Now apply the same trick to an interpreter. Its two inputs are the program and the program’s data. The program doesn’t change while it runs, so fix it and simplify:

Step throughFolding an interpreter into one program
the interpreter
function interpret(prog, args) {
return prog.execute(args);
}
class Add {
execute(args) {
return this.l.execute(args) +
this.r.execute(args);
}
}
class Arg {
execute(args) {
return args[this.i];
}
}
A tiny tree-walking interpreter, like yours from Part III but with one class per node kind. prog.execute could be any node’s method, so every call is a dynamic dispatch.
1/6
Definition

Partial evaluation specializes a program for the inputs that are known (static), producing a residual program that only takes the rest (dynamic): Input -> Output becomes DynamicInput -> Output. Partially evaluating an interpreter with respect to one guest program gives code specialized to that program, with the interpretation removed. This is the first Futamura projection[2].

You can do a version of this by hand in JavaScript. Instead of a switch that runs on every evaluation, walk the tree once and build closures. The kind of each node is decided while building, not while running:

Try itPartial evaluation by hand, with closures
// a tiny AST: (args[0] + 2) * args[1]
const t = { op: '*',
  l: { op: '+',
    l: { op: 'arg', i: 0 },
    r: { op: 'num', v: 2 } },
  r: { op: 'arg', i: 1 } };

// the interpreter: decides every time
function run(n, args) {
  switch (n.op) {
    case 'num': return n.v;
    case 'arg': return args[n.i];
    case '+': return run(n.l, args) +
      run(n.r, args);
    case '*': return run(n.l, args) *
      run(n.r, args);
  }
}

// specialize: decide once, return code
function spec(n) {
  switch (n.op) {
    case 'num': {
      const v = n.v;
      return () => v;
    }
    case 'arg': {
      const i = n.i;
      return (args) => args[i];
    }
    case '+': {
      const l = spec(n.l);
      const r = spec(n.r);
      return (a) => l(a) + r(a);
    }
    case '*': {
      const l = spec(n.l);
      const r = spec(n.r);
      return (a) => l(a) * r(a);
    }
  }
}

const f = spec(t); // the tree is gone
console.log(run(t, [1, 9]), f([1, 9]));
console.log(run(t, [5, 3]), f([5, 3]));

How does Truffle know what may be treated as constant? It starts from the root node of the code being compiled and static final fields, then follows stable fields: final fields and declared @Child nodes. Fields marked @CompilationFinal can still change, so a node can record what it has seen, but once compilation starts they are folded like constants. Truffle counts calls and loop iterations, and compiles a root node (roughly, a function) once it is hot. If a recorded fact later turns out wrong, the node calls transferToInterpreterAndInvalidate: the compiled code is thrown away and execution continues in the interpreter, which re-specializes and may be compiled again[2].

Under the hood

Truffle’s documentation names the problem it avoids: a classic engine needs “semantics, profiling, and optimization knowledge to be represented in both interpreter and compiler”. With Truffle, the optimized code is derived from the interpreter. The price is that the interpreter must be written carefully, so that partial evaluation can see what is stable, and every speculation must eventually settle, or the code loops through compiling and invalidating[2]. The GraalVM team describes the whole stack, interpreter and dynamic compiler, in “One VM to Rule Them All”[8]. It is also where the columnar-array research of the next chapter was built[6].

Your turn

ExerciseA + node that specializes itself

Write AddNode, a self-specializing + node. Its children l and r have an execute(env) method. state starts as 'uninit'. In execute(env), evaluate both children, then: in 'uninit' or 'int', if both values and their sum are int32 (isInt), set 'int' and return the sum. Otherwise, unless the state is 'generic', if both are numbers set 'double'. Otherwise set 'generic'. Always return a + b. A node must never move back to a more specific state.

const Lit = (v) =>
  ({ execute: () => v });
const Var = (k) =>
  ({ execute: (env) => env[k] });
const isInt = (x) => (x | 0) === x;
const isNum = (x) =>
  typeof x === 'number';

class AddNode {
  constructor(l, r) {
    this.l = l;
    this.r = r;
    this.state = 'uninit';
  }
  execute(env) {
    const a = this.l.execute(env);
    const b = this.r.execute(env);
    return a + b;
  }
}
Extra practiceA partial evaluator that writes code

Write pe(n), which turns a tree of { op: 'num', v }, { op: 'arg', i }, { op: '+', l, r } and { op: '*', l, r } into a JavaScript expression string over an array args. A num becomes its digits, an arg becomes args[i], and an operator becomes (L op R). But if both sides came out as numbers, fold them: return the computed number as a string instead.

function pe(n) {
  return '0';
}

What you learned

  • A self-optimizing AST node records the types it sees, rewrites itself into a specialized version, and generalizes, one way only, when a guard fails. GraalJS’s + has int, double, string and generic cases.
  • Partial evaluation fixes the known inputs and simplifies. Applied to an interpreter and one program, it inlines and folds the interpreter away, leaving a residual program: the first Futamura projection.
  • Truffle treats the root node, final fields, @Child nodes and @CompilationFinal fields as constants, and invalidates compiled code when one of them must change.
  • The engine’s semantics live in one place, the interpreter.

Check your understanding

0/6
  1. 1.

    What does a Truffle language implementer mainly write?

  2. 2.

    A + node in the int state receives 2 ** 31 - 1 and 1. What should it do?

  3. 3.

    Partially evaluating round(v, up) with up = true known gives:

  4. 4.

    In the step-through, why can the specializer inline prog.l.execute?

  5. 5.

    A @CompilationFinal field that compiled code depends on must change. What does Truffle require?

  6. 6.

    What is the main trade-off of Truffle’s approach?

References

  1. [1] Oracle and the GraalVM contributors (2026). Truffle Language Implementation Framework (truffle/README.md and truffle/docs/README.md). GraalVM source repository (github.com/oracle/graal), master branch. github.com/oracle/graal/tree/master/truffle (checked 2026-10-31)
  2. [2] Oracle and the GraalVM contributors (2026). Partial Evaluation in Truffle (truffle/docs/PartialEvaluation.md). GraalVM source repository (github.com/oracle/graal), master branch. github.com/oracle/graal/blob/master/truffle/docs/PartialEvaluation.md (checked 2026-10-31)
  3. [3] Oracle and the GraalVM contributors (2026). com.oracle.truffle.api.nodes.Node (Node.java): replace(Node). GraalVM source repository (github.com/oracle/graal), master branch. github.com/oracle/graal/blob/master/truffle/src/com.oracle.truffle.api/src/com/oracle/truffle/api/nodes/Node.java (checked 2026-10-31)
  4. [4] Oracle and the GraalVM contributors (2026). com.oracle.truffle.api.dsl.Specialization (Specialization.java): rewriteOn and replaces. GraalVM source repository (github.com/oracle/graal), master branch. github.com/oracle/graal/blob/master/truffle/src/com.oracle.truffle.api.dsl/src/com/oracle/truffle/api/dsl/Specialization.java (checked 2026-10-31)
  5. [5] Oracle and the GraalJS contributors (2026). GraalJS: JSAddNode.java and JSAddNormalNode.java (the JavaScript + operator). GraalJS source repository (github.com/oracle/graaljs), master branch. github.com/oracle/graaljs/blob/master/graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/nodes/binary/JSAddNode.java (checked 2026-10-31)
  6. [6] Oracle and the GraalVM contributors (2026). GraalVM Academic Publications (docs/Publications.md). GraalVM source repository (github.com/oracle/graal), master branch. github.com/oracle/graal/blob/master/docs/Publications.md (checked 2026-10-31)
  7. [7] Thomas Würthinger, Andreas Wöß, Lukas Stadler, Gilles Duboscq, Doug Simon, Christian Wimmer (2012). Self-Optimizing AST Interpreters. Proceedings of the 8th Symposium on Dynamic Languages (DLS ’12), ACM. doi:10.1145/2384577.2384587 (checked 2026-10-31)
  8. [8] Thomas Würthinger, Christian Wimmer, Andreas Wöß, Lukas Stadler, Gilles Duboscq, Christian Humer, Gregor Richards, Doug Simon, Mario Wolczko (2013). One VM to Rule Them All. Onward! 2013 (SPLASH 2013), ACM, pp. 187–204. doi:10.1145/2509578.2509581 (checked 2026-10-31)
  9. [9] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)