EarendilEarendildjsHow djs works
Source

Values and NaN-boxing

Every JavaScript value djs handles (a number, a string, an object, undefined) fits in 64 bits. Local variables, the operand stack, array elements and object slots all hold these 64-bit words, and the host reads them straight out of wasm memory. This page explains the encoding, why it looks the way it does, and what it costs.

In short. A value is a u64. If it is not one particular kind of NaN, it is a plain f64. Otherwise its upper half says what it is and its lower half carries the payload: a small immediate, or the byte offset of a heap cell in the arena. There are no pointers in values.

Two readings of 64 bits

IEEE-754 doubles waste a large space of bit patterns on NaN: any value with all exponent bits set and a non-zero fraction is “not a number”, and JavaScript cannot tell them apart. djs claims one corner of that space for everything that is not a number:

The two encodings of a 64-bit djs value Number (any other bits) s exponent 11 bits fraction 52 bits Tagged (top 16 = 0xFFF8) 0xFFF8 quiet NaN, sign set tag 1–6 immediate, ≥ 8 reference payload immediate bits or u32 arena offset 63 48 32 0 hi word: hi >>> 16 === 0xFFF8 means tagged lo word: the payload Every NaN is stored as 0x7FF8_0000_0000_0000, so no Number can look like a tagged value.
The two encodings of a 64-bit value

Whenever arithmetic produces a NaN, djs stores the one canonical NaN (0x7FF8_0000_0000_0000). The tagged corner (0xFFF8…) is therefore never produced by arithmetic, and the test “is this a number?” is a single compare of the top 16 bits.

The tags in use, generated from the engine’s layout export:

tagnamehigh wordpayload
1UNDEFINED0xFFF8_0001immediate
2NULL0xFFF8_0002immediate
3BOOL0xFFF8_0003immediate
4INT0xFFF8_0004immediate
5HOLE0xFFF8_0005immediate
6UNINITIALIZED0xFFF8_0006immediate
8STRING0xFFF8_0008u32 arena offset of a cell
9OBJECT0xFFF8_0009u32 arena offset of a cell
10SYMBOL0xFFF8_000Au32 arena offset of a cell
11BIGINT0xFFF8_000Bu32 arena offset of a cell
12HOST_REF0xFFF8_000Cu32 arena offset of a cell
13INTERNAL0xFFF8_000Du32 arena offset of a cell

A few of these deserve a word:

  • INT is a second form of Number: an i32 in the payload. Loop counters, array indices and lengths stay in this form, and the interpreter has fast paths for it. 1 as INT and 1.0 as f64 are different bits for the same JavaScript value, which is why == on a Rust Value means bit identity, never JavaScript equality.
  • HOLE and UNINITIALIZED are internal markers: an empty slot in a sparse array literal, and a let binding still in its temporal dead zone. Neither can reach JavaScript code.
  • BIGINT is always a heap cell, even for small values, and compares by value (D1460). BigInt is rare in codemode scripts, so a second, inline form would double every BigInt code path for little gain.
  • INTERNAL lets VM-internal cells (a closure’s function template, a captured variable’s box) sit in ordinary value slots, so the garbage collector traces them like any other reference (D007).

Try it. On the docs site this example runs in djs-wasm, right in the page; its output below was generated by running it in a djs VM when the docs were built (suspended and resumed from a heap image every 50 units of fuel, as the test suite does):

liveoutput from the build
const xs = [1, 2.5, -0, NaN, 2 ** 31, 10n ** 20n];
for (const x of xs) console.log(typeof x, x, Object.is(x, -0));
let s = "codemode";
s += " builds strings with +=";
console.log(s, s.length);
number 1 false
number 2.5 false
number -0 true
number NaN false
number 2147483648 false
bigint 100000000000000000000n false
codemode builds strings with += 31

Numbers in the bytecode

The compiler picks the cheapest way to materialize a number. Small integers are immediates in the instruction, and so are true, null and friends; anything else comes from the function’s constant pool:

