Skip to content

Tiny VM ISA

DistributedBytecode VMReference
Beta Works · surface still evolving · the ISA is stable within wire v17; new opcodes require a wire version bump, and every one so far has had one

One instruction is one little-endian 64-bit word, byte-identical to the wire crate’s Instruction { opcode, flags, reg_a, reg_b, imm }, so a frame’s instruction block is cast straight into the VM’s code slice — there is no decode step.

bits 0..8 opcode
bits 8..16 flags
bits 16..24 reg_a (destination / first operand)
bits 24..32 reg_b (second operand)
bits 32..64 imm (column index, literal, jump delta, slot, or packed operands)

Two-address form: an ALU op writes its result into reg_a. Jumps are relative to the current program counter. Falling off the end of the code ends the row.

  • 256 general registers, 64 bits each, persisting across rows of one block. Programs use the low registers as an expression stack; accumulators are reserved from the top (255 downwards).
  • A NULL byte per register: a register holding NULL always holds 0 as well. That single invariant keeps the jumps NULL-free — JMP_FALSE fires on NULL because the value is 0 — while the value ops need one test so that NULL = NULL does not compute to 1.
  • Per-column packed cursors for nullable columns (below).
  • Join state: a per-row composite-hash accumulator, the current match record, and a resume index into a 1:N match list.
OpcodeCodeFormSemantics
HALT0x00End the row.
LOAD_COL0x01a, imm=cola ← column col at the current row. With NULLABLE, through the rank dictionary; a NULL row loads 0 and marks a NULL.
LOAD_IMM0x02a, imma ← sign-extended 32-bit literal.
ADD0x10a, ba += b (wrapping).
SUB0x11a, ba -= b (wrapping).
MUL0x12a, ba *= b (wrapping).
DIV0x13a, bSigned a /= b. A zero divisor makes a NULL — never a fault.
EQ0x20a, ba ← 1 if equal, else 0.
GT0x21a, ba ← 1 if a > b (signed). < is a swapped GT.
NOT0x22aa ← 1 if a == 0. !=, <=, >= are EQ/GT plus NOT.
JMP_FALSE0x30a, immJump by imm if a == 0 (or NULL).
JMP_TRUE0x31a, immJump by imm if a != 0.
YIELD_MATCH0x40Emit the current row. With WITH_OFFSETS, its offset is appended to the materialize chunk.
YIELD_COL0x41a, imm=slotWrite a into output column slot for this row (slot-major). Must precede the row’s YIELD_MATCH.
ACC_SUM0x50acc, bacc += b unless b is NULL. acc starts NULL.
ACC_COUNT0x51acc, bacc += 1 unless b is NULL. Starts 0.
ACC_MAX0x52acc, bSigned max, NULL-skipping; starts NULL.
ACC_MIN0x53acc, bSigned min, NULL-skipping; starts NULL.
HASH_PROBE0x60a, immProbe the broadcast table with the row’s composite hash. Hit: a ← build row offset, select match k. Miss: jump by imm.
HASH_ACC0x61key, val, immGroup by key; apply accumulator imm & 0xFF of kind imm >> 8 (0 SUM, 1 COUNT, 2 MAX, 3 MIN) to val. NULL keys share one group; NULL values are skipped.
HASH_MIX0x62aFold a into the row’s composite hash. A NULL component poisons the hash so the probe misses.
LOAD_BUILD0x63a, imm=cola ← embedded column col of the current match record, NULL per its validity mask.
HASH_PROBE_OUTER0x64a, immAs HASH_PROBE on a hit. On a miss, NULL-extend a and every later LOAD_BUILD, then jump by imm — to the materialisation phase. LEFT JOIN.
FlagBitOnMeaning
NULL_IS_FALSE0comparisonsReserved: a NULL operand makes the comparison false rather than NULL.
UNSIGNED1comparisonsReserved: unsigned ordering.
NULLABLE2LOAD_COLThe column’s stripe is packed; address it through the null bitmap and rank dictionary.
WITH_OFFSETS3YIELD_MATCHAppend each yielded row’s offset to the materialize chunk (a join’s build input).

The packed-stripe trap and the rank dictionary

Section titled “The packed-stripe trap and the rank dictionary”

Fixed-width .acf stripes are packed: a NULL row occupies zero bytes. Reading data[row * 8] on a column with NULLs mis-keys every row after the first NULL and still returns plausible integers — nothing fails loudly. So:

  • A nullable column travels to the VM as stripe + null bitmap + RankDictionary. The packed index of a row is the bitmap’s rank (the number of valid rows before it), answered in O(1).
  • Rows are visited in order, so most lookups are row == last + 1: one bit test advances a per-column cursor. Any other row — the first, or a resume after a chunk flush — seeks through the dictionary once.
  • The safety valve: VmState::new refuses a program that reads a bitmapped column without NULLABLE. The worker answers EXEC_ABORT. This is tested by compiling the same query with the column declared dense and asserting the refusal.

Before the row loop starts, the VM reads the program once (Shape::of) and picks one of four loops, each holding only the arms it can execute:

LoopChosen whenArms
Filterno yields of columns, no accumulators, no probeconditionals, jumps, YIELD_MATCH
MaterializeYIELD_COL presentFilter + YIELD_COL
AggregateACC_* or HASH_ACC presentconditionals + accumulators + group table
JoinHASH_PROBE / HASH_PROBE_OUTER presentMaterialize + HASH_MIX, probes, LOAD_BUILD

Each loop is also monomorphised on nullability. A dense program runs with no NULL bookkeeping at all; a program with a nullable column, a DIV, or a join runs the NULL-tracking instantiation. The common arms are duplicated across the four loops on purpose: a shared helper is one function again, which is what spilled registers.

Shape::of refuses programs that mix an aggregate with a row yield, mix plain and grouped accumulators, exceed 16 output slots or 16 accumulators, or use join ops without a probe.

The caller owns every output buffer. execute_into(start, matches, cells, valid) runs rows [start, row_count) and stops when the chunk fills, reporting the row to resume from. The worker flushes the chunk and calls again — that is the whole streaming mechanism, and it needs no buffer larger than one chunk however many rows the answer has.

  • Offsets: 4,092 per chunk (4,092 × 16 B + 64 B header fills the 64 KiB ring exactly).
  • Cells: the row cap is derived from the ring for the program’s slot count.
  • Aggregates never truncate; a full group table stops with bad_opcode = HASH_ACC and the worker refuses rather than merging groups.
  • A YIELD_COL on a full buffer truncates at the row start, so the row is re-run whole on resume.

A key with N build rows yields N pairs. On a hit the probe selects match k; if k + 1 < count, the row is re-run from pc = 0 with k + 1 before the outer loop advances. The re-run is reached both by falling off the end and by a skip jump, so a pair filtered out by a post-probe conjunct still advances the list. A chunk cut mid-list stores k in the VM so the resume re-enters the list where it stopped instead of re-emitting pairs. Tested with a three-row list pushed through seven-slot chunks.

MeasureValue
Dispatch, mock block0.78–0.98 ns per instruction (build-dependent)
Dense 1 M-row filter, 7.4 M instructions5.8 ms → 0.78 ns per instruction, 5.8 ns per row
Nullable 1 M-row filter, 4.3 M instructions4.8 ms → 1.12 ns per instruction
Heap allocations per chunk0 (asserted with a counting allocator)