Files
cell/docs/managed_stack_frames.md
2025-12-29 22:58:51 -06:00

7.1 KiB

Managed Stack Frames Implementation Plan

This document outlines the requirements and invariants for implementing fully managed stack frames in QuickJS, eliminating recursion through the C stack for JS->JS calls.

Overview

The goal is to maintain interpreter state entirely on managed stacks (value stack + frame stack) rather than relying on C stack frames. This enables:

  • Call IC fast path: Direct dispatch to C functions without js_call_c_function overhead
  • Proper stack traces: Error().stack works correctly even through optimized paths
  • Tail call optimization: Possible without C stack growth
  • Debugging/profiling: Full interpreter state always inspectable

Current State

  • Property IC: Implemented with per-function polymorphic IC (up to 4 shapes per site)
  • Call IC: Infrastructure exists but disabled (CALL_IC_ENABLED 0) because it bypasses stack frame setup required for Error().stack

Golden Invariant

At any time, the entire live interpreter state must be reconstructible from:

(ctx->value_stack, value_top) + (ctx->frame_stack, frame_top)

No critical state may live only in C locals.

Implementation Requirements

1. Offset Semantics (use size_t / uint32_t)

Replace pointer-based addressing with offset-based addressing:

typedef struct JSStackFrame {
    uint32_t sp_offset;        // Offset into ctx->value_stack
    uint32_t var_offset;       // Start of local variables
    uint32_t arg_offset;       // Start of arguments
    // ... continuation info below
} JSStackFrame;

Rationale: Offsets survive stack reallocation, pointers don't.

2. Consistent sp_offset Semantics

Define clearly and consistently:

  • sp_offset = current stack pointer offset from ctx->value_stack
  • On function entry: sp_offset points to first free slot after arguments
  • On function exit: sp_offset restored to caller's expected position

3. Continuation Info (Caller State Restoration)

Each frame must store enough to restore caller state on return:

typedef struct JSStackFrame {
    // ... other fields

    // Continuation info
    const uint8_t *caller_pc;      // Return address in caller's bytecode
    uint32_t caller_sp_offset;     // Caller's stack pointer
    JSFunctionBytecode *caller_b;  // Caller's bytecode (for IC cache)

    // Current function info
    JSFunctionBytecode *b;         // Current function's bytecode
    JSValue *var_buf;              // Can be offset-based
    JSValue *arg_buf;              // Can be offset-based
    JSValue this_val;
} JSStackFrame;

4. Exception Handler Stack Depth Restoration

Exception handlers must record the sp_offset at handler entry so throw can restore the correct stack depth:

typedef struct JSExceptionHandler {
    uint32_t sp_offset;        // Stack depth to restore on throw
    const uint8_t *catch_pc;   // Where to jump on exception
    // ...
} JSExceptionHandler;

On throw:

  1. Unwind frame stack to find appropriate handler
  2. Restore sp_offset to handler's recorded value
  3. Push exception value
  4. Jump to catch_pc

5. Aliased argv Handling

When arguments object exists, argv may be aliased. The frame must track this:

typedef struct JSStackFrame {
    // ...
    uint16_t flags;
    #define JS_FRAME_ALIASED_ARGV  (1 << 0)
    #define JS_FRAME_STRICT        (1 << 1)
    // ...
    JSObject *arguments_obj;   // Non-NULL if arguments object created
} JSStackFrame;

When JS_FRAME_ALIASED_ARGV is set, writes to arguments[i] must update the corresponding local variable.

6. Stack Trace Accuracy (sf->cur_pc)

Critical: sf->cur_pc must be updated before any operation that could:

  • Throw an exception
  • Call into another function
  • Trigger GC

Currently the interpreter does:

sf->cur_pc = pc;  // Before potentially-throwing ops

With managed frames, ensure this is consistently done or use a different mechanism (e.g., store pc in frame on every call).

7. GC Integration

The GC must be able to mark all live values on the managed stacks:

void js_gc_mark_value_stack(JSRuntime *rt) {
    for (JSContext *ctx = rt->context_list; ctx; ctx = ctx->link) {
        JSValue *p = ctx->value_stack;
        JSValue *end = ctx->value_stack + ctx->value_top;
        while (p < end) {
            JS_MarkValue(rt, *p);
            p++;
        }
    }
}

void js_gc_mark_frame_stack(JSRuntime *rt) {
    for (JSContext *ctx = rt->context_list; ctx; ctx = ctx->link) {
        JSStackFrame *sf = ctx->frame_stack;
        JSStackFrame *end = ctx->frame_stack + ctx->frame_top;
        while (sf < end) {
            JS_MarkValue(rt, sf->this_val);
            // Mark any other JSValue fields in frame
            sf++;
        }
    }
}

8. Main Interpreter Loop Changes

Transform from recursive to iterative:

// Current (recursive):
JSValue JS_CallInternal(...) {
    // ...
    CASE(OP_call):
        // Recursive call to JS_CallInternal
        ret = JS_CallInternal(ctx, func, ...);
    // ...
}

// Target (iterative):
JSValue JS_CallInternal(...) {
    // ...
    CASE(OP_call):
        // Push new frame, update pc to callee entry
        push_frame(ctx, ...);
        pc = new_func->byte_code_buf;
        BREAK;  // Continue in same loop iteration

    CASE(OP_return):
        // Pop frame, restore caller state
        ret_val = sp[-1];
        pop_frame(ctx, &pc, &sp, &b);
        sp[0] = ret_val;
        BREAK;  // Continue executing caller
    // ...
}

Call IC Integration (After Managed Frames)

Once managed frames are complete, Call IC becomes safe:

CASE(OP_call_method):
    // ... resolve method ...

    if (JS_VALUE_GET_TAG(method) == JS_TAG_OBJECT) {
        JSObject *p = JS_VALUE_GET_OBJ(method);

        // Check Call IC
        CallICEntry *entry = call_ic_lookup(cache, pc_offset, p->shape);
        if (entry && entry->cfunc) {
            // Direct C call - safe because frame is on managed stack
            push_minimal_frame(ctx, pc, sp_offset);
            ret = entry->cfunc(ctx, this_val, argc, argv);
            pop_minimal_frame(ctx);
            // Handle return...
        }
    }

    // Slow path: full call

Testing Strategy

  1. Stack trace tests: Verify Error().stack works through all call patterns
  2. Exception tests: Verify throw/catch restores correct stack depth
  3. GC stress tests: Verify all values are properly marked during GC
  4. Benchmark: Compare performance before/after

Migration Steps

  1. Add offset fields to JSStackFrame alongside existing pointers
  2. Create push_frame/pop_frame helper functions
  3. Convert OP_call to use push_frame instead of recursion (JS->JS calls)
  4. Convert OP_return to use pop_frame
  5. Update exception handling to use offset-based stack restoration
  6. Update GC to walk managed stacks
  7. Remove/deprecate recursive JS_CallInternal calls for JS functions
  8. Enable Call IC for C functions
  9. Benchmark and optimize

References

  • Current IC implementation: source/quickjs.c lines 12567-12722 (ICCache, prop_ic_*)
  • Current stack frame: source/quickjs.c JSStackFrame definition
  • OP_call_method: source/quickjs.c lines 13654-13718