lex_bytecode/op.rs
1//! Bytecode instruction set per spec §8.2.
2
3use serde::{Deserialize, Serialize};
4
5/// Constant pool entry.
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum Const {
8 Int(i64),
9 Float(f64),
10 Bool(bool),
11 Str(String),
12 Bytes(Vec<u8>),
13 Unit,
14 /// A field name, used by MAKE_RECORD/GET_FIELD.
15 FieldName(String),
16 /// A variant tag, used by MAKE_VARIANT/TEST_VARIANT/GET_VARIANT.
17 VariantName(String),
18 /// An AST NodeId, attached to Call / EffectCall for trace keying (§10.1).
19 NodeId(String),
20}
21
22#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
23pub enum Op {
24 // stack manipulation
25 PushConst(u32),
26 Pop,
27 Dup,
28
29 // locals
30 LoadLocal(u16),
31 StoreLocal(u16),
32 /// Move a local out of its slot (leaving `Unit`) instead of
33 /// cloning it (#774). The compiler rewrites the last `LoadLocal`
34 /// of each slot in a function body to this, which is sound
35 /// because bytecode is emitted in evaluation order and has no
36 /// backward jumps: nothing that runs later can read the slot.
37 /// The payoff is uniqueness at the consumer: a list accumulator
38 /// arriving at `list.cons` (or any builtin that mutates its
39 /// argument) has a refcount of one, so the copy-on-write `List`
40 /// mutates in place and element-by-element accumulation is
41 /// O(n) instead of O(n²). Hashes as `LoadLocal` in `body_hash`.
42 TakeLocal(u16),
43
44 // constructors / pattern matching
45 /// Builds a record by interning its field-name shape in
46 /// `Program.record_shapes` (#461). `shape_idx` indexes that
47 /// side-table; `field_count` is `shape.len()` cached inline so the
48 /// stack-effect verifier can compute its delta without needing a
49 /// `Program` reference. The VM pops `field_count` values off the
50 /// stack and pairs them with `Program.record_shapes[shape_idx]`.
51 ///
52 /// Externalizing the field-name vec is what lets `Op` be `Copy`,
53 /// which is the precondition for direct-threaded dispatch
54 /// (`code[pc]` becomes a register-sized read instead of an
55 /// every-step `Vec` clone).
56 MakeRecord { shape_idx: u32, field_count: u16 },
57 /// Stack-allocated record (#464). Same shape semantics as
58 /// `MakeRecord` — pops `field_count` field values and pairs them
59 /// with `Program.record_shapes[shape_idx]` — but the field values
60 /// are stored in the current frame's slab inside the VM's
61 /// `stack_record_arena`, not in a heap-allocated `IndexMap`. The
62 /// stack pushes a `Value::StackRecord` whose `slab_start` indexes
63 /// into the arena.
64 ///
65 /// Emitted by the compiler in place of `MakeRecord` at sites that
66 /// `escape::build_escape_index` proves do not escape the frame
67 /// (returned, captured, stored into another aggregate, or passed
68 /// to a call). Runtime fallback: when the frame's
69 /// `stack_record_budget_remaining` is exhausted, the op silently
70 /// degrades to the heap path (identical observable effect to
71 /// `MakeRecord`), so a single function can mix stack and heap
72 /// records without compile-time partitioning.
73 ///
74 /// `body_hash` stability (#222): canonical encoding decodes this
75 /// op back to the historical `{"MakeRecord":{"field_name_indices":
76 /// [...]}}` form, so closure identity is invariant under the
77 /// step-2 lowering.
78 AllocStackRecord { shape_idx: u32, field_count: u16 },
79 /// Request-scoped arena record (#463 slice 2a). Same shape semantics
80 /// as `MakeRecord` and `AllocStackRecord` — pops `field_count` field
81 /// values, pairs them with `Program.record_shapes[shape_idx]`, and
82 /// pushes a record handle — but the fields live in the VM's
83 /// **request-scoped** `arena_slab`, not the per-frame stack-record
84 /// arena, so the value can outlive the allocating frame as long as
85 /// the surrounding request scope (opened by
86 /// `EffectHandler::enter_request_scope`) is still active. The
87 /// resulting `Value::ArenaRecord` indexes the slab.
88 ///
89 /// Emitted (slice 2b) at sites `arena::build_arena_index` proves do
90 /// not escape the request scope. Runtime fallback: when **no** scope
91 /// is active (e.g. a non-handler context calls a function that was
92 /// compiled with arena lowering), the op silently degrades to the
93 /// `MakeRecord` heap path — identical observable effect.
94 ///
95 /// `body_hash` stability (#222): canonical encoding decodes back to
96 /// `{"MakeRecord":{"field_name_indices":[...]}}`, so closure
97 /// identity is invariant under the lowering, mirroring
98 /// `AllocStackRecord`.
99 AllocArenaRecord { shape_idx: u32, field_count: u16 },
100 MakeTuple(u16),
101 /// Frame-local tuple (#464 tuple codegen). Stack-alloc analogue of
102 /// `MakeTuple`: pops `arity` values into the VM's stack-record
103 /// arena and pushes a `Value::StackTuple` whose `slab_start`
104 /// indexes the arena. Emitted by the compiler in place of
105 /// `MakeTuple` at sites `escape::build_escape_index` proves do not
106 /// escape the frame. Runtime fallback to the heap `Value::Tuple`
107 /// path when the frame's stack-record budget is exhausted —
108 /// identical observable effect, so stack and heap tuples can mix
109 /// within one function. `body_hash` stability (#222): canonical
110 /// encoding decodes this op back to `MakeTuple(arity)`, so closure
111 /// identity is invariant under the lowering.
112 AllocStackTuple { arity: u16 },
113 /// Request-scoped arena tuple (#463 slice 2a). Tuple analogue of
114 /// `AllocArenaRecord`: pops `arity` values into the VM's
115 /// request-scoped `arena_slab` and pushes a `Value::ArenaTuple`
116 /// handle. Same fallback rule (no active scope → `MakeTuple` heap
117 /// path) and same `body_hash` invariance (decodes back to
118 /// `MakeTuple(arity)`).
119 AllocArenaTuple { arity: u16 },
120 MakeList(u32),
121 MakeVariant { name_idx: u32, arity: u16 },
122 /// Record field access. `name_idx` indexes a `Const::FieldName`
123 /// in the constant pool — the field to read. `site_idx` is a
124 /// stable per-function index assigned by the compiler at emit
125 /// time (#462 slice 1), keyed into the per-fn inline-cache table
126 /// in the VM. Replaces the pre-#462 `(fn_id << 32 | pc)` IC key
127 /// so the cache survives the future dispatch rewrite (#461) and
128 /// a JIT (#465). `body_hash` stability: the canonical encoding
129 /// drops `site_idx` and serializes as the historical `GetField(u32)`
130 /// tuple form, so closure identity (#222) is unchanged.
131 GetField { name_idx: u32, site_idx: u32 },
132 GetElem(u16), // tuple element index
133 TestVariant(u32), // pushes Bool: top-of-stack matches variant name?
134 GetVariant(u32), // extracts payload (replaces variant on stack with its args list)
135 GetVariantArg(u16), // pop variant, push its i'th arg
136 GetListLen,
137 GetListElem(u32),
138 /// Pop [list, value]; push list with `value` appended.
139 ListAppend,
140 /// Pop list; push it indexed by the integer on top.
141 /// Stack: [list, idx] → [list[idx]]. (Like GetListElem(u32) but
142 /// the index is dynamic.)
143 GetListElemDyn,
144
145 // control flow
146 Jump(i32),
147 JumpIf(i32), // pops Bool
148 JumpIfNot(i32),
149 Call { fn_id: u32, arity: u16, node_id_idx: u32 },
150 TailCall { fn_id: u32, arity: u16, node_id_idx: u32 },
151 /// Build a Value::Closure: pop `capture_count` values (in order) and
152 /// pair them with `fn_id`.
153 MakeClosure { fn_id: u32, capture_count: u16 },
154 /// Call a closure: pop `arity` args + 1 closure (top of stack), invoke.
155 CallClosure { arity: u16, node_id_idx: u32 },
156 /// Stable sort-by-key (#338). Stack: `[xs, f]` (xs underneath).
157 /// Pops the key-fn `f` and the list `xs`, applies `f` to each
158 /// element to derive a sortable key, returns the list reordered
159 /// so keys ascend. Keys must be one of `Int` / `Float` / `Str`;
160 /// other key types pair-wise compare as equal (preserving
161 /// insertion order). `node_id_idx` is the originating NodeId.
162 SortByKey { node_id_idx: u32 },
163 /// Parallel map (#305 slice 1). Stack: `[xs, f]` (xs underneath).
164 /// Pops the closure `f` and the list `xs`, applies `f` to each
165 /// element in parallel via OS threads, pushes the result list
166 /// in input order. `node_id_idx` is the originating NodeId for
167 /// trace keying. The pool size is capped by
168 /// `LEX_PAR_MAX_CONCURRENCY` (default = available CPU cores).
169 ///
170 /// Slice 1 limitation: closures invoking effects fail at
171 /// runtime with `VmError::Effect`. The per-thread effect handler
172 /// split is queued as slice 2.
173 ParallelMap { node_id_idx: u32 },
174 /// Map a list (#464 list-builder fast path). Stack: `[xs, f]` (xs
175 /// underneath). Pops the closure `f` and list `xs`, applies `f` to
176 /// each element, pushes the result list. Native opcode (mirrors
177 /// `SortByKey`/`ParallelMap`) rather than an inlined bytecode loop:
178 /// the loop form re-`LoadLocal`'d (cloned) the whole input and
179 /// accumulator lists each iteration — O(n²) — whereas the VM here
180 /// owns `xs` and builds the output with one pre-sized allocation.
181 ListMap { node_id_idx: u32 },
182 /// Filter a list (#464). Stack: `[xs, pred]`. Pops `pred` and `xs`,
183 /// keeps the elements for which `pred(x)` is true. Native, same
184 /// rationale as `ListMap`.
185 ListFilter { node_id_idx: u32 },
186 /// Left-fold a list (#464). Stack: `[xs, init, f]` (xs deepest).
187 /// Pops `f`, `init`, `xs`; threads `acc = f(acc, x)` from `init`
188 /// over the elements; pushes the final `acc`. Native, same
189 /// rationale as `ListMap`.
190 ListFold { node_id_idx: u32 },
191 /// EFFECT_CALL `<effect_kind_const_idx>` `<op_name_const_idx>` `<arity>`.
192 /// Pops `arity` args, dispatches to a host effect handler, pushes result.
193 /// `node_id_idx` points to a `Const::NodeId` for trace keying.
194 EffectCall { kind_idx: u32, op_idx: u32, arity: u16, node_id_idx: u32 },
195 Return,
196 Panic(u32), // pushes constant message and aborts
197
198 // arithmetic — typed (per spec §8.2). `NumAdd`/etc. dispatch on operand
199 // type at runtime; emitted when the compiler doesn't have type info.
200 // The post-M5 plan is to lower NumAdd → IntAdd|FloatAdd in a typed pass.
201 IntAdd, IntSub, IntMul, IntDiv, IntMod, IntNeg,
202 IntEq, IntLt, IntLe,
203 FloatAdd, FloatSub, FloatMul, FloatDiv, FloatNeg,
204 FloatEq, FloatLt, FloatLe,
205 NumAdd, NumSub, NumMul, NumDiv, NumMod, NumNeg,
206 NumEq, NumLt, NumLe,
207 BoolAnd, BoolOr, BoolNot,
208
209 // strings
210 StrConcat, StrLen, StrEq,
211 BytesLen, BytesEq,
212
213 // superinstructions (#461)
214 //
215 // Fused opcodes emitted by the compiler's peephole pass to skip
216 // dispatch on common multi-op patterns. The pass leaves the
217 // original primitive ops in place at the trailing slots — the
218 // dispatch loop overrides its default `pc += 1` to step past
219 // them. Keeping `code.len()` invariant means existing
220 // Jump/JumpIf offsets remain valid without a renumbering pass.
221 /// Fused `LoadLocal(local_idx) + PushConst(imm_const_idx) +
222 /// IntAdd`. `imm_const_idx` must point to a `Const::Int`. The
223 /// dispatch arm reads the local, adds the constant, pushes the
224 /// result, and advances pc by 3 (past this op and the two
225 /// inert PushConst + IntAdd slots that follow). For
226 /// `body_hash` stability (#222) the canonical encoding decomposes
227 /// this op back to a standalone `LoadLocal(local_idx)` at hash
228 /// time; the unchanged PushConst / IntAdd at the next two
229 /// slots hash normally, so the total bytes match pre-fusion.
230 LoadLocalAddIntConst { local_idx: u16, imm_const_idx: u32 },
231 /// Fused `LoadLocal(src) + PushConst(imm_const_idx) + IntAdd +
232 /// StoreLocal(dest)` (#461 superinstruction slice 2). Bypasses
233 /// the value stack entirely: reads `locals[src]`, adds the Int
234 /// constant, writes `locals[dest]`. Advances pc by 4. Stack
235 /// delta: 0.
236 ///
237 /// The peephole pass that emits this op runs *after* slice 1,
238 /// looking for `[LoadLocalAddIntConst, ., ., StoreLocal]` where
239 /// the middle two slots are slice-1 tombstones (the original
240 /// PushConst + IntAdd). The verifier and the body-hash decoder
241 /// both treat the 3 following slots as tombstones owned by
242 /// this op.
243 LoadLocalAddIntConstStoreLocal { src: u16, imm_const_idx: u32, dest: u16 },
244 /// Fused `LoadLocal(lhs_idx) + LoadLocal(rhs_idx) + IntAdd`
245 /// (#461 superinstruction slice 3). The binary-op-on-two-locals
246 /// idiom — fires on any `a + b` where both operands are
247 /// statically-typed `Int` locals (e.g. `acc + n` in tail-recursive
248 /// accumulator loops). Reads `locals[lhs_idx]` and `locals[rhs_idx]`,
249 /// pushes the sum, advances pc by 3. Stack delta: +1.
250 ///
251 /// `body_hash` stability (#222): canonical encoding decomposes
252 /// back to a standalone `LoadLocal(lhs_idx)`. The unchanged
253 /// `LoadLocal(rhs_idx)` + `IntAdd` tombstones at pc+1 and pc+2
254 /// hash normally, so the total bytes match the pre-fusion form.
255 /// Verifier walks the tombstones as if live: their deltas
256 /// (+1 LoadLocal, -1 IntAdd) cancel, matching the unfused depth
257 /// at pc+3.
258 LoadLocalAddLocal { lhs_idx: u16, rhs_idx: u16 },
259 /// Fused `LoadLocal(lhs_idx) + LoadLocal(rhs_idx) + IntSub`
260 /// (#461 superinstruction slice 4). Sibling of `LoadLocalAddLocal`
261 /// for the typed `Int` subtraction binop — fires on any `a - b`
262 /// where both operands are `Int` locals. Reads `locals[lhs_idx]`
263 /// and `locals[rhs_idx]`, pushes `lhs - rhs`, advances pc by 3.
264 /// Stack delta: +1. Tombstone + body-hash story matches
265 /// `LoadLocalAddLocal` exactly.
266 LoadLocalSubLocal { lhs_idx: u16, rhs_idx: u16 },
267 /// Fused `LoadLocal(lhs_idx) + LoadLocal(rhs_idx) + IntMul`
268 /// (#461 superinstruction slice 4). Sibling of `LoadLocalAddLocal`
269 /// for the typed `Int` multiplication binop. Same shape: reads
270 /// the two Int locals, pushes `lhs * rhs`, advances pc by 3.
271 /// Stack delta: +1. Tombstone + body-hash story matches
272 /// `LoadLocalAddLocal` exactly.
273 LoadLocalMulLocal { lhs_idx: u16, rhs_idx: u16 },
274 /// Fused `LoadLocal(local_idx) + PushConst(imm_const_idx) +
275 /// IntEq + JumpIfNot(offset)` (#461 superinstruction slice 5,
276 /// pattern-match arm-test idiom). Fires on every numeric
277 /// pattern arm test — `match n { 0 => acc; _ => recurse }` and
278 /// the cascade of integer-literal arms in any pattern match —
279 /// after `compile_pattern_test` lowers the historical NumEq to
280 /// IntEq for Int-literal patterns. Reads the local, compares
281 /// against the Int constant; if equal, advances pc by 4 (past
282 /// the 3 tombstones, into the arm body); if not equal, jumps
283 /// to `pc + 4 + jump_offset` (the JumpIfNot's original target —
284 /// the next arm test or the panic-no-match block). Stack
285 /// delta: 0 (original sequence had +1, +1, -1, -1).
286 ///
287 /// Jump-aware peephole — slice 5 is the first fusion that
288 /// absorbs a control-flow op. The verifier walks the fused op
289 /// with both fall-through and branch successors, skipping past
290 /// the trailing three tombstones (mirroring slice 2's 4-slot
291 /// pattern). `body_hash` decodes back to a standalone
292 /// `LoadLocal(local_idx)`; the trailing primitive ops stay in
293 /// the code stream as tombstones and hash normally — so
294 /// closure identity (#222) stays invariant.
295 LoadLocalEqIntConstJumpIfNot { local_idx: u16, imm_const_idx: u32, jump_offset: i32 },
296 /// Fused `LoadLocal(src) + StoreLocal(dst) +
297 /// LoadLocalEqIntConstJumpIfNot { local_idx: dst, ... }` (#461
298 /// superinstruction slice 6). Absorbs the match-scrutinee dance
299 /// — the `LoadLocal + StoreLocal` the compiler emits to bind the
300 /// match expression to a fresh local before each arm's pattern
301 /// test reads it back. Reads `locals[src]`, mirrors the original
302 /// `StoreLocal(dst)` by writing the same value into `locals[dst]`
303 /// (so the SECOND and later arm tests in the same match still
304 /// see the scrutinee at the expected slot), then compares against
305 /// the constant. Equal → advance pc by 6 (skip past the 5
306 /// tombstones — original StoreLocal + slice-5 fused op + slice-5's
307 /// 3 trailing tombstones). Not equal → jump to
308 /// `pc + 6 + jump_offset` (the original JumpIfNot's target;
309 /// `jump_offset` is copied unchanged from the slice-5 op).
310 /// Stack delta: 0.
311 ///
312 /// `body_hash` decodes back to a standalone `LoadLocal(src)`;
313 /// the trailing 5 ops (StoreLocal, the slice-5 fused op
314 /// decoded as LoadLocal(dst), PushConst, IntEq, JumpIfNot) stay
315 /// in the code stream as tombstones and hash normally.
316 LoadLocalStoreEqIntConstJumpIfNot {
317 src: u16, dst: u16, imm_const_idx: u32, jump_offset: i32,
318 },
319 /// Fused `LoadLocal(local_idx) + GetField{name_idx, site_idx} +
320 /// IntAdd` (#461 superinstruction slice 7). Fires on the
321 /// `acc + r.field` accumulator-with-field-read idiom — the
322 /// shape any `expr + record.field` lowers to when the LHS is
323 /// already on the stack and the RHS is a same-frame record
324 /// field. After #464 step 2 dropped the IndexMap allocation
325 /// from hot-path records, this fusion is the next dispatch-
326 /// overhead bottleneck on the `response_build` profile.
327 ///
328 /// Dispatch: pops the prior stack top (an Int), reads
329 /// `locals[local_idx]`, performs the polymorphic-IC GetField
330 /// lookup keyed by `(fn_id, site_idx)` against `name_idx`,
331 /// adds the field value to the popped Int, pushes the result,
332 /// advances pc by 3.
333 ///
334 /// Stack delta: +1 (matches a bare `LoadLocal`). The trailing
335 /// `GetField` (delta 0) and `IntAdd` (delta -1) stay in the
336 /// code stream as inert tombstones; the verifier walks them as
337 /// live and their cancelling deltas leave depth at pc+3
338 /// matching the unfused form.
339 ///
340 /// `body_hash` stability (#222): canonical encoding decomposes
341 /// to a standalone `LoadLocal(local_idx)`; the unchanged
342 /// `GetField` and `IntAdd` at pc+1 and pc+2 hash normally, so
343 /// the total bytes match pre-fusion. The trailing `GetField`'s
344 /// own body-hash decoding (which strips `site_idx`) means the
345 /// hash is unchanged across recompiles where IC-site numbering
346 /// differs.
347 ///
348 /// Safety: the trailing two slots must not be jump targets
349 /// (standard tombstone rule). The first slot may be a target —
350 /// the fused op there is live.
351 LoadLocalGetFieldAdd { local_idx: u16, name_idx: u32, site_idx: u32 },
352 /// Slice 8 of #461: `IntSub` / `IntMul` siblings of slice 7's
353 /// `LoadLocalGetFieldAdd`. Fuse `LoadLocal + GetField + IntSub`
354 /// and `LoadLocal + GetField + IntMul` respectively — the
355 /// `acc - r.field` and `acc * r.field` idioms. Same tombstone,
356 /// jump-safety, body-hash (decode to `LoadLocal(local_idx)`),
357 /// and verifier (+1 delta) story as slice 7.
358 ///
359 /// `IntSub` is not commutative: the unfused sequence leaves the
360 /// field value on top, so `IntSub`'s deeper-minus-top semantics
361 /// give `acc - field`. The fused dispatch preserves that order.
362 LoadLocalGetFieldSub { local_idx: u16, name_idx: u32, site_idx: u32 },
363 LoadLocalGetFieldMul { local_idx: u16, name_idx: u32, site_idx: u32 },
364 /// Slice 9 of #461: fuse `LoadLocal(local_idx) + GetField{name_idx,
365 /// site_idx}` — the bare `record.field` read, the single most
366 /// common field-access shape. Unlike slices 7/8 there's no
367 /// arithmetic terminator; this is a 2-op window.
368 ///
369 /// The win is allocation, not just dispatch: the unfused pair
370 /// `LoadLocal` clones the entire record onto the value stack
371 /// (a `Box<IndexMap>` for a heap record), `GetField` pops it,
372 /// reads one field, and drops the rest. The fused op reads the
373 /// field out of the local by reference (`read_local_record_field`)
374 /// and clones only that one value. On the `response_build`
375 /// profile the whole-record clone+drop of the returned `Response`
376 /// (`r.total`) was the dominant malloc source.
377 ///
378 /// Stack delta: +1 (LoadLocal +1, GetField 0). The trailing
379 /// `GetField` stays as a single inert tombstone (delta 0); the
380 /// verifier walks it, leaving depth at pc+2 matching the unfused
381 /// `[LoadLocal, GetField]` pair. `body_hash` decodes to a
382 /// standalone `LoadLocal(local_idx)`; the trailing `GetField`
383 /// hashes normally.
384 ///
385 /// Safety: the trailing slot (the original `GetField`) must not
386 /// be a jump target. The first slot may be.
387 LoadLocalGetField { local_idx: u16, name_idx: u32, site_idx: u32 },
388}