Skip to content

Latest commit

 

History

History
218 lines (172 loc) · 7.29 KB

File metadata and controls

218 lines (172 loc) · 7.29 KB

Executor (JavaScript backend)

src/nsengine/executor.ts

JSExecutor is the reference implementation of the nanoscript VM: a stack machine whose stack slots hold raw JavaScript values.

const executor = new JSExecutor(1024);   // the size argument is currently ignored
const result = executor.execute(program);
const result = executor.executeDebug(program);   // traces each instruction + stack

State

class JSExecutor {
    stack: any[] = [];      // operand stack AND call frames AND locals
    heap: any[] = [];       // reserved; no opcode currently emitted touches it
    ip = 0;                 // instruction pointer
    sp = 0;                 // tracked but not authoritative — stack.length is
    fp = 0;                 // frame pointer (an index into stack)
    ret: any;               // the return-value register
    ops: Function[];        // opcode → bound handler
}

The stack is a plain Array used with push/pop, so stack.length is the real stack pointer. sp is maintained alongside it by OP_ALLOC_STACK/OP_POP_STACK but nothing reads it.

Dispatch

while (program.instructions[this.ip].opcode != prg.OP_TERM) {
    this.ops[program.instructions[this.ip].opcode]();
}

ops is built in the constructor as an array of this.op_x.bind(this) in exact opcode order. Each handler is responsible for advancing ip itself — which is what lets jumps and branches be ordinary handlers.

Adding an opcode means editing three parallel structures: the constant in program.ts, its name in OP_NAMES, and its handler position in ops. The ops.test.ts suite exists to catch drift between the first two.

Several opcodes share one handler:

Handler Opcodes
op_add OP_ADDi, OP_ADDf, OP_ADDs
op_load_local OP_LOAD_LOCAL8/32/64
op_store_local OP_STORE_LOCAL8/32/64
op_equal OP_EQUALi/f/b/s/o
op_return32 OP_STACKPOP_RET8/32/64 (three identical methods)
op_increment_local_post OP_INCREMENT_LOCALi32, OP_INCREMENT_LOCALf64

Result

if (this.stack.length > 0) return this.stack.pop();
return 0;

OP_TERM therefore returns whatever is on top of the stack. A top-level return x compiles to "push x; OP_TERM", and a trailing expression statement leaves its value there too.

execute() and executeDebug() clear stack, heap, ret, ip, sp and fp before they start, so one executor can run any number of programs and the long-lived executor NSEngine holds cannot accumulate a previous script's frame.

Calling convention

Call site

<arg1> … <argN>          pushed left-to-right
<callee load>
OP_CALL_INTERNAL target
OP_POP_STACK N
OP_PUSH_RETURN64         (only if the value is wanted)

OP_CALL_INTERNAL

this.stack.push(this.fp);        // save caller frame pointer
this.stack.push(this.ip + 1);    // return address
this.fp = this.stack.length;     // new frame starts here
this.ip = operand;

Frame layout

index                 contents
──────────────────────────────────────────
fp - 3 - k            argument (k = 0 is the last argument)
…
fp - 3                last argument
fp - 2                saved fp
fp - 1                return address
fp + 0                first local        ← OP_ALLOC_STACK reserves these
fp + 1                second local
…                     operand scratch space

Arguments live below the saved fp/return-address pair, which is why the compiler numbers them from -3 downwards. Locals are at non-negative offsets.

OP_STACKPOP_RET32 (return with a value)

this.ret = this.stack.pop();               // the return value → register
while (this.stack.length > this.fp) this.stack.pop();   // discard locals
this.ip = this.stack.pop();                // return address
this.fp = this.stack.pop();                // restore caller frame

Arguments are not popped here — OP_POP_STACK N at the call site does that.

OP_STACKPOP_VOID (implicit return)

this.ret = null;
while (this.stack.length > this.fp) this.stack.pop();
this.ip = this.stack.pop();
this.fp = this.stack.pop();

Identical to the RET variants apart from not producing a value. The unwinding loop is what keeps a function that declares locals and falls off the end from reading a local as its return address, and clearing ret keeps a void call from handing back the previous call's value.

OP_CALL_EXTERNAL

let fn = this.stack.pop();
if (argCount < 1) {
    this.ret = fn();
} else {
    let args = [];
    for (let i = 0; i < argCount; i++) args.push(this.stack.at(-(i+1)));
    this.ret = fn(...args.reverse());
}

Arguments are read, not poppedOP_POP_STACK at the call site removes them. The callee was pushed above the arguments, so it is popped first and the args are then read from the new top downwards and reversed.

Member access

op_load_member() {
    let obj = this.stack.pop();
    if (obj === null || obj === undefined) throw new Error(`Cannot read member ${name} of ${obj}`);
    const member = obj[name];
    if (member === undefined) { this.stack.push(null); return; }
    this.stack.push(typeof member === 'function' ? member.bind(obj) : member);
}

Binding is what makes console.log(x) and arr.push(x) work: the method is detached from its receiver by the time OP_CALL_EXTERNAL invokes it.

An absent member reads as null, matching the language's null-not-undefined model; a property whose value is undefined is indistinguishable from a missing one. Reading a member of null is still an error, since that is a mistake rather than a lookup miss.

Iteration

op_wrap_collection() {
    this.stack.push(new CollectionIterator(this.stack.pop()));
}

op_increment_iterator() {
    const it = this.stack.at(-1);           // peek — the iterator stays
    if (!it.hasNext()) {
        this.stack.pop();                   // drop the iterator
        this.ip = operand;                  // exit the loop
    } else {
        this.stack.push(it.next());
        this.ip++;
    }
}

CollectionIterator (src/utilities/collection_iterator.ts) chooses its strategy once, at construction: an index cursor for arrays and strings, the value iterator for Set/Map/anything with Symbol.iterator, and Object.values for a plain object. Anything else throws Value of type … is not iterable rather than iterating zero times.

Generator in the same file implements ICollectionIterator for lazy sequences but is never instantiated.

Performance characteristics

Per instruction the JS backend pays for:

  1. an array index into program.instructions
  2. a property read for .opcode
  3. an array index into ops
  4. a megamorphic function call through a bound closure
  5. a second program.instructions[this.ip] lookup inside the handler to read the operand
  6. push/pop on a polymorphic any[]

Steps 1–5 are pure overhead — roughly 10–40× the cost of the arithmetic they surround. This is precisely what the WebAssembly backend removes: it compiles the instruction stream into straight-line native code so that only step 6's equivalent (a typed-array load/store) remains. See wasm-engine.md.