Skip to main content

Module slots

Module slots 

Source
Expand description

Which locals can live in fusevm frame slots instead of the host’s scope chain.

Every identifier in a node-js program is a name lookup: the compiler emits CallBuiltin(GETLOCAL) with the name as a string constant, and the host pops it off the VM stack, borrows the thread-local JsHost, walks the Rc<RefCell<EnvData>> chain and hashes the name in each scope. An empty for (let i = 0; i < 5_000_000; i++) {} costs four of those round-trips per iteration — 178 ns against node’s 6 ns — and none of them is JIT-able, since fusevm’s block tier declines any region containing a CallBuiltin.

A local that no closure can reach does not need a scope entry at all: it can live in the frame slot vector fusevm already keeps, addressed by index (Op::GetSlot / Op::SetSlot). This module decides which names qualify. The shape of the analysis follows pythonrs’s fn_slots_allowed / fn_slots, which does the same job against the same VM.

The rules are deliberately conservative, because a name that is slotted in one place and looked up by name in another is a silent wrong answer:

  1. A name reachable from another chunk keeps its binding. A nested function, arrow or class body compiles to its own chunk and resolves what it captures through the environment chain; a try block is likewise its own chunk on its own VM frame, so a slot written outside it is invisible inside. Every identifier mentioned in either is therefore off the table — but only those identifiers, not the whole chunk, so a loop counter still gets a slot in a file that also defines a callback. A direct eval can name anything, so it disables the chunk outright.
  2. One declaration per name. Shadowing (let x in two sibling blocks) is exactly where a flat name→slot table would be wrong, so a name declared more than once in the chunk is left alone rather than scope-tracked.
  3. No read before the declaration, in source order. This is what keeps the temporal dead zone and var hoisting behaving as they do today: a slot reads as undefined before its first write, which is neither node’s ReferenceError for a let nor what node-js currently answers. A name whose first mention is a read stays on the name path, where those answers come from. Source order is a conservative stand-in for execution order — a loop can re-enter a block, but it cannot reach a statement the source has not introduced yet.
  4. Simple identifiers only. Destructuring targets, delete x, and anything that is not an Expr::Ident bind through the host.
  5. At the top level of a script, let/const only. A top-level var is a property of the global object (var g = 1; globalThis.g is 1 on node v26.7.0), so it has to stay a real binding.

Structs§

Plan
What the compiler needs to know about a chunk’s locals.

Functions§

param_names
The simple identifier parameters, in order. A destructuring or rest pattern is bound by the body prologue, so it is not seeded here.
plan
Plan the slots for a chunk: params are bound into the environment by the caller before the chunk runs (the compiler emits a prologue that copies each into its slot), body is the statement list about to be compiled, and top_level marks a script/module body, where var stays a global.

Type Aliases§

SlotTable
Name → frame slot for one chunk. Empty when the chunk is not eligible.