let a = 1, b = 1000000, c = 2.5;
function 0 <anonymous>: params=2 locals=4 stack=1 captures=0
  handler [3, 28) -> 28 depth 0
    0  async_start loc0 (<state>)
    3  push_i8 1
    5  put_loc loc1 (a)
    8  push_i32 1000000
   13  put_loc loc2 (b)
   16  push_const #0 (2.5)
   21  put_loc loc3 (c)
   24  undefined
   25  async_return loc0 (<state>)
   28  async_reject loc0 (<state>)
opcodebyteimmediatessizepop → pushcostdescription
push_i84I820 → 1Basic-> n: a small integer constant.
push_i325I3250 → 1Basic-> n: an integer constant.
push_const6Const50 → 1Basic-> v: a constant pool entry (non-int32 numbers, strings).
push_true2–10 → 1Basic-> true
undefined0–10 → 1Basic-> undefined

References are offsets, not pointers

A reference payload is a u32 byte offset into the VM’s arena, the single byte array that holds all VM state (D1051). Offset 0 is the Roots cell, so 0 doubles as “null”, and the all-zero bit pattern is the number +0, not a reference. Freshly zeroed memory is therefore always safe for the garbage collector to scan.

Every offset points at a cell: an 8-byte header (kind, GC bits, flags, size) followed by the fields of that kind. A one-byte string, for example:

Layout of the Str8 cell +0 +1 +2 +3 +4 +5 +6 +7 0 8 16 KIND u8 GC u8 FLAGS u16 SIZE u32 LEN u32 HASH u32 DATA: u8 × n variable-length tail; n follows from SIZE header traced by the GC (Value, Ref) plain data padding
The Str8 cell

and an object, whose PROTO, PROPS and ELEMENTS are references the collector follows, and whose tail of value slots it scans:

Layout of the Object cell +0 +1 +2 +3 +4 +5 +6 +7 0 8 16 24 KIND u8 GC u8 FLAGS u16 SIZE u32 CLASS u32 PROTO Ref PROPS Ref ELEMENTS Ref SLOTS: Value × n variable-length tail; n follows from SIZE header traced by the GC (Value, Ref) plain data padding
The Object cell

These diagrams are drawn from LAYOUTS, the table the cells! macro generates (D1052). The same table drives the collector’s tracing, the image verifier (D1056), the layout hash in every image’s build hash, and the host’s reader (js/src/layout.generated.ts, D1210).

How the host reads a value

On the JavaScript side a value is two little-endian u32 words. Decoding one takes one comparison:

if (hi >>> 16 !== TAG_PREFIX) {
  // a Number: reinterpret the 8 bytes as an f64
} else {
  const tag = hi & 0xffff;   // STRING, OBJECT, ...
  const cell = lo >>> 0;     // the cell's offset in the arena
}

That is what makes passing data out of the sandbox cheap (the goal of cheap data passing): a tool result that became a large array of objects in the VM is read by walking cells in wasm memory, with no serialization step inside the VM.

Trade-offs

Trade-off. NaN-boxing over a tag-plus-payload struct. QuickJS uses a 16-byte struct on 64-bit hosts (and NaN-boxes on 32-bit ones). Eight bytes per value halves the size of every stack, array and slot, and in wasm a value is one native i64 load or store (D016). The cost: NaNs must be canonicalized whenever a computation could produce one, and type tests decode bits instead of reading a tag field.

Trade-off. NaN-boxing over pointer tagging (V8’s small integers). With a tagged pointer word, every double that is not a small integer becomes a heap allocation. Codemode scripts handle JSON with many non-integral numbers, and boxing them would put pressure on the collector. NaN-boxing keeps every Number inline.

Trade-off. u32 offsets instead of pointers. Offsets make the heap position independent: a snapshot is a copy of the arena, the arena can grow and move without fixups, the host can read it, and a corrupt offset is a bounds-checked panic (a wasm trap), never undefined behavior. The price is an add and a bounds check on every access, and a 4 GiB limit per arena (offsets could be scaled by the 8-byte alignment if that ever matters).

Where to look

Decisions on this page:

  • D1050: Value encoding
  • D1051: Arena and cell header
  • D1052: Layout description macro
  • D007: Internal-reference Value tag
  • D1460: BigInt values are always heap cells, compared by value
  • D1210: Exported heap layout drives the host-side reader
  • D016: wasm32 is the primary target for performance and memory design