From f62235971a72f1eee5931c2d0da7b5958934ed84 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:46:21 -0300 Subject: [PATCH 01/22] [perf] add stack zone design doc and phase-0 spike benchmarks Design for Cog-style stack zone (flat frames + lazy context reification) written against SqueakJS semantics (send/doReturn/transferTo/closures). Synthetic fib-by-sends benchmark isolates heap contexts vs flat frames and JS vs WASM: flat frames alone give 1.38x in pure JS (incremental path inside jit.js); frames + per-method WASM compilation give 2.7x (phase 2 target). A WASM bytecode interpreter alone is break-even. Co-Authored-By: Claude Fable 5 --- perf/README.md | 40 +++++ perf/spike/as/frames.ts | 273 +++++++++++++++++++++++++++++ perf/spike/bench2.js | 68 ++++++++ perf/spike/js/mimic.js | 90 ++++++++++ perf/spike/js/variants.js | 350 ++++++++++++++++++++++++++++++++++++++ perf/stack-zone-design.md | 141 +++++++++++++++ 6 files changed, 962 insertions(+) create mode 100644 perf/README.md create mode 100644 perf/spike/as/frames.ts create mode 100644 perf/spike/bench2.js create mode 100644 perf/spike/js/mimic.js create mode 100644 perf/spike/js/variants.js create mode 100644 perf/stack-zone-design.md diff --git a/perf/README.md b/perf/README.md new file mode 100644 index 00000000..b9ff72b8 --- /dev/null +++ b/perf/README.md @@ -0,0 +1,40 @@ +# perf/ — proyecto stack zone (frames planos + reificación perezosa de Contexts) + +Ver [stack-zone-design.md](stack-zone-design.md) para el diseño completo, las reglas +de reificación verificadas contra el código, y el plan por fases. + +## spike/ — benchmarks fase 0 (sintéticos) + +Cinco variantes del mismo `fib(32)` por sends (7.05M activaciones) que aíslan +(a) contexts heap vs frames planos y (b) JS vs WASM. Resultados 2026-07-05 +(Node 20, M-series): + +| variante | ms | vs V1 | +|---|---|---| +| V1 contexts + trampolín (≈ jit.js actual) | 198 | 1.00x | +| V2 frames + trampolín (JS puro, camino incremental) | 143 | **1.38x** | +| V3 frames + intérprete de bytecodes JS | 313 | 0.63x | +| V4 frames + intérprete de bytecodes WASM | 183 | 1.08x | +| V5 frames + métodos compilados WASM (estimador fase 2) | 74 | **2.7x** | + +Correr: + +``` +npm install --no-save assemblyscript +npx asc perf/spike/as/frames.ts --target release -O3 --noAssert --runtime stub \ + --initialMemory 2 --outFile perf/spike/as/frames.wasm +node perf/spike/bench2.js # desde perf/spike/ +``` + +## harness/ — oráculo diferencial (paso 0) + +Corre `ws/client/cuis.image` headless en Node con reloj virtual determinista y +acumula un hash de la traza de ejecución (pc/método/sends en cada checkpoint). +Dos corridas del mismo VM producen el mismo hash; un VM modificado que diverge +en semántica produce otro hash → detección automática de bugs sin tests en-imagen. + +``` +node perf/harness/difftrace.js --golden # graba perf/harness/golden.json +node perf/harness/difftrace.js # compara contra el golden +node perf/harness/difftrace.js --bench # mide tiempo real (reloj de verdad) +``` diff --git a/perf/spike/as/frames.ts b/perf/spike/as/frames.ts new file mode 100644 index 00000000..39ae6bce --- /dev/null +++ b/perf/spike/as/frames.ts @@ -0,0 +1,273 @@ +// V4: frames planos + intérprete de bytecodes en WASM (espejo de V3 en variants.js). +// Diferencias deliberadas con el spike anterior (que perdió 2.2x): +// - load/store crudos sobre memoria lineal, sin objetos Int32Array +// (sin bounds checks ni indirección dataStart) +// - sin recursión del host: un solo loop de dispatch, frames en nuestra memoria +// - valores taggeados i32: smallint = (v<<1)|1, oop = dirección par + +// Mapa de memoria (offsets en bytes; --initialMemory 2 páginas = 128KB) +const METHOD_BASE: i32 = 4096; // bytecodes del método fib (u8) +const CACHE_BASE: i32 = 8192; // cache de métodos: 512 × [check:i32, method:i32] +const RCVR_OOP: i32 = 16384; // objeto receiver: [classId:i32]; oop = dirección +const ZONE_BASE: i32 = 65536; // stack zone + +const SEL_FIB: i32 = 7; +const CLASS_SMALLINT: i32 = 99; +const CLASS_FIB: i32 = 5; +const METH_FIB: i32 = 1; +const CACHE_MASK: i32 = 511; +const HASCTX_BIT: i32 = 1 << 17; + +// offsets en bytes de los slots del frame +const F_SAVEDFP: i32 = 0; +const F_SAVEDPC: i32 = 4; +const F_METHOD: i32 = 8; +const F_FLAGS: i32 = 12; +const F_CTX: i32 = 16; +const F_FIXED: i32 = 20; + +let sends: i32 = 0; + +export function init(): void { + // fib: n<2 ifTrue:[^n] ifFalse:[^(self fib: n-1) + (self fib: n-2)] + // opcodes: 0 PUSH_ARG0, 1 PUSH_C1, 2 PUSH_C2, 3 PUSH_SELF, 4 SUB, 5 LT, + // 6 JMPF , 7 SEND_FIB, 8 ADD, 9 RET + const b: i32 = METHOD_BASE; + store(b + 0, 0); // PUSH_ARG0 + store(b + 1, 2); // PUSH_C2 + store(b + 2, 5); // LT + store(b + 3, 6); // JMPF + store(b + 4, 7); // target: pc 7 + store(b + 5, 0); // PUSH_ARG0 + store(b + 6, 9); // RET + store(b + 7, 3); // PUSH_SELF + store(b + 8, 0); // PUSH_ARG0 + store(b + 9, 1); // PUSH_C1 + store(b + 10, 4); // SUB + store(b + 11, 7); // SEND_FIB + store(b + 12, 3); // PUSH_SELF + store(b + 13, 0); // PUSH_ARG0 + store(b + 14, 2); // PUSH_C2 + store(b + 15, 4); // SUB + store(b + 16, 7); // SEND_FIB + store(b + 17, 8); // ADD + store(b + 18, 9); // RET + store(RCVR_OOP, CLASS_FIB); + memory.fill(CACHE_BASE, 0, 4096); +} + +export function run(n: i32): i32 { + sends = 0; + let icc: i32 = 1000; + // frame base: rcvr @ ZONE_BASE, arg @ +4, frame @ +8 + store(ZONE_BASE, RCVR_OOP); + store(ZONE_BASE + 4, (n << 1) | 1); + const bfp: i32 = ZONE_BASE + 8; + store(bfp + F_SAVEDFP, 0); + store(bfp + F_SAVEDPC, 0); + store(bfp + F_METHOD, METH_FIB); + store(bfp + F_FLAGS, 1); + store(bfp + F_CTX, 0); + let fp: i32 = bfp; + let sp: i32 = bfp + F_FIXED - 4; + let pc: i32 = METHOD_BASE; + while (true) { + const op: i32 = load(pc); + pc++; + switch (op) { + case 0: { // PUSH_ARG0 + sp += 4; store(sp, load(fp - 4)); break; + } + case 1: { sp += 4; store(sp, 3); break; } // 1 taggeado + case 2: { sp += 4; store(sp, 5); break; } // 2 taggeado + case 3: { // PUSH_SELF + sp += 4; store(sp, load(fp - 8)); break; + } + case 4: { // SUB taggeado: (a-1)-(b-1)+1 = a-b+1 + const b2: i32 = load(sp); sp -= 4; const a: i32 = load(sp); + if ((a & b2 & 1) != 0) store(sp, a - b2 + 1); + else unreachable(); + break; + } + case 5: { // LT (comparación taggeada es monótona) + const b2: i32 = load(sp); sp -= 4; const a: i32 = load(sp); + if ((a & b2 & 1) != 0) store(sp, a < b2 ? 3 : 1); + else unreachable(); + break; + } + case 6: { // JMPF + const t: i32 = load(pc); pc++; + const c: i32 = load(sp); sp -= 4; + if (c == 1) pc = METHOD_BASE + t; + break; + } + case 7: { // SEND_FIB + sends++; + const numArgs: i32 = 1; + const rcvr: i32 = load(sp - (numArgs << 2)); + const classId: i32 = (rcvr & 1) != 0 ? CLASS_SMALLINT : load(rcvr); + const k: i32 = ((classId ^ (SEL_FIB * 31)) & CACHE_MASK) << 3; + const check: i32 = (classId << 8) | SEL_FIB; + let methodId: i32; + if (load(CACHE_BASE + k) == check) { + methodId = load(CACHE_BASE + k + 4); + } else { + store(CACHE_BASE + k, check); + store(CACHE_BASE + k + 4, METH_FIB); + methodId = METH_FIB; + } + const nfp: i32 = sp + 4; + store(nfp + F_SAVEDFP, fp); + store(nfp + F_SAVEDPC, pc); + store(nfp + F_METHOD, methodId); + store(nfp + F_FLAGS, numArgs); + store(nfp + F_CTX, 0); + const numTemps: i32 = 0; // nil-fill presente, 0 iteraciones en fib + for (let i: i32 = 0; i < numTemps; i++) store(nfp + F_FIXED + (i << 2), 0); + fp = nfp; + sp = nfp + F_FIXED - 4; + pc = METHOD_BASE; // methodStart(methodId); un solo método en el bench + icc--; if (icc <= 0) icc = 1000; + break; + } + case 8: { // ADD taggeado: a+b-1 + const b2: i32 = load(sp); sp -= 4; const a: i32 = load(sp); + if ((a & b2 & 1) != 0) store(sp, a + b2 - 1); + else unreachable(); + break; + } + case 9: { // RET + const rv: i32 = load(sp); + const flags: i32 = load(fp + F_FLAGS); + if ((flags & HASCTX_BIT) != 0) unreachable(); // widowCold: no pasa en el bench + const sfp: i32 = load(fp + F_SAVEDFP); + if (sfp == 0) return rv >> 1; + const numArgs: i32 = flags & 0xFFFF; + const rs: i32 = fp - 4 - (numArgs << 2); + store(rs, rv); + sp = rs; + pc = load(fp + F_SAVEDPC); + fp = sfp; + break; + } + default: unreachable(); + } + } + return 0; // inalcanzable +} + +export function getSends(): i32 { + return sends; +} + +// --------------------------------------------------------------------------- +// V5: frames + "métodos compilados" en WASM + trampolín (estimador de fase 2: +// codegen WASM por método). Misma estructura que V2 en JS, pero sobre memoria +// lineal. El dispatch del trampolín usa switch sobre methodId (aprox. de +// call_indirect; subestima levemente su costo). +// --------------------------------------------------------------------------- +let g_fp: i32 = 0; +let g_sp: i32 = 0; +let g_pc: i32 = 0; +let g_method: i32 = 0; +let g_running: bool = false; +let g_result: i32 = 0; +let g_icc: i32 = 1000; + +function send5(numArgs: i32): void { + sends++; + const rcvr: i32 = load(g_sp - (numArgs << 2)); + const classId: i32 = (rcvr & 1) != 0 ? CLASS_SMALLINT : load(rcvr); + const k: i32 = ((classId ^ (SEL_FIB * 31)) & CACHE_MASK) << 3; + const check: i32 = (classId << 8) | SEL_FIB; + let methodId: i32; + if (load(CACHE_BASE + k) == check) { + methodId = load(CACHE_BASE + k + 4); + } else { + store(CACHE_BASE + k, check); + store(CACHE_BASE + k + 4, METH_FIB); + methodId = METH_FIB; + } + const nfp: i32 = g_sp + 4; + store(nfp + F_SAVEDFP, g_fp); + store(nfp + F_SAVEDPC, g_pc); + store(nfp + F_METHOD, methodId); + store(nfp + F_FLAGS, numArgs); + store(nfp + F_CTX, 0); + g_fp = nfp; + g_sp = nfp + F_FIXED - 4; + g_method = methodId; + g_pc = 0; + g_icc--; if (g_icc <= 0) g_icc = 1000; +} + +function ret5(rv: i32): void { + const flags: i32 = load(g_fp + F_FLAGS); + if ((flags & HASCTX_BIT) != 0) unreachable(); + const sfp: i32 = load(g_fp + F_SAVEDFP); + if (sfp == 0) { g_running = false; g_result = rv; return; } + const numArgs: i32 = flags & 0xFFFF; + const rs: i32 = g_fp - 4 - (numArgs << 2); + store(rs, rv); + g_sp = rs; + g_pc = load(g_fp + F_SAVEDPC); + g_method = load(sfp + F_METHOD); + g_fp = sfp; +} + +function fibCompiled5(): void { + const fp: i32 = g_fp; + while (true) { + switch (g_pc) { + case 0: { + const n: i32 = load(fp - 4); + // n < 2 con n taggeado: tagged(2) = 5, comparación monótona + if ((n & 1) != 0 && n < 5) { ret5(n); return; } + g_sp += 4; store(g_sp, load(fp - 8)); // self + if ((n & 1) != 0) { g_sp += 4; store(g_sp, n - 2); } // n-1 taggeado + else unreachable(); + g_pc = 1; send5(1); return; + } + case 1: { + g_sp += 4; store(g_sp, load(fp - 8)); // self + const n: i32 = load(fp - 4); + if ((n & 1) != 0) { g_sp += 4; store(g_sp, n - 4); } // n-2 taggeado + else unreachable(); + g_pc = 2; send5(1); return; + } + case 2: { + const b: i32 = load(g_sp); const a: i32 = load(g_sp - 4); + if ((a & b & 1) != 0) { g_sp -= 4; store(g_sp, a + b - 1); } + else unreachable(); + ret5(load(g_sp)); + return; + } + default: unreachable(); + } + } +} + +export function run5(n: i32): i32 { + sends = 0; + g_icc = 1000; + store(ZONE_BASE, RCVR_OOP); + store(ZONE_BASE + 4, (n << 1) | 1); + const bfp: i32 = ZONE_BASE + 8; + store(bfp + F_SAVEDFP, 0); + store(bfp + F_SAVEDPC, 0); + store(bfp + F_METHOD, METH_FIB); + store(bfp + F_FLAGS, 1); + store(bfp + F_CTX, 0); + g_fp = bfp; + g_sp = bfp + F_FIXED - 4; + g_pc = 0; + g_method = METH_FIB; + g_running = true; + while (g_running) { + switch (g_method) { + case METH_FIB: fibCompiled5(); break; + default: unreachable(); + } + } + return g_result >> 1; +} diff --git a/perf/spike/bench2.js b/perf/spike/bench2.js new file mode 100644 index 00000000..5acdf4ba --- /dev/null +++ b/perf/spike/bench2.js @@ -0,0 +1,68 @@ +"use strict"; +// Bench fase 0 del diseño stack-zone: 5 variantes, mismo fib(32) por sends. +const fs = require("fs"); +const path = require("path"); +const mimic = require("./js/mimic.js"); // V0: contexts + recursión JS (spike anterior) +const { makeContextVM, makeFrameTrampolineVM, makeFrameInterpVM } = require("./js/variants.js"); + +const N = 32, EXPECT_RESULT = 2178309, EXPECT_SENDS = 7049155; +const WARMUP = 3, RUNS = 7; + +async function loadWasm() { + const bytes = fs.readFileSync(path.join(__dirname, "as/frames.wasm")); + const { instance } = await WebAssembly.instantiate(bytes, { + env: { abort: () => { throw new Error("wasm abort"); } }, + }); + instance.exports.init(); + return instance.exports; +} + +function bench(name, runFn) { + let best = Infinity, r; + for (let i = 0; i < WARMUP; i++) r = runFn(N); + for (let i = 0; i < RUNS; i++) { + r = runFn(N); + if (r.ns < best) best = r.ns; + } + if (r.result !== EXPECT_RESULT) throw Error(name + ": resultado " + r.result); + // V0 cuenta la activación raíz como send; V1-V4 no (la arman a mano) + if (r.sends !== EXPECT_SENDS && r.sends !== EXPECT_SENDS - 1) + throw Error(name + ": sends " + r.sends); + return { name, ms: best / 1e6, sendsPerSec: EXPECT_SENDS / (best / 1e9) }; +} + +(async () => { + const wasm = await loadWasm(); + const wasmRun = (n) => { + const t0 = process.hrtime.bigint(); + const result = wasm.run(n); + const t1 = process.hrtime.bigint(); + return { result, sends: wasm.getSends(), ns: Number(t1 - t0) }; + }; + + const rows = [ + bench("V0 contexts + recursión JS (spike anterior, referencia)", mimic), + bench("V1 contexts + trampolín (≈ jit.js actual)", makeContextVM()), + bench("V2 frames + trampolín (jit.js con stack zone, JS puro)", makeFrameTrampolineVM()), + bench("V3 frames + intérprete bytecodes JS", makeFrameInterpVM()), + bench("V4 frames + intérprete bytecodes WASM", wasmRun), + bench("V5 frames + métodos compilados WASM (estimador fase 2)", (n) => { + const t0 = process.hrtime.bigint(); + const result = wasm.run5(n); + const t1 = process.hrtime.bigint(); + return { result, sends: wasm.getSends(), ns: Number(t1 - t0) }; + }), + ]; + + const base = rows[1].ms; // V1 = arquitectura actual + console.log(`fib(${N}) = ${EXPECT_RESULT}, ${(EXPECT_SENDS / 1e6).toFixed(2)}M sends, best of ${RUNS} (tras ${WARMUP} warmup)\n`); + for (const r of rows) { + const ratio = base / r.ms; + console.log( + r.ms.toFixed(1).padStart(7) + " ms " + + (r.sendsPerSec / 1e6).toFixed(1).padStart(6) + "M sends/s " + + (ratio >= 1 ? (ratio.toFixed(2) + "x más rápido que V1") : ((r.ms / base).toFixed(2) + "x más lento que V1")).padStart(24) + " " + + r.name + ); + } +})(); diff --git a/perf/spike/js/mimic.js b/perf/spike/js/mimic.js new file mode 100644 index 00000000..b6c03eb7 --- /dev/null +++ b/perf/spike/js/mimic.js @@ -0,0 +1,90 @@ +"use strict"; +// Synthetic microbenchmark mimicking the shape of SqueakJS's hot loop as measured +// in the CPU profile: findSelectorInClass (linear-probe method dict lookup), +// executeNewMethod (context allocation from a recycle pool, arg copy, temp nil-fill), +// doReturn (context recycling). Not a real Smalltalk interpreter -- just the same +// data-structure shapes and control flow, run recursively via a fib(n) send tree +// so we get millions of sends of a realistic shape. + +function makeMethodDict(entries) { + var size = 1; + while (size < entries.length * 4) size *= 2; // keep load factor low, like a real MethodDict + var selectors = new Array(size).fill(-1); // -1 == nil + var methods = new Array(size).fill(-1); + var mask = size - 1; + for (var i = 0; i < entries.length; i++) { + var sel = entries[i][0], meth = entries[i][1]; + var idx = sel & mask; + while (selectors[idx] !== -1) idx = (idx + 1) & mask; + selectors[idx] = sel; + methods[idx] = meth; + } + return { selectors: selectors, methods: methods, mask: mask }; +} + +function lookupSelectorInDict(mDict, selectorHash) { + var idx = selectorHash & mDict.mask; + while (true) { + if (mDict.selectors[idx] === selectorHash) return mDict.methods[idx]; + if (mDict.selectors[idx] === -1) return -1; // nil + idx = (idx + 1) & mDict.mask; + } +} + +var SEL_FIB = 1; +var METH_FIB = 100; +var fibClassDict = makeMethodDict([[SEL_FIB, METH_FIB]]); + +var FRAME_SIZE = 8; // sender + arg + temps, like Squeak's Context_tempFrameStart layout +var freeList = []; +var sendCount = 0; + +function allocateContext() { + var ctx = freeList.pop(); + if (!ctx) ctx = { pointers: new Array(FRAME_SIZE) }; + return ctx; +} +function recycleContext(ctx) { + freeList.push(ctx); +} + +function executeNewMethod(senderCtx, arg) { + sendCount++; + var found = lookupSelectorInDict(fibClassDict, SEL_FIB); // findSelectorInClass equivalent + if (found !== METH_FIB) throw new Error("lookup failed"); + var ctx = allocateContext(); + ctx.sender = senderCtx; + ctx.pointers[0] = arg; + ctx.pointers[1] = 0; + for (var i = 2; i < FRAME_SIZE; i++) ctx.pointers[i] = null; // fill temps with nil + return ctx; +} + +function doReturn(ctx) { + var sender = ctx.sender; + recycleContext(ctx); + return sender; +} + +function fibSend(senderCtx, n) { + var ctx = executeNewMethod(senderCtx, n); + var result; + if (n < 2) { + result = n; + } else { + var a = fibSend(ctx, n - 1); + var b = fibSend(ctx, n - 2); + result = a + b; + } + doReturn(ctx); + return result; +} + +module.exports = function run(n) { + sendCount = 0; + freeList.length = 0; + var t0 = process.hrtime.bigint(); + var result = fibSend(null, n); + var t1 = process.hrtime.bigint(); + return { result: result, sends: sendCount, ns: Number(t1 - t0) }; +}; diff --git a/perf/spike/js/variants.js b/perf/spike/js/variants.js new file mode 100644 index 00000000..f6b887a7 --- /dev/null +++ b/perf/spike/js/variants.js @@ -0,0 +1,350 @@ +"use strict"; +// Spike fase 0 del diseño stack-zone (ver ../stack-zone-design.md). +// Tres variantes JS del mismo benchmark fib-por-sends, para aislar dos efectos: +// V1: contexts heap + trampolín ≈ arquitectura actual (jit.js + vm.interpreter.js) +// V2: frames planos + trampolín ≈ jit.js con stack zone (camino JS incremental) +// V3: frames planos + intérprete de bytecodes ≈ intérprete fase-1 (espejo del WASM V4) +// Todas hacen el mismo trabajo por send: probe de cache de métodos, chequeo de clase +// del receiver, contador de interrupciones, y (V2/V3) chequeo de hasContext al retornar. + +const SEL_FIB = 7; +const CLASS_SMALLINT = 99; +const CLASS_FIB = 5; +const METH_FIB = 1; +const CACHE_MASK = 511; + +function makeCache() { + // direct-mapped: [checkWord, methodId] × 512 + const cache = new Int32Array((CACHE_MASK + 1) * 2); + return cache; +} + +function cacheLookup(cache, classId, selId) { + const k = ((classId ^ (selId * 31)) & CACHE_MASK) << 1; + const check = (classId << 8) | selId; + if (cache[k] === check) return cache[k + 1]; + // slow path: "búsqueda" y fill (en el bench siempre encuentra METH_FIB) + cache[k] = check; + cache[k + 1] = METH_FIB; + return METH_FIB; +} + +// --------------------------------------------------------------------------- +// V1: contexts heap + trampolín (modela la arquitectura actual) +// Context = objeto con array `pointers`: [sender, pc, sp, method, receiver, arg0, ...opstack] +// Reciclado por free-list como allocateOrRecycleContext/recycleIfPossible. +// --------------------------------------------------------------------------- +const CTX_SENDER = 0, CTX_PC = 1, CTX_SP = 2, CTX_METHOD = 3, CTX_RCVR = 4, CTX_TEMP0 = 5; +const CTX_SIZE = 24; + +function makeContextVM() { + const cache = makeCache(); + const rcvrObj = { classId: CLASS_FIB }; + const vm = { + activeCtx: null, pc: 0, sp: 0, methodId: 0, + running: false, result: 0, sends: 0, icc: 1000, + freeCtx: null, + }; + + function allocCtx() { + let ctx = vm.freeCtx; + if (ctx) { vm.freeCtx = ctx.pointers[CTX_SENDER]; return ctx; } + return { pointers: new Array(CTX_SIZE).fill(null) }; + } + + function send(selId, numArgs) { + vm.sends++; + const stack = vm.activeCtx.pointers; + const rcvr = stack[vm.sp - numArgs]; + const classId = typeof rcvr === "number" ? CLASS_SMALLINT : rcvr.classId; + const methodId = cacheLookup(cache, classId, selId); + // storeContextRegisters: guardar pc/sp en el context actual + stack[CTX_PC] = vm.pc; + stack[CTX_SP] = vm.sp - numArgs - 1; // sp tras popear rcvr+args + const ctx = allocCtx(); + const p = ctx.pointers; + p[CTX_SENDER] = vm.activeCtx; + p[CTX_METHOD] = methodId; + // copiar receiver + args al nuevo context (como executeNewMethod:1046) + p[CTX_RCVR] = rcvr; + for (let i = 0; i < numArgs; i++) p[CTX_TEMP0 + i] = stack[vm.sp - numArgs + 1 + i]; + // nil-fill de temps (fib: 0 temps extra — loop presente igual) + const numTemps = 0; + for (let i = 0; i < numTemps; i++) p[CTX_TEMP0 + numArgs + i] = null; + vm.activeCtx = ctx; + vm.methodId = methodId; + vm.pc = 0; + vm.sp = CTX_TEMP0 + numArgs + numTemps - 1; // tope del opstack (vacío) + if (--vm.icc <= 0) vm.icc = 1000; + } + + function doReturn(rv) { + const ctx = vm.activeCtx; + const sender = ctx.pointers[CTX_SENDER]; + // escaneo mínimo de unwind (target === sender, un compare como hoy) + // nil sender/ip + reciclar (doReturn:1103-1107) + ctx.pointers[CTX_SENDER] = vm.freeCtx; // reuso del slot como link de free-list + ctx.pointers[CTX_PC] = null; + vm.freeCtx = ctx; + if (sender === null) { vm.running = false; vm.result = rv; return; } + // fetchContextRegisters + const sp = sender.pointers[CTX_SP]; + vm.activeCtx = sender; + vm.methodId = sender.pointers[CTX_METHOD]; + vm.pc = sender.pointers[CTX_PC]; + vm.sp = sp + 1; + sender.pointers[sp + 1] = rv; // push del resultado + } + + // fib "compilado" al estilo jit.js: switch sobre pc, return al trampolín en cada send + function fibCompiled(vm) { + const stack = vm.activeCtx.pointers; + while (true) switch (vm.pc) { + case 0: { + const n = stack[CTX_TEMP0]; + if (typeof n === "number" && n < 2) { doReturn(n); return; } + stack[++vm.sp] = stack[CTX_RCVR]; + const a = n; // inline #- con chequeo como generateNumericOp + if (typeof a === "number") stack[++vm.sp] = a - 1; else throw Error("fail"); + vm.pc = 1; send(SEL_FIB, 1); return; + } + case 1: { + stack[++vm.sp] = stack[CTX_RCVR]; + const a = stack[CTX_TEMP0]; + if (typeof a === "number") stack[++vm.sp] = a - 2; else throw Error("fail"); + vm.pc = 2; send(SEL_FIB, 1); return; + } + case 2: { + const b = stack[vm.sp], a = stack[vm.sp - 1]; + if (typeof a === "number" && typeof b === "number") stack[--vm.sp] = a + b; + else throw Error("fail"); + doReturn(stack[vm.sp]); + return; + } + } + } + + const methodFns = [null, fibCompiled]; + + return function run(n) { + vm.freeCtx = null; vm.sends = 0; vm.icc = 1000; + const base = { pointers: new Array(CTX_SIZE).fill(null) }; + base.pointers[CTX_SENDER] = null; + base.pointers[CTX_METHOD] = METH_FIB; + base.pointers[CTX_RCVR] = rcvrObj; + base.pointers[CTX_TEMP0] = n; + vm.activeCtx = base; vm.methodId = METH_FIB; vm.pc = 0; + vm.sp = CTX_TEMP0; // opstack vacío arriba de arg0 + vm.running = true; + const t0 = process.hrtime.bigint(); + while (vm.running) methodFns[vm.methodId](vm); + const t1 = process.hrtime.bigint(); + return { result: vm.result, sends: vm.sends, ns: Number(t1 - t0) }; + }; +} + +// --------------------------------------------------------------------------- +// Maquinaria de frames compartida por V2/V3 (layout del diseño): +// rcvr @ fp-1-numArgs, args hasta fp-1, +// fp+0 savedFp | fp+1 savedPc | fp+2 method | fp+3 flags | fp+4 ctxOop | temps | opstack +// --------------------------------------------------------------------------- +const F_SAVEDFP = 0, F_SAVEDPC = 1, F_METHOD = 2, F_FLAGS = 3, F_CTX = 4, F_FIXED = 5; +const HASCTX_BIT = 1 << 17; + +// --------------------------------------------------------------------------- +// V2: frames planos + trampolín (jit.js con stack zone, sin WASM) +// --------------------------------------------------------------------------- +function makeFrameTrampolineVM() { + const cache = makeCache(); + const rcvrObj = { classId: CLASS_FIB }; + const zone = new Array(1 << 16).fill(0); + const vm = { + fp: 0, sp: 0, pc: 0, methodId: 0, + running: false, result: 0, sends: 0, icc: 1000, + }; + + function send(selId, numArgs) { + vm.sends++; + const rcvr = zone[vm.sp - numArgs]; + const classId = typeof rcvr === "number" ? CLASS_SMALLINT : rcvr.classId; + const methodId = cacheLookup(cache, classId, selId); + const nfp = vm.sp + 1; + zone[nfp + F_SAVEDFP] = vm.fp; + zone[nfp + F_SAVEDPC] = vm.pc; + zone[nfp + F_METHOD] = methodId; + zone[nfp + F_FLAGS] = numArgs; + zone[nfp + F_CTX] = 0; + const numTemps = 0; // nil-fill presente + for (let i = 0; i < numTemps; i++) zone[nfp + F_FIXED + i] = null; + vm.fp = nfp; + vm.sp = nfp + F_FIXED - 1 + numTemps; + vm.methodId = methodId; + vm.pc = 0; + if (--vm.icc <= 0) vm.icc = 1000; + } + + function doReturn(rv) { + const fp = vm.fp; + const flags = zone[fp + F_FLAGS]; + if (flags & HASCTX_BIT) widowCold(fp); + const sfp = zone[fp + F_SAVEDFP]; + if (sfp === 0) { vm.running = false; vm.result = rv; return; } + const numArgs = flags & 0xFFFF; + const rs = fp - 1 - numArgs; + zone[rs] = rv; + vm.sp = rs; + vm.pc = zone[fp + F_SAVEDPC]; + vm.methodId = zone[sfp + F_METHOD]; + vm.fp = sfp; + } + + function widowCold() { throw Error("no contexts casados en este bench"); } + + function fibCompiled(vm) { + const fp = vm.fp; + while (true) switch (vm.pc) { + case 0: { + const n = zone[fp - 1]; + if (typeof n === "number" && n < 2) { doReturn(n); return; } + zone[++vm.sp] = zone[fp - 2]; + if (typeof n === "number") zone[++vm.sp] = n - 1; else throw Error("fail"); + vm.pc = 1; send(SEL_FIB, 1); return; + } + case 1: { + zone[++vm.sp] = zone[fp - 2]; + const a = zone[fp - 1]; + if (typeof a === "number") zone[++vm.sp] = a - 2; else throw Error("fail"); + vm.pc = 2; send(SEL_FIB, 1); return; + } + case 2: { + const b = zone[vm.sp], a = zone[vm.sp - 1]; + if (typeof a === "number" && typeof b === "number") zone[--vm.sp] = a + b; + else throw Error("fail"); + doReturn(zone[vm.sp]); + return; + } + } + } + + const methodFns = [null, fibCompiled]; + + return function run(n) { + vm.sends = 0; vm.icc = 1000; + // frame base: rcvr@0, arg@1, frame@2 + zone[0] = rcvrObj; zone[1] = n; + zone[2 + F_SAVEDFP] = 0; zone[2 + F_SAVEDPC] = 0; + zone[2 + F_METHOD] = METH_FIB; zone[2 + F_FLAGS] = 1; zone[2 + F_CTX] = 0; + vm.fp = 2; vm.sp = 2 + F_FIXED - 1; vm.pc = 0; vm.methodId = METH_FIB; + vm.running = true; + const t0 = process.hrtime.bigint(); + while (vm.running) methodFns[vm.methodId](vm); + const t1 = process.hrtime.bigint(); + return { result: vm.result, sends: vm.sends, ns: Number(t1 - t0) }; + }; +} + +// --------------------------------------------------------------------------- +// V3: frames planos + intérprete de bytecodes en JS (espejo exacto del WASM V4) +// --------------------------------------------------------------------------- +// mini-ISA: ver diseño; fib: n < 2 ifTrue:[^n] ifFalse:[^(self fib: n-1) + (self fib: n-2)] +const OP_PUSH_ARG0 = 0, OP_PUSH_C1 = 1, OP_PUSH_C2 = 2, OP_PUSH_SELF = 3, + OP_SUB = 4, OP_LT = 5, OP_JMPF = 6, OP_SEND_FIB = 7, OP_ADD = 8, OP_RET = 9; +const FIB_BYTES = new Uint8Array([ + OP_PUSH_ARG0, OP_PUSH_C2, OP_LT, OP_JMPF, 7, // pc 0-4: n<2? si no → pc7 + OP_PUSH_ARG0, OP_RET, // pc 5-6: ^n + OP_PUSH_SELF, OP_PUSH_ARG0, OP_PUSH_C1, OP_SUB, OP_SEND_FIB, // pc 7-11 + OP_PUSH_SELF, OP_PUSH_ARG0, OP_PUSH_C2, OP_SUB, OP_SEND_FIB, // pc 12-16 + OP_ADD, OP_RET, // pc 17-18 +]); + +function makeFrameInterpVM() { + const cache = makeCache(); + const rcvrObj = { classId: CLASS_FIB }; + const zone = new Array(1 << 16).fill(0); + const methodBytes = [null, FIB_BYTES]; + let sends = 0; + + function run(n) { + sends = 0; + let icc = 1000; + zone[0] = rcvrObj; zone[1] = n; + zone[2 + F_SAVEDFP] = 0; zone[2 + F_SAVEDPC] = 0; + zone[2 + F_METHOD] = METH_FIB; zone[2 + F_FLAGS] = 1; zone[2 + F_CTX] = 0; + let fp = 2, sp = 2 + F_FIXED - 1, pc = 0; + let bytes = FIB_BYTES; + const t0 = process.hrtime.bigint(); + let result; + loop: while (true) { + switch (bytes[pc++]) { + case OP_PUSH_ARG0: zone[++sp] = zone[fp - 1]; break; + case OP_PUSH_C1: zone[++sp] = 1; break; + case OP_PUSH_C2: zone[++sp] = 2; break; + case OP_PUSH_SELF: zone[++sp] = zone[fp - 2]; break; + case OP_SUB: { + const b = zone[sp--], a = zone[sp]; + if (typeof a === "number" && typeof b === "number") zone[sp] = a - b; + else throw Error("fail"); + break; + } + case OP_LT: { + const b = zone[sp--], a = zone[sp]; + if (typeof a === "number" && typeof b === "number") zone[sp] = a < b ? 1 : 0; + else throw Error("fail"); + break; + } + case OP_JMPF: { + const target = bytes[pc++]; + if (zone[sp--] === 0) pc = target; + break; + } + case OP_SEND_FIB: { + sends++; + const numArgs = 1; + const rcvr = zone[sp - numArgs]; + const classId = typeof rcvr === "number" ? CLASS_SMALLINT : rcvr.classId; + const methodId = cacheLookup(cache, classId, SEL_FIB); + const nfp = sp + 1; + zone[nfp + F_SAVEDFP] = fp; + zone[nfp + F_SAVEDPC] = pc; + zone[nfp + F_METHOD] = methodId; + zone[nfp + F_FLAGS] = numArgs; + zone[nfp + F_CTX] = 0; + const numTemps = 0; + for (let i = 0; i < numTemps; i++) zone[nfp + F_FIXED + i] = null; + fp = nfp; sp = nfp + F_FIXED - 1 + numTemps; pc = 0; + bytes = methodBytes[methodId]; + if (--icc <= 0) icc = 1000; + break; + } + case OP_ADD: { + const b = zone[sp--], a = zone[sp]; + if (typeof a === "number" && typeof b === "number") zone[sp] = a + b; + else throw Error("fail"); + break; + } + case OP_RET: { + const rv = zone[sp]; + const flags = zone[fp + F_FLAGS]; + if (flags & HASCTX_BIT) throw Error("no contexts casados en este bench"); + const sfp = zone[fp + F_SAVEDFP]; + if (sfp === 0) { result = rv; break loop; } + const numArgs = flags & 0xFFFF; + const rs = fp - 1 - numArgs; + zone[rs] = rv; + sp = rs; + pc = zone[fp + F_SAVEDPC]; + const callerMethod = zone[sfp + F_METHOD]; + bytes = methodBytes[callerMethod]; + fp = sfp; + break; + } + default: throw Error("bytecode ilegal"); + } + } + const t1 = process.hrtime.bigint(); + return { result: result, sends: sends, ns: Number(t1 - t0) }; + } + return run; +} + +module.exports = { makeContextVM, makeFrameTrampolineVM, makeFrameInterpVM }; diff --git a/perf/stack-zone-design.md b/perf/stack-zone-design.md new file mode 100644 index 00000000..479bc68a --- /dev/null +++ b/perf/stack-zone-design.md @@ -0,0 +1,141 @@ +# Diseño: Stack Zone con reificación perezosa de Contexts para SqueakJS + +Estado: borrador v1 (2026-07-05). Basado en el concepto de "stack zone" de Cog +(OpenSmalltalk VM), rediseñado contra la semántica concreta de SqueakJS +(`vm.interpreter.js`, `vm.primitives.js`, `jit.js`) — no es una traducción del C. + +## Qué ataca (datos del perfil de 2026-07-05, Cuis abriendo el class browser) + +Sobre CPU activa (~12s de 23.3s grabados): + +- Ciclo de vida de Context (executeNewMethod + doReturn + allocateOrRecycleContext): **14.0%** +- Dispatch de primitivos (doNamedPrimitive + doPrimitive): 7.0% +- Lookup de método (send; findMethodCacheEntry inlineado): 4.4% — la cache ya funciona, + `lookupSelectorInDict` real midió 0.26ms/23s. NO atacar esto. +- Loop base + helpers de stack: 3.1% + +El costo del Context no es la alocación (ya hay free-list: `freeContexts`/ +`freeLargeContexts`) sino el protocolo completo por send/return: +`storeContextRegisters` (encode pc/sp), copia de receiver+args (`arrayCopy`), +nil-fill de temps, 4+ stores de punteros, y a la vuelta `fetchContextRegisters` +(decode), escaneo de unwind, nil de sender/ip, manejo de free-list. + +## Idea central + +Los Contexts existen como objetos heap **solo cuando algo los observa**. El caso +común (send → return sin que nadie capture el contexto) corre sobre **frames +planos en un stack manual** (memoria lineal WASM o Array JS), con push/pop de +~6 slots. El Context objeto se crea perezosamente ("marriage" en la jerga Cog) +solo en los puntos enumerados abajo, que son exactamente los puntos donde el +código actual de SqueakJS necesita `activeContext` como objeto. + +Esto es independiente del lenguaje de implementación: funciona en JS puro +(reemplazo incremental dentro de jit.js/vm.interpreter.js) o en WASM (rewrite +del núcleo). El spike (fase 0) mide ambos para decidir. + +## Layout del frame (stack crece hacia arriba; slots de 1 palabra) + +``` + ... stack de operandos del caller ... + receiver @ fp - 1 - numArgs ← lo pusheó el caller; NO se copia + arg1 .. argN @ fp - numArgs .. fp-1 + savedFp @ fp + 0 (fp del caller; 0 = frame base de página) + savedPc @ fp + 1 (pc de reanudación del caller, entero crudo, SIN encode Squeak) + method @ fp + 2 (oop/id del método del callee) + flags @ fp + 3 (numArgs | isBlock<<16 | hasContext<<17) + contextOop @ fp + 4 (contexto casado, 0 si no hay) + temp1 .. tempK @ fp + 5 .. (nil-fill igual que hoy) + ... stack de operandos del callee ... +``` + +- **Receiver y args no se copian**: quedan donde el caller los pusheó. Elimina el + `arrayCopy` de executeNewMethod:1046. +- **savedPc es un int crudo**: elimina `encodeSqueakPC`/`decodeSqueakPC` del camino + caliente. La forma "Squeak-visible" (offset por literales, 1-based) se computa + solo al casar el frame. +- Return: `result → slot del receiver; sp := ese slot; fp := savedFp; + method := mem[savedFp+2]; pc := savedPc`. ~5 loads/stores + 1 branch (hasContext). + +## Reglas de reificación (marriage) — verificadas contra el código actual + +Un frame se casa con un Context heap SOLO en: + +1. **`thisContext`** — bytecode 0x89 (V3) / 0x52 (Sista) → `exportThisContext()` + (vm.interpreter.js:1315). +2. **Creación de closure** — el closure guarda `outerContext` + (vm.interpreter.js:850, 868, 912). Todo `[...]` casa su frame home. + (Es también por qué hoy ponen `reclaimableContextCount = 0`.) +3. **Cambio de proceso** — `transferTo` guarda `activeContext` en + `oldProc.suspendedContext` (vm.primitives.js:1773). Casa **solo el frame tope**; + el resto de la cadena queda como frames vivos en la zona. El sender de un + contexto casado se resuelve perezosamente (leerlo casa al frame caller). +4. **Evicción de página** (zona llena) → "flush": cada frame de la página víctima + se serializa a un Context heap completo (pc/sp encodeados, temps+stack copiados, + senders enlazados como oops). La página se libera. +5. **Acceso reflectivo** — primitivos que reciben un Context casado + (instVarAt:, el debugger, etc.) leen/escriben "a través" al frame vivo. + Chokepoint único en el VM; el código Smalltalk no puede saltearlo porque el + acceso a slots de otro contexto siempre pasa por primitivos o por el VM. +6. **Unwind que entrega el contexto a Smalltalk** — `aboutToReturnThrough:` / + `cannotReturn:` (vm.interpreter.js:1120-1132). El escaneo de unwind en sí + (recorrer senders chequeando `isUnwindMarked`) camina frames sin reificar. + +Ciclo de vida del contexto casado: +- **Casado**: objeto Context proxy; sus campos se leen/escriben vía el frame. +- **Viudo**: el frame retornó → el contexto queda muerto (pc=nil, sender=nil), + igual que hoy hace doReturn:1103-1104. +- **Flusheado**: el frame se serializó completo al contexto (evicción o snapshot + de imagen); el contexto pasa a ser un Context heap normal y reanudable, como + los de hoy. + +## Páginas y multiproceso + +- Zona = N páginas (arranque: 64 × 8KB). Una cadena de frames de un proceso puede + cruzar páginas: el frame base de cada página (savedFp=0) guarda el oop del + contexto caller (casado al cruzar). +- Overflow al pushear → página nueva (evict LRU si no hay libre). +- Return desde frame base → el caller es un Context heap → "makeBaseFrame": + inflarlo a frame en una página (camino frío; es el inverso del flush). +- `transferTo`: casar tope + guardar en suspendedContext. Al reanudar un proceso + cuyo contexto casado todavía tiene frame vivo en la zona → cambiar fp/sp/pc y + listo (cambio de proceso casi gratis, las páginas no se tocan). +- Snapshot de imagen: flush total de la zona (igual que Cog). + +## Chequeo de interrupciones + +Idéntico a hoy: `interruptCheckCounter` con feedback (vm.interpreter.js:674-726), +decrementado por send y por salto hacia atrás. `checkForInterrupts` solo necesita +contexto objeto si va a cambiar de proceso → regla 3. + +## Qué NO cambia + +- Formato de objetos (Spur / pre-Spur), imagen, GC del heap de objetos. +- Cache de métodos global (ya rinde: 0.26ms de lookup real en 23s). +- Semántica observable: mismos Contexts vistos desde Smalltalk (debugger, + excepciones, `ensure:`, procesos), solo que materializados a demanda. + +## Fases + +- **Fase 0 (spike, hoy)**: fib sintético sobre la maquinaria de frames, 5 variantes + (ver bench2). Decide: ¿el algoritmo gana en JS? ¿WASM suma sobre eso? +- **Fase 1**: según fase 0 — + (a) si JS+frames gana fuerte: integrarlo en SqueakJS real (jit.js genera código + que opera sobre frames; vm.interpreter.js mantiene el camino Context como + fallback/oracle). Incremental, sin WASM. + (b) si WASM suma claramente: intérprete WASM sobre heap Spur en memoria lineal; + todo-o-nada por sesión, validado contra el VM JS como oráculo con imágenes de test. +- **Fase 2 (solo si fase 1 = WASM)**: "Cogit-para-WASM" — generar módulos WASM + por método en runtime (JS puede compilar/instanciar bytes WASM al vuelo, + compartiendo memoria y funcref table). Elimina el costo de dispatch de bytecodes + que el intérprete WASM reintroduce (hoy jit.js ya lo elimina en JS — un + intérprete WASM fase-1 parte con esa desventaja; medir). + +## Riesgos principales + +- Read-through de contextos casados: un acceso que no pase por el chokepoint = + bug silencioso. Mitigación: build con aserciones + suite SUnit de tests/ como + oráculo diferencial. +- El intérprete WASM reintroduce dispatch por bytecode que jit.js ya había + eliminado — la fase 1(b) puede perder en código numérico aunque gane en sends. + Por eso el spike mide dispatch por separado (V2 vs V3). +- Empezar solo con Spur 32-bit (cuis.image); pre-Spur y 64-bit después. From a14d8cee1f7d42f5d25e095a9459a54b091833bb Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:01:26 -0300 Subject: [PATCH 02/22] [perf] add deterministic differential-trace harness (phase 0 oracle) Runs ws/client/cuis.image headless with a fully virtualized environment (virtual Date/Date.now/performance.now, seeded Math.random, inert WebSocket) and accumulates an FNV-1a hash of the execution trace (sendCount/pc/sp/method oop per slice). Two runs of the same VM produce identical hashes (validated 6/6 over the full 3.6M-send Cuis startup), so any semantic divergence introduced by VM changes is caught by comparing against a recorded golden. --bench mode measures wall time on the same workload with the real clock: baseline 2.87M sends/s. golden.json is machine-specific (timezone offset enters the trace), so it is gitignored; each machine records its own with --golden. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + perf/harness/difftrace.js | 231 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 perf/harness/difftrace.js diff --git a/.gitignore b/.gitignore index 245b454e..53a32785 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ gh-pages dist/*.min.js* *.code-workspace +perf/harness/golden.json diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js new file mode 100644 index 00000000..079a409c --- /dev/null +++ b/perf/harness/difftrace.js @@ -0,0 +1,231 @@ +"use strict"; +// Oráculo diferencial para el proyecto stack-zone (ver ../stack-zone-design.md). +// +// Corre una imagen headless en Node con entorno determinista (reloj virtual, +// random sembrado, WebSocket inerte) y acumula un hash de la traza de ejecución +// (sendCount/pc/sp/método en cada slice). Dos corridas del mismo VM producen el +// mismo hash; cualquier divergencia semántica introducida por un VM modificado +// produce otro hash. +// +// node perf/harness/difftrace.js --golden graba perf/harness/golden.json +// node perf/harness/difftrace.js compara contra el golden (exit 1 si difiere) +// node perf/harness/difftrace.js --bench reloj real, mide wall-time y sends/s +// opciones: --sends N (default 20000000), --image ruta (default ws/client/cuis.image) + +var os = require("os"); +var fs = require("fs"); +var path = require("path"); + +var repoRoot = path.join(__dirname, "..", ".."); +var args = process.argv.slice(2); +var mode = args.indexOf("--golden") >= 0 ? "golden" + : args.indexOf("--bench") >= 0 ? "bench" + : "check"; +function argValue(name, deflt) { + var i = args.indexOf(name); + return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : deflt; +} +var maxSends = parseInt(argValue("--sends", "20000000"), 10); +var imagePath = path.resolve(repoRoot, argValue("--image", "ws/client/cuis.image")); +var goldenPath = path.join(__dirname, "golden.json"); +var logPath = argValue("--log", null); // traza por slice, para ubicar divergencias + +// --------------------------------------------------------------------------- +// Entorno determinista (solo en modos de traza; --bench usa el reloj real) +// --------------------------------------------------------------------------- +var virtualMs = 0; +var VIRTUAL_EPOCH = 1735689600000; // 2025-01-01, fijo +// El reloj virtual queda congelado hasta que arranca el loop de interpretación: +// el boot de Node/carga de imagen consume Date.now un número variable de veces +// (estado frío vs caliente) y no debe correr la línea de tiempo. +var clockRunning = false; +if (mode !== "bench") { + var clockCalls = 0; + var virtualNow = function() { + if (clockRunning && ++clockCalls % 4 === 0) virtualMs++; + return VIRTUAL_EPOCH + virtualMs; + }; + // Stub de Date completo: new Date() sin args también debe ser virtual + // (el código de la imagen loguea timestamps; con reloj real divergen las trazas) + var RealDate = Date; + var FakeDate = function Date() { + if (arguments.length === 0) return new RealDate(virtualNow()); + return new (RealDate.bind.apply(RealDate, [null].concat(Array.prototype.slice.call(arguments))))(); + }; + FakeDate.prototype = RealDate.prototype; + FakeDate.now = virtualNow; + FakeDate.UTC = RealDate.UTC; + FakeDate.parse = RealDate.parse; + global.Date = FakeDate; + global.performance = { now: function() { return virtualMs; } }; + var seed = 42 >>> 0; + Math.random = function() { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 4294967296; + }; +} + +// --------------------------------------------------------------------------- +// Bootstrapping del VM: mismo esquema que squeak_node.js +// --------------------------------------------------------------------------- +Object.assign(global, { + self: new Proxy({}, { + get: function(obj, prop) { return global[prop]; }, + set: function(obj, prop, value) { global[prop] = value; return true; } + }) +}); + +// WebSocket inerte: la imagen de ws/client intenta conectarse al arrancar; acá +// queda CONNECTING para siempre (el timeout del lado Smalltalk corre con el +// reloj virtual, así que es determinista). +function FakeWebSocket(url) { + this.url = url; + this.readyState = 0; + this.onopen = null; this.onclose = null; this.onmessage = null; this.onerror = null; +} +FakeWebSocket.prototype.send = function() {}; +FakeWebSocket.prototype.close = function() { this.readyState = 3; }; +FakeWebSocket.prototype.addEventListener = function() {}; +FakeWebSocket.prototype.removeEventListener = function() {}; + +Object.assign(self, { + localStorage: {}, + // inerte también en --bench: el workload debe ser idéntico al de la traza + WebSocket: FakeWebSocket, + sha1: require(path.join(repoRoot, "lib/sha1")), + btoa: function(string) { return Buffer.from(string, "ascii").toString("base64"); }, + atob: function(string) { return Buffer.from(string, "base64").toString("ascii"); } +}); + +[ + "globals.js", "vm.js", "vm.object.js", "vm.object.spur.js", "vm.image.js", + "vm.interpreter.js", "vm.interpreter.proxy.js", "vm.instruction.stream.js", + "vm.instruction.stream.sista.js", "vm.instruction.printer.js", "vm.primitives.js", + "jit.js", "vm.display.js", "vm.display.headless.js", "vm.input.js", + "vm.input.headless.js", "vm.plugins.js", "vm.plugins.file.node.js", +].forEach(function(f) { require(path.join(repoRoot, f)); }); + +Object.extend(Squeak, { + vmPath: path.dirname(imagePath) + path.sep, + platformSubtype: "Node.js", + osVersion: mode === "bench" + ? process.version + " " + os.platform() + " " + os.release() + " " + os.arch() + : "difftrace-deterministic", // la imagen puede leer esto; que no varíe por máquina + windowSystem: "none", +}); + +Object.extend(Squeak.Primitives.prototype, { + loadModuleDynamically: function(modName) { + try { + require(path.join(repoRoot, "plugins", modName)); + return Squeak.externalModules[modName]; + } catch (e) { + console.error("Plugin " + modName + " could not be loaded"); + } + return undefined; + } +}); + +// --------------------------------------------------------------------------- +// Driver síncrono + hash de traza +// --------------------------------------------------------------------------- +var hash = 2166136261 >>> 0; // FNV-1a +function mix(v) { + hash = (hash ^ (v >>> 0)) >>> 0; + hash = Math.imul(hash, 16777619) >>> 0; +} + +var data = fs.readFileSync(imagePath); +var image = new Squeak.Image(imagePath.replace(/\.image$/, "")); +image.readFromBuffer(data.buffer, function startRunning() { + var display = { vmOptions: ["-vm-display-null", "-nodisplay"] }; + var vm = new Squeak.Interpreter(image, display); + var slices = 0, idleStreak = 0, stopReason = "maxSends"; + var logLines = logPath ? [] : null; + var wallStart = process.hrtime.bigint(); + clockRunning = true; + try { + while (vm.sendCount < maxSends) { + if (display.quitFlag) { stopReason = "quit"; break; } + var result = vm.interpret(5); + slices++; + if (mode !== "bench") { + mix(vm.sendCount); + mix(vm.pc); + mix(vm.sp); + mix(vm.method && vm.method.oop ? vm.method.oop : 0); + if (logLines) logLines.push(slices + " sends=" + vm.sendCount + " pc=" + vm.pc + + " sp=" + vm.sp + " oop=" + (vm.method && vm.method.oop) + + " vms=" + virtualMs + " r=" + result); + } + if (result === "sleep") { + // todos los procesos esperan sin timer: nada más va a pasar + if (++idleStreak >= 3) { stopReason = "idle"; break; } + warpClock(vm, 10); + } else if (typeof result === "number" && result > 1) { + // todos esperan hasta un timer futuro: saltar la espera + warpClock(vm, result); + idleStreak = 0; + } else if (result === "break") { + stopReason = "breakpoint"; break; + } else { + idleStreak = 0; + } + } + } catch (e) { + stopReason = "error: " + e.message; + } + var wallMs = Number(process.hrtime.bigint() - wallStart) / 1e6; + if (logLines) fs.writeFileSync(logPath, logLines.join("\n") + "\n"); + + function warpClock(vm, ms) { + if (mode === "bench") { + // adelantar el reloj del VM (startupTime) para no esperar de verdad + var now = vm.primHandler.millisecondClockValue(); + vm.primHandler.millisecondClockValueSet(now + ms); + } else { + virtualMs += ms; + } + } + + var report = { + image: path.relative(repoRoot, imagePath), + maxSends: maxSends, + sendCount: vm.sendCount, + slices: slices, + stopReason: stopReason, + hash: hash.toString(16), + virtualMs: virtualMs, + }; + + if (mode === "bench") { + console.log("bench: " + vm.sendCount + " sends en " + wallMs.toFixed(0) + " ms (" + + (vm.sendCount / (wallMs / 1000) / 1e6).toFixed(2) + "M sends/s), stop: " + stopReason); + return; + } + + console.log("trace: sends=" + report.sendCount + " slices=" + report.slices + + " stop=" + report.stopReason + " hash=" + report.hash + " virtualMs=" + report.virtualMs + + " (wall " + wallMs.toFixed(0) + " ms)"); + + if (mode === "golden") { + fs.writeFileSync(goldenPath, JSON.stringify(report, null, 2) + "\n"); + console.log("golden grabado en " + path.relative(repoRoot, goldenPath)); + } else { + if (!fs.existsSync(goldenPath)) { + console.error("no hay golden.json — corré primero con --golden"); + process.exit(2); + } + var golden = JSON.parse(fs.readFileSync(goldenPath, "utf8")); + var keys = ["image", "maxSends", "sendCount", "slices", "stopReason", "hash", "virtualMs"]; + var diffs = keys.filter(function(k) { return String(golden[k]) !== String(report[k]); }); + if (diffs.length === 0) { + console.log("OK: traza idéntica al golden"); + } else { + diffs.forEach(function(k) { + console.error("DIVERGE " + k + ": golden=" + golden[k] + " actual=" + report[k]); + }); + process.exit(1); + } + } +}); From 0fd131b3a6971a8a7f6b9ab2ed79a8549e96033e Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:02:28 -0300 Subject: [PATCH 03/22] [perf] harness: reset image-side artifacts before each run The image creates its .changes file and debug logs next to itself, so a fresh clone's first run saw different filesystem state than subsequent runs. Delete those artifacts before every run (and gitignore them) so all runs start from the canonical state. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 ++ perf/harness/difftrace.js | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/.gitignore b/.gitignore index 53a32785..695d7e14 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ gh-pages dist/*.min.js* *.code-workspace perf/harness/golden.json +ws/client/cuis.changes +ws/client/CuisDebug-*.log diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 079a409c..0b01d8d9 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -135,6 +135,18 @@ function mix(v) { hash = Math.imul(hash, 16777619) >>> 0; } +// La imagen escribe artefactos junto a sí misma (crea el .changes si falta, +// logs de debug). Limpiarlos antes de correr para que toda corrida parta del +// mismo estado de filesystem — si no, la primera corrida en un clone fresco +// difiere de las siguientes. +var imageDir = path.dirname(imagePath); +var imageBase = path.basename(imagePath, ".image"); +fs.readdirSync(imageDir).forEach(function(f) { + if (f === imageBase + ".changes" || /^CuisDebug-.*\.log$/.test(f)) { + fs.unlinkSync(path.join(imageDir, f)); + } +}); + var data = fs.readFileSync(imagePath); var image = new Squeak.Image(imagePath.replace(/\.image$/, "")); image.readFromBuffer(data.buffer, function startRunning() { From 2bf92d260e3e67c0b1bce775b5eb372c9e57d3e6 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:21:40 -0300 Subject: [PATCH 04/22] [perf] harness: hash only representation-independent state sp is an index into context.pointers today but a zone index with the stack zone, so it (and scheduling-level slices/virtualMs) must not be part of the cross-representation oracle. Hash sendCount/method/pc only. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 0b01d8d9..32e21a5e 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -162,12 +162,14 @@ image.readFromBuffer(data.buffer, function startRunning() { var result = vm.interpret(5); slices++; if (mode !== "bench") { + // Solo estado independiente de la representación de contexts: + // sp/activeContext cambian de significado con el stack zone, + // pero sendCount/método/pc son comparables entre ambos VMs. mix(vm.sendCount); mix(vm.pc); - mix(vm.sp); mix(vm.method && vm.method.oop ? vm.method.oop : 0); if (logLines) logLines.push(slices + " sends=" + vm.sendCount + " pc=" + vm.pc + - " sp=" + vm.sp + " oop=" + (vm.method && vm.method.oop) + + " oop=" + (vm.method && vm.method.oop) + " vms=" + virtualMs + " r=" + result); } if (result === "sleep") { @@ -229,7 +231,9 @@ image.readFromBuffer(data.buffer, function startRunning() { process.exit(2); } var golden = JSON.parse(fs.readFileSync(goldenPath, "utf8")); - var keys = ["image", "maxSends", "sendCount", "slices", "stopReason", "hash", "virtualMs"]; + // slices/virtualMs quedan fuera de la comparación: son del scheduling, + // no de la semántica (podrían variar levemente entre representaciones) + var keys = ["image", "maxSends", "sendCount", "stopReason", "hash"]; var diffs = keys.filter(function(k) { return String(golden[k]) !== String(report[k]); }); if (diffs.length === 0) { console.log("OK: traza idéntica al golden"); From cc6f1c2002e75dca4c5ef7a2e4bee0f678e16fe3 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:28:36 -0300 Subject: [PATCH 05/22] [perf] route all operand-stack access through vm.stack Mechanical refactor, groundwork for the stack zone: vm.stack is THE operand array (today activeContext.pointers, kept in sync at every context switch; a zone page once flat frames land). All stack helpers, arg copies, perform's stack slides, and jit-generated code now index vm.stack instead of reaching through activeContext. become refreshes vm.stack in case the active context's pointers array was swapped. No behavior change: differential trace hash is identical to the pre-refactor golden; bench unchanged (2.90M vs 2.87M sends/s, noise). Co-Authored-By: Claude Fable 5 --- jit.js | 2 +- vm.interpreter.js | 27 +++++++++++++++------------ vm.primitives.js | 4 +++- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/jit.js b/jit.js index 6129a2b1..f4065d6d 100644 --- a/jit.js +++ b/jit.js @@ -235,7 +235,7 @@ to single-step. this.instVarNames = optInstVarNames; this.allVars = ['context', 'stack', 'rcvr', 'inst[', 'temp[', 'lit[']; this.sourcePos['context'] = this.source.length; this.source.push("var context = vm.activeContext;\n"); - this.sourcePos['stack'] = this.source.length; this.source.push("var stack = context.pointers;\n"); + this.sourcePos['stack'] = this.source.length; this.source.push("var stack = vm.stack;\n"); this.sourcePos['rcvr'] = this.source.length; this.source.push("var rcvr = vm.receiver;\n"); this.sourcePos['inst['] = this.source.length; this.source.push("var inst = rcvr.pointers;\n"); this.sourcePos['temp['] = this.source.length; this.source.push("var temp = vm.homeContext.pointers;\n"); diff --git a/vm.interpreter.js b/vm.interpreter.js index 58f14ad4..75b2c224 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -55,6 +55,7 @@ Object.subclass('Squeak.Interpreter', initVMState: function() { this.byteCodeCount = 0; this.sendCount = 0; + this.stack = null; // operand stack array (activeContext.pointers; a zone page once stack frames land) this.interruptCheckCounter = 0; this.interruptCheckCounterFeedBackReset = 1000; this.interruptChecksEveryNms = 3; @@ -1023,7 +1024,7 @@ Object.subclass('Squeak.Interpreter', var indent = ' '; var ctx = this.activeContext; while (!ctx.isNil) { indent += '| '; ctx = ctx.pointers[Squeak.Context_sender]; } - var args = this.activeContext.pointers.slice(this.sp + 1 - argumentCount, this.sp + 1); + var args = this.stack.slice(this.sp + 1 - argumentCount, this.sp + 1); console.log(this.sendCount + indent + this.printMethod(newMethod, optClass, optSel, args)); } if (this.breakOnContextChanged) { @@ -1043,7 +1044,7 @@ Object.subclass('Squeak.Interpreter', newContext.pointers[Squeak.Context_sender] = this.activeContext; //Copy receiver and args to new context //Note this statement relies on the receiver slot being contiguous with args... - this.arrayCopy(this.activeContext.pointers, this.sp-argumentCount, newContext.pointers, Squeak.Context_tempFrameStart-1, argumentCount+1); + this.arrayCopy(this.stack, this.sp-argumentCount, newContext.pointers, Squeak.Context_tempFrameStart-1, argumentCount+1); //...and fill the remaining temps with nil this.arrayFill(newContext.pointers, Squeak.Context_tempFrameStart+argumentCount, Squeak.Context_tempFrameStart+tempCount, this.nilObj); this.popN(argumentCount+1); @@ -1057,6 +1058,7 @@ Object.subclass('Squeak.Interpreter', this.method = newMethod; this.pc = newPC; this.sp = newSP; + this.stack = newContext.pointers; this.receiver = newContext.pointers[Squeak.Context_receiver]; if (this.receiver !== newRcvr) throw Error("receivers don't match"); @@ -1167,7 +1169,7 @@ Object.subclass('Squeak.Interpreter', //Bundle up receiver, args and selector as a messageObject var message = this.instantiateClass(this.specialObjects[Squeak.splOb_ClassMessage], 0); var argArray = this.instantiateClass(this.specialObjects[Squeak.splOb_ClassArray], argCount); - this.arrayCopy(this.activeContext.pointers, this.sp-argCount+1, argArray.pointers, 0, argCount); //copy args from stack + this.arrayCopy(this.stack, this.sp-argCount+1, argArray.pointers, 0, argCount); //copy args from stack message.pointers[Squeak.Message_selector] = selector; message.pointers[Squeak.Message_arguments] = argArray; if (message.pointers.length > Squeak.Message_lookupClass) //Early versions don't have lookupClass @@ -1183,7 +1185,7 @@ Object.subclass('Squeak.Interpreter', // selector has been found, rearrange stack if (entry.argCount !== trueArgCount) return false; - var stack = this.activeContext.pointers; // slide eveything down... + var stack = this.stack; // slide eveything down... var selectorIndex = this.sp - trueArgCount; this.arrayCopy(stack, selectorIndex+1, stack, selectorIndex, trueArgCount); this.sp--; // adjust sp accordingly @@ -1215,7 +1217,7 @@ Object.subclass('Squeak.Interpreter', // selector has been found, rearrange stack if (entry.argCount !== trueArgCount) return false; - var stack = this.activeContext.pointers; + var stack = this.stack; var selectorIndex = this.sp - (argCount - 1); stack[selectorIndex - 1] = rcvr; this.arrayCopy(args.pointers, 0, stack, selectorIndex, trueArgCount); @@ -1329,6 +1331,7 @@ Object.subclass('Squeak.Interpreter', this.method = meth; this.pc = this.decodeSqueakPC(ctxt.pointers[Squeak.Context_instructionPointer], meth); this.sp = this.decodeSqueakSP(ctxt.pointers[Squeak.Context_stackPointer]); + this.stack = ctxt.pointers; // operand stack array; sp indexes into it }, storeContextRegisters: function() { //Save pc, sp into activeContext object, prior to change of context @@ -1392,25 +1395,25 @@ Object.subclass('Squeak.Interpreter', 'stack access', { pop: function() { //Note leaves garbage above SP. Cleaned out by fullGC. - return this.activeContext.pointers[this.sp--]; + return this.stack[this.sp--]; }, popN: function(nToPop) { this.sp -= nToPop; }, push: function(object) { - this.activeContext.pointers[++this.sp] = object; + this.stack[++this.sp] = object; }, popNandPush: function(nToPop, object) { - this.activeContext.pointers[this.sp -= nToPop - 1] = object; + this.stack[this.sp -= nToPop - 1] = object; }, top: function() { - return this.activeContext.pointers[this.sp]; + return this.stack[this.sp]; }, stackTopPut: function(object) { - this.activeContext.pointers[this.sp] = object; + this.stack[this.sp] = object; }, stackValue: function(depthIntoStack) { - return this.activeContext.pointers[this.sp - depthIntoStack]; + return this.stack[this.sp - depthIntoStack]; }, stackInteger: function(depthIntoStack) { return this.checkSmallInt(this.stackValue(depthIntoStack)); @@ -1858,7 +1861,7 @@ Object.subclass('Squeak.Interpreter', console.log(this.printStack() + this.printActiveContext() + '\n\n' + this.printByteCodes(this.method, '   ', '=> ', this.pc), - this.activeContext.pointers.slice(0, this.sp + 1)); + this.stack.slice(0, this.sp + 1)); }, willSendOrReturn: function() { // Answer whether the next bytecode corresponds to a Smalltalk diff --git a/vm.primitives.js b/vm.primitives.js index 5f0281fd..0076aaf0 100644 --- a/vm.primitives.js +++ b/vm.primitives.js @@ -1470,6 +1470,8 @@ Object.subclass('Squeak.Primitives', if (argCount > 1) copyHash = this.stackBoolean(argCount-2); if (!this.success) return false; this.success = this.vm.image.bulkBecome(rcvr.pointers, arg.pointers, doBothWays, copyHash); + // become may have swapped the active context's pointers array + this.vm.stack = this.vm.activeContext.pointers; return this.popNIfOK(argCount); }, doStringReplace: function() { @@ -1584,7 +1586,7 @@ Object.subclass('Squeak.Primitives', if (typeof blockArgCount !== "number") return false; if (blockArgCount != argCount) return false; if (!block.pointers[Squeak.BlockContext_caller].isNil) return false; - this.vm.arrayCopy(this.vm.activeContext.pointers, this.vm.sp-argCount+1, block.pointers, Squeak.Context_tempFrameStart, argCount); + this.vm.arrayCopy(this.vm.stack, this.vm.sp-argCount+1, block.pointers, Squeak.Context_tempFrameStart, argCount); var initialIP = block.pointers[Squeak.BlockContext_initialIP]; block.pointers[Squeak.Context_instructionPointer] = initialIP; block.pointers[Squeak.Context_stackPointer] = argCount; From 0f51931e9181d260aa0ceb4148e2898d1fa0cfc2 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:32:16 -0300 Subject: [PATCH 06/22] [perf] design: v1 marriage protocol (snapshot + sync-at-send + flush-on-write) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapping vm.primitives.js showed unwind prims 195-199 return false, so all exception handling walks contexts from Smalltalk code via sends — which means every image-level access to context fields goes through either a send with the context as receiver or a reflective primitive. That bounds the read-through surface to two chokepoints and allows a proxy-free v1. Co-Authored-By: Claude Fable 5 --- perf/stack-zone-design.md | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/perf/stack-zone-design.md b/perf/stack-zone-design.md index 479bc68a..69c8ce26 100644 --- a/perf/stack-zone-design.md +++ b/perf/stack-zone-design.md @@ -88,6 +88,57 @@ Ciclo de vida del contexto casado: de imagen); el contexto pasa a ser un Context heap normal y reanudable, como los de hoy. +## Protocolo de marriage v1 para JS: "snapshot + sync-at-send + flush-on-write" + +Hallazgo clave (2026-07-05, mapeo de vm.primitives.js): los prims de unwind +195-199 **retornan false** en SqueakJS — la búsqueda de handlers y el unwinding +corren como código Smalltalk normal caminando contexts vía sends (`ctx sender`, +`ctx method`, ...). Consecuencia: **todo acceso Smalltalk a campos de un context +pasa por (a) un send con el context como receiver, o (b) primitivos reflectivos** +(instVarAt: 73/74, storeStackp 76, clone 148, ...). No hay tercer camino. +Eso permite un protocolo sin proxies: + +1. El slot fp+4 del frame guarda su Context casado (o null); el Context casado + lleva `ctx.frame` → frame vivo. +2. **marry(frame)** = crear el Context y llenarlo con un snapshot completo + (receiver/method/closure/pc/stackp/temps/stack). El frame sigue corriendo; + el snapshot puede quedar stale. +3. **Puntos de sync** (refrescar snapshot, baratos y acotados): + - `send()`: si el receiver es un context casado-vivo → sync. Un chequeo + `rcvr.frame !== undefined` por send cubre TODOS los reads a nivel bytecode. + - Primitivos reflectivos (objectAt/objectAtPut/storeStackp/clone/pointsTo): + chokepoint único, sync antes de leer. + - El campo `sender` de un snapshot se llena casando al frame caller + (lazy, un marry por nivel) → caminar la cadena desde `thisContext` casa + de a uno, sin flush masivo. +4. **Stores** a campos de un context casado-vivo (bytecode store-inst-var o + reflectivo) → flush de toda la cadena activa a contexts reales + continuar + con makeBaseFrame(top). Solo pasa en debugger/unwind — acá frío y correcto + le gana a rápido. +5. Return de un frame casado → **widow**: sender=nil, pc=nil, clear `.frame` + (semántica idéntica al doReturn actual). +6. `thisContext` → marry del frame activo solamente (la cadena se casa lazy + vía 3c a medida que se camina). +7. Cambio de proceso: `transferTo` casa solo el tope; las páginas quedan vivas; + resume con frame vivo = saltar a (page, fp); si no, makeBaseFrame. +8. Snapshot de imagen: flush total. GC lógico de vm.image.js: los slots de + páginas vivas entran como roots; `become:` recorre también la zona. +9. Los contexts NUNCA se ejecutan directamente: el intérprete solo corre + frames; makeBaseFrame/flush son las únicas transiciones. (Un solo engine.) + +Nota sobre closures: con closures reales (Cuis/Squeak modernos) los valores +copiados viven EN el closure y las temps compartidas van en arrays de +indirección — el `outerContext` se usa solo para identidad (target de +non-local return), home receiver/method y debugging. Un snapshot stale de +temps en el context casado es aceptable en v1 (el debugger podría mostrar +temps viejas del home mientras el frame corre; Cog lo hace exacto con +read-through — mejora futura). + +Prerequisito ya implementado: todo acceso al stack de operandos pasa por +`vm.stack` (commit "route all operand-stack access through vm.stack"), así el +modo frames solo re-apunta `vm.stack` a la página activa sin tocar los +bytecodes ni los templates de push/pop del jit. + ## Páginas y multiproceso - Zona = N páginas (arranque: 64 × 8KB). Una cadena de frames de un proceso puede From cfbe16cfb2a6acc769059e00327493292f30d176 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:52:03 -0300 Subject: [PATCH 07/22] [perf] route temp-var access through vm.temps/tempOffset Second mechanical groundwork refactor: interpretOne and jit-generated code now access temps as temps[tempOffset+n] instead of reaching through homeContext.pointers[6+n]. In context mode tempOffset stays 6 and vm.temps === homeContext.pointers (synced at the same points as vm.stack), so generated code is equivalent; in stack-zone mode temps will live in the frame at a per-activation base. Trace hash identical to golden; bench unchanged (2.80M sends/s, noise). Co-Authored-By: Claude Fable 5 --- jit.js | 44 ++++++++++++++++++++++++++------------------ vm.interpreter.js | 40 +++++++++++++++++++++++----------------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/jit.js b/jit.js index f4065d6d..1ed18b42 100644 --- a/jit.js +++ b/jit.js @@ -200,6 +200,12 @@ to single-step. // if a compiler does not support single-stepping, return false return true; }, + tempIndex: function(n) { + // index expression for temp n inside this.source: + // context mode: constant (temps at homeContext.pointers[6+n]); + // stack-zone mode: frame-relative (temps at vm.temps[tempBase+n], tempBase per activation) + return this.vm.useStackZone ? "tB + " + n : Squeak.Context_tempFrameStart + n; + }, functionNameFor: function(cls, sel) { if (cls === undefined || cls === '?') { var isMethod = this.method.sqClass === this.vm.specialObjects[Squeak.splOb_ClassCompiledMethod]; @@ -238,7 +244,9 @@ to single-step. this.sourcePos['stack'] = this.source.length; this.source.push("var stack = vm.stack;\n"); this.sourcePos['rcvr'] = this.source.length; this.source.push("var rcvr = vm.receiver;\n"); this.sourcePos['inst['] = this.source.length; this.source.push("var inst = rcvr.pointers;\n"); - this.sourcePos['temp['] = this.source.length; this.source.push("var temp = vm.homeContext.pointers;\n"); + this.sourcePos['temp['] = this.source.length; this.source.push(this.vm.useStackZone + ? "var temp = vm.temps, tB = vm.tempOffset;\n" + : "var temp = vm.temps;\n"); this.sourcePos['lit['] = this.source.length; this.source.push("var lit = vm.method.pointers;\n"); this.sourcePos['loop-start'] = this.source.length; this.source.push("while (true) switch (vm.pc) {\ncase 0:\n"); if (this.sista) this.generateSista(method); @@ -267,7 +275,7 @@ to single-step. break; // load temp var case 0x10: case 0x18: - this.generatePush("temp[", 6 + (byte & 0xF), "]"); + this.generatePush("temp[", this.tempIndex((byte & 0xF)), "]"); break; // loadLiteral case 0x20: case 0x28: case 0x30: case 0x38: @@ -283,7 +291,7 @@ to single-step. break; // storeAndPop temp var case 0x68: - this.generatePopInto("temp[", 6 + (byte & 0x07), "]"); + this.generatePopInto("temp[", this.tempIndex((byte & 0x07)), "]"); break; // Quick push case 0x70: @@ -361,7 +369,7 @@ to single-step. byte2 = this.method.bytes[this.pc++]; switch (byte2 >> 6) { case 0: this.generatePush("inst[", byte2 & 0x3F, "]"); return; - case 1: this.generatePush("temp[", 6 + (byte2 & 0x3F), "]"); return; + case 1: this.generatePush("temp[", this.tempIndex((byte2 & 0x3F)), "]"); return; case 2: this.generatePush("lit[", 1 + (byte2 & 0x3F), "]"); return; case 3: this.generatePush("lit[", 1 + (byte2 & 0x3F), "].pointers[1]"); return; } @@ -370,7 +378,7 @@ to single-step. byte2 = this.method.bytes[this.pc++]; switch (byte2 >> 6) { case 0: this.generateStoreInto("inst[", byte2 & 0x3F, "]"); return; - case 1: this.generateStoreInto("temp[", 6 + (byte2 & 0x3F), "]"); return; + case 1: this.generateStoreInto("temp[", this.tempIndex((byte2 & 0x3F)), "]"); return; case 2: throw Error("illegal store into literal"); case 3: this.generateStoreInto("lit[", 1 + (byte2 & 0x3F), "].pointers[1]"); return; } @@ -380,7 +388,7 @@ to single-step. byte2 = this.method.bytes[this.pc++]; switch (byte2 >> 6) { case 0: this.generatePopInto("inst[", byte2 & 0x3F, "]"); return; - case 1: this.generatePopInto("temp[", 6 + (byte2 & 0x3F), "]"); return; + case 1: this.generatePopInto("temp[", this.tempIndex((byte2 & 0x3F)), "]"); return; case 2: throw Error("illegal pop into literal"); case 3: this.generatePopInto("lit[", 1 + (byte2 & 0x3F), "].pointers[1]"); return; } @@ -444,19 +452,19 @@ to single-step. case 0x8C: byte2 = this.method.bytes[this.pc++]; byte3 = this.method.bytes[this.pc++]; - this.generatePush("temp[", 6 + byte3, "].pointers[", byte2, "]"); + this.generatePush("temp[", this.tempIndex(byte3), "].pointers[", byte2, "]"); return; // remote store into temp vector case 0x8D: byte2 = this.method.bytes[this.pc++]; byte3 = this.method.bytes[this.pc++]; - this.generateStoreInto("temp[", 6 + byte3, "].pointers[", byte2, "]"); + this.generateStoreInto("temp[", this.tempIndex(byte3), "].pointers[", byte2, "]"); return; // remote store and pop into temp vector case 0x8E: byte2 = this.method.bytes[this.pc++]; byte3 = this.method.bytes[this.pc++]; - this.generatePopInto("temp[", 6 + byte3, "].pointers[", byte2, "]"); + this.generatePopInto("temp[", this.tempIndex(byte3), "].pointers[", byte2, "]"); return; // pushClosureCopy case 0x8F: @@ -502,10 +510,10 @@ to single-step. break; // load temporary variable case 0x40: case 0x41: case 0x42: case 0x43: case 0x44: case 0x45: case 0x46: case 0x47: - this.generatePush("temp[", 6 + (b & 0x07), "]"); + this.generatePush("temp[", this.tempIndex((b & 0x07)), "]"); break; case 0x48: case 0x49: case 0x4A: case 0x4B: - this.generatePush("temp[", 6 + (b & 0x03) + 8, "]"); + this.generatePush("temp[", this.tempIndex((b & 0x03) + 8), "]"); break; case 0x4C: this.generatePush("rcvr"); break; @@ -577,7 +585,7 @@ to single-step. this.generatePopInto("inst[", b & 0x07, "]"); break; case 0xD0: case 0xD1: case 0xD2: case 0xD3: case 0xD4: case 0xD5: case 0xD6: case 0xD7: - this.generatePopInto("temp[", 6 + (b & 0x07), "]"); + this.generatePopInto("temp[", this.tempIndex((b & 0x07)), "]"); break; case 0xD8: this.generateInstruction("pop", "vm.sp--"); break; @@ -609,7 +617,7 @@ to single-step. break; case 0xE5: b2 = bytes[this.pc++]; - this.generatePush("temp[", 6 + b2, "]"); + this.generatePush("temp[", this.tempIndex(b2), "]"); break; case 0xE6: throw Error("unusedBytecode 0xE6"); @@ -662,7 +670,7 @@ to single-step. break; case 0xF2: b2 = bytes[this.pc++]; - this.generatePopInto("temp[", 6 + b2, "]"); + this.generatePopInto("temp[", this.tempIndex(b2), "]"); break; case 0xF3: b2 = bytes[this.pc++]; @@ -674,7 +682,7 @@ to single-step. break; case 0xF5: b2 = bytes[this.pc++]; - this.generateStoreInto("temp[", 6 + b2, "]"); + this.generateStoreInto("temp[", this.tempIndex(b2), "]"); break; case 0xF6: case 0xF7: throw Error("unusedBytecode " + b); @@ -702,17 +710,17 @@ to single-step. case 0xFB: b2 = bytes[this.pc++]; b3 = bytes[this.pc++]; - this.generatePush("temp[", 6 + b3, "].pointers[", b2, "]"); + this.generatePush("temp[", this.tempIndex(b3), "].pointers[", b2, "]"); break; case 0xFC: b2 = bytes[this.pc++]; b3 = bytes[this.pc++]; - this.generateStoreInto("temp[", 6 + b3, "].pointers[", b2, "]"); + this.generateStoreInto("temp[", this.tempIndex(b3), "].pointers[", b2, "]"); break; case 0xFD: b2 = bytes[this.pc++]; b3 = bytes[this.pc++]; - this.generatePopInto("temp[", 6 + b3, "].pointers[", b2, "]"); + this.generatePopInto("temp[", this.tempIndex(b3), "].pointers[", b2, "]"); break; case 0xFE: case 0xFF: throw Error("unusedBytecode " + b); diff --git a/vm.interpreter.js b/vm.interpreter.js index 75b2c224..1a200cc2 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -56,6 +56,8 @@ Object.subclass('Squeak.Interpreter', this.byteCodeCount = 0; this.sendCount = 0; this.stack = null; // operand stack array (activeContext.pointers; a zone page once stack frames land) + this.temps = null; // temp base array (homeContext.pointers; a zone page once stack frames land) + this.tempOffset = Squeak.Context_tempFrameStart; // index of temp 0 in this.temps this.interruptCheckCounter = 0; this.interruptCheckCounterFeedBackReset = 1000; this.interruptChecksEveryNms = 3; @@ -232,7 +234,7 @@ Object.subclass('Squeak.Interpreter', // load temporary variable case 0x10: case 0x11: case 0x12: case 0x13: case 0x14: case 0x15: case 0x16: case 0x17: case 0x18: case 0x19: case 0x1A: case 0x1B: case 0x1C: case 0x1D: case 0x1E: case 0x1F: - this.push(this.homeContext.pointers[Squeak.Context_tempFrameStart+(b&0xF)]); return; + this.push(this.temps[this.tempOffset+(b&0xF)]); return; // loadLiteral case 0x20: case 0x21: case 0x22: case 0x23: case 0x24: case 0x25: case 0x26: case 0x27: @@ -253,7 +255,7 @@ Object.subclass('Squeak.Interpreter', this.receiver.dirty = true; this.receiver.pointers[b&7] = this.pop(); return; case 0x68: case 0x69: case 0x6A: case 0x6B: case 0x6C: case 0x6D: case 0x6E: case 0x6F: - this.homeContext.pointers[Squeak.Context_tempFrameStart+(b&7)] = this.pop(); return; + this.temps[this.tempOffset+(b&7)] = this.pop(); return; // Quick push case 0x70: this.push(this.receiver); return; @@ -296,15 +298,15 @@ Object.subclass('Squeak.Interpreter', case 0x8B: this.callPrimBytecode(0x81); return; case 0x8C: b2 = this.nextByte(); // remote push from temp vector - this.push(this.homeContext.pointers[Squeak.Context_tempFrameStart+this.nextByte()].pointers[b2]); + this.push(this.temps[this.tempOffset+this.nextByte()].pointers[b2]); return; case 0x8D: b2 = this.nextByte(); // remote store into temp vector - var vec = this.homeContext.pointers[Squeak.Context_tempFrameStart+this.nextByte()]; + var vec = this.temps[this.tempOffset+this.nextByte()]; vec.pointers[b2] = this.top(); vec.dirty = true; return; case 0x8E: b2 = this.nextByte(); // remote store and pop into temp vector - var vec = this.homeContext.pointers[Squeak.Context_tempFrameStart+this.nextByte()]; + var vec = this.temps[this.tempOffset+this.nextByte()]; vec.pointers[b2] = this.pop(); vec.dirty = true; return; @@ -411,9 +413,9 @@ Object.subclass('Squeak.Interpreter', // load temporary variable case 0x40: case 0x41: case 0x42: case 0x43: case 0x44: case 0x45: case 0x46: case 0x47: - this.push(this.homeContext.pointers[Squeak.Context_tempFrameStart+(b&0x7)]); return; + this.push(this.temps[this.tempOffset+(b&0x7)]); return; case 0x48: case 0x49: case 0x4A: case 0x4B: - this.push(this.homeContext.pointers[Squeak.Context_tempFrameStart+(b&0x3)+8]); return; + this.push(this.temps[this.tempOffset+(b&0x3)+8]); return; case 0x4C: this.push(this.receiver); return; case 0x4D: this.push(this.trueObj); return; @@ -510,7 +512,7 @@ Object.subclass('Squeak.Interpreter', this.receiver.dirty = true; this.receiver.pointers[b&7] = this.pop(); return; case 0xD0: case 0xD1: case 0xD2: case 0xD3: case 0xD4: case 0xD5: case 0xD6: case 0xD7: - this.homeContext.pointers[Squeak.Context_tempFrameStart+(b&7)] = this.pop(); return; + this.temps[this.tempOffset+(b&7)] = this.pop(); return; case 0xD8: this.pop(); return; // pop case 0xD9: this.nono(); return; // FIXME: Unconditional trap @@ -530,7 +532,7 @@ Object.subclass('Squeak.Interpreter', case 0xE4: b2 = this.nextByte(); this.push(this.method.methodGetLiteral(b2 + (extA << 8))); return; case 0xE5: - b2 = this.nextByte(); this.push(this.homeContext.pointers[Squeak.Context_tempFrameStart+b2]); return; + b2 = this.nextByte(); this.push(this.temps[this.tempOffset+b2]); return; case 0xE6: this.nono(); return; // unused case 0xE7: this.pushNewArray(this.nextByte()); return; // create new temp vector case 0xE8: b2 = this.nextByte(); this.push(b2 + (extB << 8)); return; // push SmallInteger @@ -567,7 +569,7 @@ Object.subclass('Squeak.Interpreter', assoc.pointers[Squeak.Assn_value] = this.pop(); return; case 0xF2: // pop into temp - this.homeContext.pointers[Squeak.Context_tempFrameStart + this.nextByte()] = this.pop(); + this.temps[this.tempOffset + this.nextByte()] = this.pop(); return; case 0xF3: // store into receiver this.receiver.dirty = true; @@ -579,7 +581,7 @@ Object.subclass('Squeak.Interpreter', assoc.pointers[Squeak.Assn_value] = this.top(); return; case 0xF5: // store into temp - this.homeContext.pointers[Squeak.Context_tempFrameStart + this.nextByte()] = this.top(); + this.temps[this.tempOffset + this.nextByte()] = this.top(); return; case 0xF6: case 0xF7: this.nono(); return; // unused @@ -589,15 +591,15 @@ Object.subclass('Squeak.Interpreter', case 0xF9: this.pushFullClosure(extA); return; case 0xFA: this.pushClosureCopyExtended(extA, extB); return; case 0xFB: b2 = this.nextByte(); // remote push from temp vector - this.push(this.homeContext.pointers[Squeak.Context_tempFrameStart+this.nextByte()].pointers[b2]); + this.push(this.temps[this.tempOffset+this.nextByte()].pointers[b2]); return; case 0xFC: b2 = this.nextByte(); // remote store into temp vector - var vec = this.homeContext.pointers[Squeak.Context_tempFrameStart+this.nextByte()]; + var vec = this.temps[this.tempOffset+this.nextByte()]; vec.pointers[b2] = this.top(); vec.dirty = true; return; case 0xFD: b2 = this.nextByte(); // remote store and pop into temp vector - var vec = this.homeContext.pointers[Squeak.Context_tempFrameStart+this.nextByte()]; + var vec = this.temps[this.tempOffset+this.nextByte()]; vec.pointers[b2] = this.pop(); vec.dirty = true; return; @@ -729,7 +731,7 @@ Object.subclass('Squeak.Interpreter', var lobits = nextByte & 63; switch (nextByte>>6) { case 0: this.push(this.receiver.pointers[lobits]);break; - case 1: this.push(this.homeContext.pointers[Squeak.Context_tempFrameStart+lobits]); break; + case 1: this.push(this.temps[this.tempOffset+lobits]); break; case 2: this.push(this.method.methodGetLiteral(lobits)); break; case 3: this.push(this.method.methodGetLiteral(lobits).pointers[Squeak.Assn_value]); break; } @@ -742,7 +744,7 @@ Object.subclass('Squeak.Interpreter', this.receiver.pointers[lobits] = this.top(); break; case 1: - this.homeContext.pointers[Squeak.Context_tempFrameStart+lobits] = this.top(); + this.temps[this.tempOffset+lobits] = this.top(); break; case 2: this.nono(); @@ -762,7 +764,7 @@ Object.subclass('Squeak.Interpreter', this.receiver.pointers[lobits] = this.pop(); break; case 1: - this.homeContext.pointers[Squeak.Context_tempFrameStart+lobits] = this.pop(); + this.temps[this.tempOffset+lobits] = this.pop(); break; case 2: this.nono(); @@ -1059,6 +1061,8 @@ Object.subclass('Squeak.Interpreter', this.pc = newPC; this.sp = newSP; this.stack = newContext.pointers; + this.temps = newContext.pointers; // home === newContext for method activations + this.tempOffset = Squeak.Context_tempFrameStart; this.receiver = newContext.pointers[Squeak.Context_receiver]; if (this.receiver !== newRcvr) throw Error("receivers don't match"); @@ -1332,6 +1336,8 @@ Object.subclass('Squeak.Interpreter', this.pc = this.decodeSqueakPC(ctxt.pointers[Squeak.Context_instructionPointer], meth); this.sp = this.decodeSqueakSP(ctxt.pointers[Squeak.Context_stackPointer]); this.stack = ctxt.pointers; // operand stack array; sp indexes into it + this.temps = this.homeContext.pointers; // temp access: temps[tempOffset+n] + this.tempOffset = Squeak.Context_tempFrameStart; }, storeContextRegisters: function() { //Save pc, sp into activeContext object, prior to change of context From cad0d6069c8f42adbaab81263dffa32536088165 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:31:28 -0300 Subject: [PATCH 08/22] [perf] stack zone: flat frames with lazy context reification, oracle-validated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New vm.stackzone.js implements the design in perf/stack-zone-design.md: sends push flat frames into growable zone pages; heap Contexts exist only as married snapshots (created on thisContext/closure creation/ process switch), synced when a married context is a send receiver or reflective-primitive target, widowed on frame return, and flushed wholesale on writes/become/snapshot. makeBaseFrame inflates dormant contexts; the interpreter never executes on heap contexts. Enabled per instance via options.stackZone; context mode is untouched (golden trace identical). Supporting changes: - interpretOne receiver inst-var stores routed through storeInstVar (incl. the doubleExtended 0x84 cases Cuis uses for Context>>sender/ terminate — missing those made unwind chain surgery invisible to live frames) - blockReturn bytecodes routed through doBlockReturn - context identity hashes drawn from a separate stream so ordinary objects get allocation-order-independent hashes across representations - tryPrimitive activation-change check uses page/fp in frames mode (an instance-level activeContext accessor was a 10x+ slowdown) - harness: --frames/--nojit flags, cadence-independent oracle sampling at fixed sendCount checkpoints, per-send fine logging, debug hooks Validation: full Cuis startup (3,398,634 sends, boot through WS timeout to quit) produces the identical trace hash as context mode without jit, zero divergent checkpoints. Bench (20M sends): frames-nojit 2.62M sends/s vs contexts-nojit 2.46M (+6.5%) vs contexts+jit 2.84M — the jit frames templates are the next milestone. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 212 ++++++++++++- vm.image.js | 16 +- vm.interpreter.js | 50 ++- vm.primitives.js | 17 +- vm.stackzone.js | 636 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 893 insertions(+), 38 deletions(-) create mode 100644 vm.stackzone.js diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 32e21a5e..cd6b03c9 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -21,6 +21,8 @@ var args = process.argv.slice(2); var mode = args.indexOf("--golden") >= 0 ? "golden" : args.indexOf("--bench") >= 0 ? "bench" : "check"; +var useFrames = args.indexOf("--frames") >= 0; // correr con el stack zone activado +var noJit = args.indexOf("--nojit") >= 0; // apagar el jit (para aislar divergencias jit vs frames) function argValue(name, deflt) { var i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : deflt; @@ -28,7 +30,9 @@ function argValue(name, deflt) { var maxSends = parseInt(argValue("--sends", "20000000"), 10); var imagePath = path.resolve(repoRoot, argValue("--image", "ws/client/cuis.image")); var goldenPath = path.join(__dirname, "golden.json"); -var logPath = argValue("--log", null); // traza por slice, para ubicar divergencias +var logPath = argValue("--log", null); // traza por checkpoint, para ubicar divergencias +var logFrom = parseInt(argValue("--logfrom", "-1"), 10); // log fino por-send en [logfrom, logto] +var logTo = parseInt(argValue("--logto", "-1"), 10); // --------------------------------------------------------------------------- // Entorno determinista (solo en modos de traza; --bench usa el reloj real) @@ -103,6 +107,7 @@ Object.assign(self, { "vm.instruction.stream.sista.js", "vm.instruction.printer.js", "vm.primitives.js", "jit.js", "vm.display.js", "vm.display.headless.js", "vm.input.js", "vm.input.headless.js", "vm.plugins.js", "vm.plugins.file.node.js", + "vm.stackzone.js", ].forEach(function(f) { require(path.join(repoRoot, f)); }); Object.extend(Squeak, { @@ -151,9 +156,190 @@ var data = fs.readFileSync(imagePath); var image = new Squeak.Image(imagePath.replace(/\.image$/, "")); image.readFromBuffer(data.buffer, function startRunning() { var display = { vmOptions: ["-vm-display-null", "-nodisplay"] }; - var vm = new Squeak.Interpreter(image, display); + var vm = new Squeak.Interpreter(image, display, useFrames ? { stackZone: true } : {}); + if (noJit) vm.compiler = null; + if (process.env.ZDBG9) { + // volcar bytes+literales del método activado cuando mbytes coincide + var z9size = parseInt(process.env.ZDBG9_SIZE), z9From = parseInt(process.env.ZDBG9_FROM || "0"); + var origENM9 = vm.executeNewMethod; + var dumped = 0; + vm.executeNewMethod = function(newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel) { + if (vm.sendCount >= z9From && dumped < 2 && newMethod.bytes && newMethod.bytes.length === z9size) { + dumped++; + var lits = []; + for (var i = 0; i < newMethod.pointers.length; i++) { + var l = newMethod.pointers[i]; + lits.push(l && l.bytesAsString ? l.bytesAsString() : (l && l.sqClass ? l.sqClass.className() : String(l))); + } + console.error("MDUMP s=" + vm.sendCount + " sel=" + (optSel && optSel.bytesAsString ? optSel.bytesAsString() : "?") + + " bytes=[" + Array.prototype.join.call(newMethod.bytes, ",") + "] lits=" + JSON.stringify(lits)); + } + return origENM9.call(vm, newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel); + }; + } + if (process.env.ZDBG7) { + // dump de cadena al activar un método específico (por _traceId) + var z7id = parseInt(process.env.ZDBG7_ID), z7From = parseInt(process.env.ZDBG7_FROM || "0"); + var chainDesc = function() { + var desc = []; + if (vm.useStackZone) { + var page = vm.zonePage, fp = vm.fp, hops = 0; + while (fp >= 0 && hops++ < 20) { + var m = page.slots[fp + Squeak.Frame_method]; + var cl = page.slots[fp + Squeak.Frame_closure]; + desc.push("m" + (m.bytes ? m.bytes.length : "?") + (cl && !cl.isNil ? "b" : "")); + fp = page.slots[fp + Squeak.Frame_savedFp]; + } + if (fp < 0) desc.push("BASE:" + (page.baseCallerCtx && !page.baseCallerCtx.isNil ? "ctx" : "nil")); + } else { + var ctx = vm.activeContext, hops = 0; + while (!ctx.isNil && hops++ < 20) { + var m2 = ctx.pointers[Squeak.Context_method]; + var cl2 = ctx.pointers[Squeak.Context_closure]; + desc.push("m" + (m2.bytes ? m2.bytes.length : (vm.isSmallInt(m2) ? "INT" : "?")) + (cl2 && !cl2.isNil ? "b" : "")); + ctx = ctx.pointers[Squeak.Context_sender]; + } + } + return desc.join("<"); + }; + var origENM7 = vm.executeNewMethod; + vm.executeNewMethod = function(newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel) { + var r = origENM7.call(vm, newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel); + if (vm.sendCount >= z7From && newMethod._traceId === z7id) + console.error("ACT s=" + vm.sendCount + " chain: " + chainDesc()); + return r; + }; + } + if (process.env.ZDBG6) { + var origTT = vm.primHandler.transferTo; + vm.primHandler.transferTo = function(newProc) { + console.error("TT s=" + vm.sendCount); + return origTT.call(this, newProc); + }; + } + if (process.env.ZDBG5) { + // historial de activaciones de contexto explícitas (process switch / value de + // contextos dormidos) en ambos modos + var origNAC = vm.newActiveContext; + vm.newActiveContext = function(newContext) { + var m = newContext.pointers[Squeak.Context_method]; + console.error("NAC s=" + vm.sendCount + + " mbytes=" + (m && m.bytes ? m.bytes.length : "int?") + + " senderNil=" + (newContext.pointers[Squeak.Context_sender].isNil === true) + + (vm.useStackZone ? " frame=" + (newContext.frame != null) : "")); + return origNAC.call(vm, newContext); + }; + } + if (process.env.ZDBG4) { + // atrapar escrituras de campos de contexts en modo contexts: + // via bytecode (storeInstVar) y via primitivos (objectAtPut/storeStackp) + var ctxClass = vm.specialObjects[Squeak.splOb_ClassMethodContext]; + var origSIV = vm.storeInstVar; + var sivCount = 0; + vm.storeInstVar = function(index, value) { + if (++sivCount <= 3) console.error("SIV-ALIVE #" + sivCount + " s=" + vm.sendCount + " rcls=" + this.getClass(this.receiver).className()); + if (this.receiver.sqClass === ctxClass) + console.error("CTXSTORE s=" + vm.sendCount + " idx=" + index + " val=" + (value && value.isNil ? "nil" : typeof value)); + return origSIV.call(this, index, value); + }; + var origOAP = vm.primHandler.objectAtPut; + vm.primHandler.objectAtPut = function(a, b, c) { + var rcvr = this.stackNonInteger(2); + if (rcvr.sqClass === ctxClass) + console.error("CTXATPUT s=" + vm.sendCount + " idx=" + this.stackPos32BitInt(1)); + return origOAP.call(this, a, b, c); + }; + var origSSP = vm.primHandler.primitiveStoreStackp; + vm.primHandler.primitiveStoreStackp = function(argCount) { + console.error("CTXSTACKP s=" + vm.sendCount); + return origSSP.call(this, argCount); + }; + } + if (process.env.ZDBG3) { + // dump de cadenas en la ventana: frames (fp chain) vs contexts (sender chain) + var z3From = parseInt(process.env.ZDBG3_FROM || "0"), z3To = parseInt(process.env.ZDBG3_TO || "99999999"); + var origDR = vm.doReturn; + vm.doReturn = function(returnValue, targetContext) { + if (vm.sendCount >= z3From && vm.sendCount <= z3To) { + var desc = []; + if (vm.useStackZone) { + var page = vm.zonePage, fp = vm.fp, hops = 0; + while (fp >= 0 && hops++ < 12) { + var m = page.slots[fp + Squeak.Frame_method]; + var cl = page.slots[fp + Squeak.Frame_closure]; + desc.push(fp + ":m" + (m.bytes ? m.bytes.length : "?") + (cl && !cl.isNil ? "[blk]" : "")); + fp = page.slots[fp + Squeak.Frame_savedFp]; + } + if (fp < 0) desc.push("base->" + (page.baseCallerCtx && !page.baseCallerCtx.isNil ? "ctx" : "nil")); + } else { + var ctx = vm.activeContext, hops = 0; + while (!ctx.isNil && hops++ < 12) { + var m2 = ctx.pointers[Squeak.Context_method]; + var cl2 = ctx.pointers[Squeak.Context_closure]; + desc.push("m" + (m2.bytes ? m2.bytes.length : "?") + (cl2 && !cl2.isNil ? "[blk]" : "")); + ctx = ctx.pointers[Squeak.Context_sender]; + } + } + console.error("DR s=" + vm.sendCount + " pc=" + vm.pc + " chain: " + desc.join(" <- ")); + } + return origDR.call(vm, returnValue, targetContext); + }; + } + if (process.env.ZDBG) { + var zFrom = parseInt(process.env.ZDBG_FROM || "0"), zTo = parseInt(process.env.ZDBG_TO || "99999999"); + var origANC = vm.primHandler.activateNewClosureMethod; + vm.primHandler.activateNewClosureMethod = function(blockClosure, argCount) { + if (vm.sendCount >= zFrom && vm.sendCount <= zTo) { + var outer = blockClosure.pointers[Squeak.Closure_outerContext]; + var m = outer.frame != null ? outer.frame.page.slots[outer.frame.fp + Squeak.Frame_method] + : outer.pointers[Squeak.Context_method]; + console.error("ANC s=" + vm.sendCount + " startpc=" + blockClosure.pointers[Squeak.Closure_startpc] + + " mlits=" + (m.pointers ? m.pointers.length : "?") + " mbytes=" + (m.bytes ? m.bytes.length : "?") + + " outerMarried=" + (outer.frame != null) + " isNilM=" + (m.isNil === true)); + } + var r = origANC.call(this, blockClosure, argCount); + if (vm.sendCount >= zFrom && vm.sendCount <= zTo && !vm._zdbgArmed) { + vm._zdbgArmed = 60; + var origIO = vm.interpretOne.bind(vm); + vm.interpretOne = function(singleStep) { + if (vm._zdbgArmed-- > 0) + console.error("BC pc=" + vm.pc + " byte=" + vm.method.bytes[vm.pc] + + " mbytes=" + vm.method.bytes.length + " sp=" + vm.sp + " fp=" + vm.fp); + return origIO(singleStep); + }; + } + return r; + }; + } var slices = 0, idleStreak = 0, stopReason = "maxSends"; var logLines = logPath ? [] : null; + if (mode !== "bench") { + // Muestreo en checkpoints fijos de sendCount: independiente de la + // representación (contexts/frames) Y de la cadencia de interrupciones + // (que difiere con/sin jit). Es la señal que entra al hash. + var origENM = vm.executeNewMethod; + vm.executeNewMethod = function(newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel) { + // método identificado por fingerprint de contenido (los oops temporales + // interleavan contexts y difieren entre representaciones; el identity + // hash de métodos de imagen V3 es 0) + if (newMethod._traceId === undefined) { + var fp = newMethod.bytes ? newMethod.bytes.length : 0; + if (newMethod.bytes) for (var bi = 0; bi < Math.min(newMethod.bytes.length, 16); bi++) + fp = ((fp * 31) + newMethod.bytes[bi]) | 0; + newMethod._traceId = fp; + } + if (vm.sendCount < maxSends && (vm.sendCount & 4095) === 0) { + mix(vm.sendCount); + mix(newMethod._traceId); + mix(vm.pc); + if (logLines) logLines.push("s=" + vm.sendCount + " h=" + newMethod._traceId + " pc=" + vm.pc); + } + if (logLines && vm.sendCount >= logFrom && vm.sendCount <= logTo) + logLines.push("S=" + vm.sendCount + " h=" + newMethod._traceId + " pc=" + vm.pc + " args=" + argumentCount + " prim=" + primitiveIndex + + " rcls=" + vm.getClass(newRcvr).className() + " sel=" + (optSel && optSel.bytesAsString ? optSel.bytesAsString() : "?")); + return origENM.call(vm, newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel); + }; + } var wallStart = process.hrtime.bigint(); clockRunning = true; try { @@ -161,17 +347,9 @@ image.readFromBuffer(data.buffer, function startRunning() { if (display.quitFlag) { stopReason = "quit"; break; } var result = vm.interpret(5); slices++; - if (mode !== "bench") { - // Solo estado independiente de la representación de contexts: - // sp/activeContext cambian de significado con el stack zone, - // pero sendCount/método/pc son comparables entre ambos VMs. - mix(vm.sendCount); - mix(vm.pc); - mix(vm.method && vm.method.oop ? vm.method.oop : 0); - if (logLines) logLines.push(slices + " sends=" + vm.sendCount + " pc=" + vm.pc + - " oop=" + (vm.method && vm.method.oop) + - " vms=" + virtualMs + " r=" + result); - } + // el hash se alimenta solo de los checkpoints por sendCount (arriba); + // los límites de slice dependen de la cadencia de interrupciones, + // que varía entre modos (jit/no-jit) sin implicar divergencia semántica if (result === "sleep") { // todos los procesos esperan sin timer: nada más va a pasar if (++idleStreak >= 3) { stopReason = "idle"; break; } @@ -188,6 +366,7 @@ image.readFromBuffer(data.buffer, function startRunning() { } } catch (e) { stopReason = "error: " + e.message; + if (process.env.DIFFTRACE_DEBUG) console.error(e.stack); } var wallMs = Number(process.hrtime.bigint() - wallStart) / 1e6; if (logLines) fs.writeFileSync(logPath, logLines.join("\n") + "\n"); @@ -231,9 +410,10 @@ image.readFromBuffer(data.buffer, function startRunning() { process.exit(2); } var golden = JSON.parse(fs.readFileSync(goldenPath, "utf8")); - // slices/virtualMs quedan fuera de la comparación: son del scheduling, - // no de la semántica (podrían variar levemente entre representaciones) - var keys = ["image", "maxSends", "sendCount", "stopReason", "hash"]; + // slices/virtualMs/sendCount-final quedan fuera de la comparación: + // dependen de la cadencia de interrupciones y la granularidad del slice, + // no de la semántica + var keys = ["image", "maxSends", "stopReason", "hash"]; var diffs = keys.filter(function(k) { return String(golden[k]) !== String(report[k]); }); if (diffs.length === 0) { console.log("OK: traza idéntica al golden"); diff --git a/vm.image.js b/vm.image.js index ea9c04e1..982e5e75 100644 --- a/vm.image.js +++ b/vm.image.js @@ -760,11 +760,25 @@ Object.subclass('Squeak.Image', }, instantiateClass: function(aClass, indexableSize, filler) { var newObject = new (aClass.classInstProto()); // Squeak.Object - var hash = this.registerObject(newObject); + var hash = aClass === this.contextClass() ? this.registerContext(newObject) + : this.registerObject(newObject); newObject.initInstanceOf(aClass, indexableSize, hash, filler); this.hasNewInstances[aClass.oop] = true; // need GC to find all instances return newObject; }, + contextClass: function() { + return this.specialObjectsArray.pointers[Squeak.splOb_ClassMethodContext]; + }, + registerContext: function(obj) { + // like registerObject, but contexts draw their identity hashes from a + // separate stream: their allocation pattern is an implementation detail + // (recycling, or stack-zone frames that materialize contexts lazily), + // and interleaving them into the main stream would make every other + // object's identity hash depend on it + obj.oop = -(++this.newSpaceCount); + this.lastContextHash = (13849 + (27181 * (this.lastContextHash || 999))) & 0xFFFFFFFF; + return this.lastContextHash & 0xFFF; + }, clone: function(object) { var newObject = new (object.sqClass.classInstProto()); // Squeak.Object var hash = this.registerObject(newObject); diff --git a/vm.interpreter.js b/vm.interpreter.js index 1a200cc2..f55caa0d 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -32,9 +32,11 @@ Object.subclass('Squeak.Interpreter', this.primHandler = new Squeak.Primitives(this, display); this.loadImageState(); this.initVMState(); + if (this.options.stackZone && this.enableStackZone) this.enableStackZone(); this.loadInitialContext(); this.hackImage(); this.initCompiler(); + if (this.useStackZone) this.compiler = null; // jit templates for frames mode not implemented yet console.log('squeak: ready'); }, loadImageState: function() { @@ -58,6 +60,11 @@ Object.subclass('Squeak.Interpreter', this.stack = null; // operand stack array (activeContext.pointers; a zone page once stack frames land) this.temps = null; // temp base array (homeContext.pointers; a zone page once stack frames land) this.tempOffset = Squeak.Context_tempFrameStart; // index of temp 0 in this.temps + // stack-zone state, declared here for stable object shape even when unused + this.useStackZone = false; + this.zonePages = null; + this.zonePage = null; + this.fp = -1; this.interruptCheckCounter = 0; this.interruptCheckCounterFeedBackReset = 1000; this.interruptChecksEveryNms = 3; @@ -253,7 +260,7 @@ Object.subclass('Squeak.Interpreter', // storeAndPop rcvr, temp case 0x60: case 0x61: case 0x62: case 0x63: case 0x64: case 0x65: case 0x66: case 0x67: this.receiver.dirty = true; - this.receiver.pointers[b&7] = this.pop(); return; + this.storeInstVar(b&7, this.pop()); return; case 0x68: case 0x69: case 0x6A: case 0x6B: case 0x6C: case 0x6D: case 0x6E: case 0x6F: this.temps[this.tempOffset+(b&7)] = this.pop(); return; @@ -273,7 +280,7 @@ Object.subclass('Squeak.Interpreter', case 0x7A: this.doReturn(this.falseObj); return; case 0x7B: this.doReturn(this.nilObj); return; case 0x7C: this.doReturn(this.pop()); return; - case 0x7D: this.doReturn(this.pop(), this.activeContext.pointers[Squeak.BlockContext_caller]); return; // blockReturn + case 0x7D: this.doBlockReturn(this.pop()); return; // blockReturn case 0x7E: this.nono(); return; case 0x7F: this.nono(); return; // Sundry @@ -436,10 +443,10 @@ Object.subclass('Squeak.Interpreter', case 0x5A: this.doReturn(this.falseObj); return; case 0x5B: this.doReturn(this.nilObj); return; case 0x5C: this.doReturn(this.pop()); return; - case 0x5D: this.doReturn(this.nilObj, this.activeContext.pointers[Squeak.BlockContext_caller]); return; // blockReturn nil + case 0x5D: this.doBlockReturn(this.nilObj); return; // blockReturn nil case 0x5E: if (extA == 0) { - this.doReturn(this.pop(), this.activeContext.pointers[Squeak.BlockContext_caller]); return; // blockReturn + this.doBlockReturn(this.pop()); return; // blockReturn } else { this.nono(); return; } @@ -510,7 +517,7 @@ Object.subclass('Squeak.Interpreter', // storeAndPop rcvr, temp case 0xC8: case 0xC9: case 0xCA: case 0xCB: case 0xCC: case 0xCD: case 0xCE: case 0xCF: this.receiver.dirty = true; - this.receiver.pointers[b&7] = this.pop(); return; + this.storeInstVar(b&7, this.pop()); return; case 0xD0: case 0xD1: case 0xD2: case 0xD3: case 0xD4: case 0xD5: case 0xD6: case 0xD7: this.temps[this.tempOffset+(b&7)] = this.pop(); return; @@ -561,7 +568,7 @@ Object.subclass('Squeak.Interpreter', this.jumpIfFalse(this.nextByte() + (extB << 8)); return; case 0xF0: // pop into receiver this.receiver.dirty = true; - this.receiver.pointers[this.nextByte() + (extA << 8)] = this.pop(); + this.storeInstVar(this.nextByte() + (extA << 8), this.pop()); return; case 0xF1: // pop into literal var assoc = this.method.methodGetLiteral(this.nextByte() + (extA << 8)); @@ -573,7 +580,7 @@ Object.subclass('Squeak.Interpreter', return; case 0xF3: // store into receiver this.receiver.dirty = true; - this.receiver.pointers[this.nextByte() + (extA << 8)] = this.top(); + this.storeInstVar(this.nextByte() + (extA << 8), this.top()); return; case 0xF4: // store into literal var assoc = this.method.methodGetLiteral(this.nextByte() + (extA << 8)); @@ -741,7 +748,7 @@ Object.subclass('Squeak.Interpreter', switch (nextByte>>6) { case 0: this.receiver.dirty = true; - this.receiver.pointers[lobits] = this.top(); + this.storeInstVar(lobits, this.top()); break; case 1: this.temps[this.tempOffset+lobits] = this.top(); @@ -761,7 +768,7 @@ Object.subclass('Squeak.Interpreter', switch (nextByte>>6) { case 0: this.receiver.dirty = true; - this.receiver.pointers[lobits] = this.pop(); + this.storeInstVar(lobits, this.pop()); break; case 1: this.temps[this.tempOffset+lobits] = this.pop(); @@ -784,8 +791,8 @@ Object.subclass('Squeak.Interpreter', case 2: this.push(this.receiver.pointers[byte3]); break; case 3: this.push(this.method.methodGetLiteral(byte3)); break; case 4: this.push(this.method.methodGetLiteral(byte3).pointers[Squeak.Assn_value]); break; - case 5: this.receiver.dirty = true; this.receiver.pointers[byte3] = this.top(); break; - case 6: this.receiver.dirty = true; this.receiver.pointers[byte3] = this.pop(); break; + case 5: this.receiver.dirty = true; this.storeInstVar(byte3, this.top()); break; + case 6: this.receiver.dirty = true; this.storeInstVar(byte3, this.pop()); break; case 7: var assoc = this.method.methodGetLiteral(byte3); assoc.dirty = true; assoc.pointers[Squeak.Assn_value] = this.top(); break; @@ -850,7 +857,7 @@ Object.subclass('Squeak.Interpreter', blockSize = blockSizeHigh * 256 + this.nextByte(), initialPC = this.encodeSqueakPC(this.pc, this.method), closure = this.newClosure(numArgs, initialPC, numCopied); - closure.pointers[Squeak.Closure_outerContext] = this.activeContext; + closure.pointers[Squeak.Closure_outerContext] = this.activeContextObj(); this.reclaimableContextCount = 0; // The closure refers to thisContext so it can't be reclaimed if (numCopied > 0) { for (var i = 0; i < numCopied; i++) @@ -868,7 +875,7 @@ Object.subclass('Squeak.Interpreter', blockSize = byteB + (extB << 8), initialPC = this.encodeSqueakPC(this.pc, this.method), closure = this.newClosure(numArgs, initialPC, numCopied); - closure.pointers[Squeak.Closure_outerContext] = this.activeContext; + closure.pointers[Squeak.Closure_outerContext] = this.activeContextObj(); this.reclaimableContextCount = 0; // The closure refers to thisContext so it can't be reclaimed if (numCopied > 0) { for (var i = 0; i < numCopied; i++) @@ -887,7 +894,7 @@ Object.subclass('Squeak.Interpreter', if ((byteB >> 6 & 1) == 1) { context = this.vm.nilObj; } else { - context = this.activeContext; + context = this.activeContextObj(); } var compiledBlock = this.method.methodGetLiteral(literalIndex); var closure = this.newFullClosure(context, numCopied, compiledBlock); @@ -1123,6 +1130,21 @@ Object.subclass('Squeak.Interpreter', this.breakNow(); } }, + doBlockReturn: function(returnValue) { + // return from block to its caller (stack-zone mode rebinds this) + this.doReturn(returnValue, this.activeContext.pointers[Squeak.BlockContext_caller]); + }, + activeContextObj: function() { + // the active context as an object (stack-zone mode rebinds this to marry the frame) + return this.activeContext; + }, + storeInstVar: function(index, value) { + // receiver inst-var store from a bytecode; in stack-zone mode a store + // into a married-live context must go through the write-through path + var rcvr = this.receiver; + if (rcvr.frame != null) return this.storeToMarriedContext(rcvr, index, value); + rcvr.pointers[index] = value; + }, aboutToReturnThrough: function(resultObj, aContext) { this.push(this.exportThisContext()); this.push(resultObj); diff --git a/vm.primitives.js b/vm.primitives.js index 0076aaf0..7fd7a3ed 100644 --- a/vm.primitives.js +++ b/vm.primitives.js @@ -1344,7 +1344,7 @@ Object.subclass('Squeak.Primitives', primIdx = this.stackInteger(1); if (!this.success) return false; var arraySize = argumentArray.pointersSize(), - cntxSize = this.vm.activeContext.pointersSize(); + cntxSize = this.vm.activeContextObj().pointersSize(); if (this.vm.sp + arraySize >= cntxSize) return false; // Pop primIndex and argArray, then push args in place... this.vm.popN(2); @@ -1365,7 +1365,7 @@ Object.subclass('Squeak.Primitives', primMethod = this.stackNonInteger(2); if (!this.success) return false; var arraySize = argumentArray.pointersSize(), - cntxSize = this.vm.activeContext.pointersSize(); + cntxSize = this.vm.activeContextObj().pointersSize(); if (this.vm.sp + arraySize >= cntxSize) return false; // Pop primIndex, rcvr, and argArray, then push new receiver and args in place... this.vm.popN(3); @@ -1471,7 +1471,10 @@ Object.subclass('Squeak.Primitives', if (!this.success) return false; this.success = this.vm.image.bulkBecome(rcvr.pointers, arg.pointers, doBothWays, copyHash); // become may have swapped the active context's pointers array - this.vm.stack = this.vm.activeContext.pointers; + if (!this.vm.useStackZone) { + this.vm.stack = this.vm.activeContext.pointers; + this.vm.temps = this.vm.homeContext.pointers; + } return this.popNIfOK(argCount); }, doStringReplace: function() { @@ -1590,7 +1593,7 @@ Object.subclass('Squeak.Primitives', var initialIP = block.pointers[Squeak.BlockContext_initialIP]; block.pointers[Squeak.Context_instructionPointer] = initialIP; block.pointers[Squeak.Context_stackPointer] = argCount; - block.pointers[Squeak.BlockContext_caller] = this.vm.activeContext; + block.pointers[Squeak.BlockContext_caller] = this.vm.activeContextObj(); this.vm.popN(argCount+1); this.vm.newActiveContext(block); if (this.vm.interruptCheckCounter-- <= 0) this.vm.checkForInterrupts(); @@ -1609,7 +1612,7 @@ Object.subclass('Squeak.Primitives', var initialIP = block.pointers[Squeak.BlockContext_initialIP]; block.pointers[Squeak.Context_instructionPointer] = initialIP; block.pointers[Squeak.Context_stackPointer] = blockArgCount; - block.pointers[Squeak.BlockContext_caller] = this.vm.activeContext; + block.pointers[Squeak.BlockContext_caller] = this.vm.activeContextObj(); this.vm.popN(argCount+1); this.vm.newActiveContext(block); if (this.vm.interruptCheckCounter-- <= 0) this.vm.checkForInterrupts(); @@ -1772,7 +1775,7 @@ Object.subclass('Squeak.Primitives', var oldProc = sched.pointers[Squeak.ProcSched_activeProcess]; sched.pointers[Squeak.ProcSched_activeProcess] = newProc; sched.dirty = true; - oldProc.pointers[Squeak.Proc_suspendedContext] = this.vm.activeContext; + oldProc.pointers[Squeak.Proc_suspendedContext] = this.vm.activeContextObj(); oldProc.dirty = true; this.vm.newActiveContext(newProc.pointers[Squeak.Proc_suspendedContext]); newProc.pointers[Squeak.Proc_suspendedContext] = this.vm.nilObj; @@ -2151,7 +2154,7 @@ Object.subclass('Squeak.Primitives', primitiveSnapshot: function(argCount) { this.vm.popNandPush(1, this.vm.trueObj); // put true on stack for saved snapshot this.vm.storeContextRegisters(); // store current state for snapshot - this.activeProcess().pointers[Squeak.Proc_suspendedContext] = this.vm.activeContext; // store initial context + this.activeProcess().pointers[Squeak.Proc_suspendedContext] = this.vm.activeContextObj(); // store initial context this.vm.image.fullGC("snapshot"); // before cleanup so traversal works var buffer = this.vm.image.writeToBuffer(); // Write snapshot if files are supported diff --git a/vm.stackzone.js b/vm.stackzone.js new file mode 100644 index 00000000..12799aac --- /dev/null +++ b/vm.stackzone.js @@ -0,0 +1,636 @@ +"use strict"; +/* + * Stack zone: flat activation frames with lazy context reification. + * See perf/stack-zone-design.md for the full design and the v1 marriage + * protocol (snapshot + sync-at-send + flush-on-write). + * + * Enabled per interpreter instance via options.stackZone. When enabled, the + * interpreter NEVER executes on heap contexts: sends push flat frames into + * growable zone pages (one page per process fragment), and heap Context + * objects exist only as dormant snapshots. makeBaseFrame (context -> frame) + * and flush (frame -> context) are the only transitions. + * + * Frame layout (slot indices relative to fp, stack grows up): + * receiver+args pushed by caller end at fp-1 (rcvr at fp-1-numArgs) + * fp+0 savedFp caller's fp in same page, -1 if base frame of page + * fp+1 savedPc caller's resumption pc (raw zero-based int, no encoding) + * fp+2 method CompiledMethod + * fp+3 flags numArgs (16 bits) | hasContext<<17 + * fp+4 context married Context or null + * fp+5 closure BlockClosure/FullBlockClosure or nilObj + * fp+6 receiver + * fp+7 temps (args copied here first, then locals), then operand stack + */ + +Object.extend(Squeak, { + Frame_savedFp: 0, + Frame_savedPc: 1, + Frame_method: 2, + Frame_flags: 3, + Frame_context: 4, + Frame_closure: 5, + Frame_receiver: 6, + Frame_firstTemp: 7, + Frame_hasContext: 1 << 17, +}); + +Object.extend(Squeak.Interpreter.prototype, +'stack zone', { + enableStackZone: function() { + this.useStackZone = true; + this.zonePages = []; + this.zonePage = null; + this.fp = -1; + // no hay getter mágico: activeContext queda null y los lectores legítimos + // usan activeContextObj() (un defineProperty con getter sobre la instancia + // degrada TODOS los accesos a propiedades del vm — 10x+ en el camino caliente) + this.activeContext = null; + var vm = this; + // rebind execution methods to the frames variants + this.send = this.sendZ; + this.tryPrimitive = this.tryPrimitiveZ; + this.activeContextObj = this.activeContextObjZ; + this.sendSuperDirected = this.sendSuperDirectedZ; + this.executeNewMethod = this.executeNewMethodZ; + this.doReturn = this.doReturnZ; + this.doBlockReturn = this.doBlockReturnZ; + this.exportThisContext = this.exportThisContextZ; + this.newActiveContext = this.newActiveContextZ; + this.loadInitialContext = this.loadInitialContextZ; + this.storeContextRegisters = this.storeContextRegistersZ; + this.fetchContextRegisters = this.fetchContextRegistersZ; + this.allocateOrRecycleContext = this.allocateOrRecycleContextZ; + // primHandler: closure activation pushes frames; reflective prims sync/flush + var ph = this.primHandler; + ph.activateNewClosureMethod = ph.activateNewClosureMethodZ; + ph.activateNewFullClosure = ph.activateNewFullClosureZ; + var origObjectAt = ph.objectAt; + ph.objectAt = function(cameFromBytecode, convertChars, includeInstVars) { + var rcvr = this.stackNonInteger(1); + if (rcvr.frame != null) vm.syncPage(rcvr.frame.page); + return origObjectAt.call(this, cameFromBytecode, convertChars, includeInstVars); + }; + var origObjectAtPut = ph.objectAtPut; + ph.objectAtPut = function(cameFromBytecode, convertChars, includeInstVars) { + var rcvr = this.stackNonInteger(2); + if (rcvr.frame != null) vm.flushAllAndContinue(); + return origObjectAtPut.call(this, cameFromBytecode, convertChars, includeInstVars); + }; + var origStoreStackp = ph.primitiveStoreStackp; + ph.primitiveStoreStackp = function(argCount) { + var ctxt = this.stackNonInteger(1); + if (ctxt.frame != null) vm.flushAllAndContinue(); + return origStoreStackp.call(this, argCount); + }; + var origArrayBecome = ph.primitiveArrayBecome; + ph.primitiveArrayBecome = function(argCount, doBothWays, copyHash) { + // frames hold raw refs the become machinery doesn't know about: + // flush everything to real contexts first, then become sees it all + vm.flushAllAndContinue(); + return origArrayBecome.call(this, argCount, doBothWays, copyHash); + }; + var origSnapshot = ph.primitiveSnapshot; + ph.primitiveSnapshot = function(argCount) { + vm.flushAllAndContinue(); + return origSnapshot.call(this, argCount); + }; + // logical GC: live page slots are roots + this.image.gcRoots = function() { + return [this.specialObjectsArray].concat(vm.frameGCRoots()); + }; + }, + allocPage: function() { + for (var i = 0; i < this.zonePages.length; i++) + if (!this.zonePages[i].live) { + var page = this.zonePages[i]; + page.slots.length = 0; + page.live = true; + page.baseCallerCtx = null; + return page; + } + var fresh = { slots: [], fp: -1, sp: -1, pc: -1, live: true, baseCallerCtx: null }; + this.zonePages.push(fresh); + return fresh; + }, + activatePage: function(page) { + this.zonePage = page; + var slots = page.slots; + this.stack = slots; + this.temps = slots; + this.fp = page.fp; + this.sp = page.sp; + this.pc = page.pc; + this.method = slots[this.fp + Squeak.Frame_method]; + this.receiver = slots[this.fp + Squeak.Frame_receiver]; + this.tempOffset = this.fp + Squeak.Frame_firstTemp; + this.homeContext = null; // not meaningful in frames mode + }, + saveActivePage: function() { + var page = this.zonePage; + if (!page) return; + page.fp = this.fp; + page.sp = this.sp; + page.pc = this.pc; + }, + frameGCRoots: function() { + this.saveActivePage(); + var roots = []; + for (var i = 0; i < this.zonePages.length; i++) { + var page = this.zonePages[i]; + if (!page.live) continue; + var slots = page.slots; + for (var j = 0; j <= page.sp; j++) { + var v = slots[j]; + if (typeof v === "object" && v !== null) roots.push(v); + } + } + return roots; + }, +}, +'frames: marriage', { + marryFrame: function(page, fp) { + var slots = page.slots; + var ctx = slots[fp + Squeak.Frame_context]; + if (ctx) return ctx; + var method = slots[fp + Squeak.Frame_method]; + ctx = this.instantiateClass(this.specialObjects[Squeak.splOb_ClassMethodContext], + method.methodNeedsLargeFrame() ? Squeak.Context_largeFrameSize : Squeak.Context_smallFrameSize); + // shallow snapshot: immutable fields real, mutable ones minimal-but-sane + // (syncPage fills pc/stackp/sender/temps before Smalltalk can look) + ctx.pointers[Squeak.Context_sender] = this.nilObj; + ctx.pointers[Squeak.Context_instructionPointer] = this.nilObj; + ctx.pointers[Squeak.Context_stackPointer] = 0; + ctx.pointers[Squeak.Context_method] = method; + ctx.pointers[Squeak.Context_closure] = slots[fp + Squeak.Frame_closure]; + ctx.pointers[Squeak.Context_receiver] = slots[fp + Squeak.Frame_receiver]; + ctx.frame = { page: page, fp: fp }; + ctx.dirty = true; + slots[fp + Squeak.Frame_context] = ctx; + slots[fp + Squeak.Frame_flags] |= Squeak.Frame_hasContext; + return ctx; + }, + syncPage: function(page) { + // refresh the snapshots of all married frames in a page (top to base); + // marries callers shallowly to fill sender fields, so a full walk from + // Smalltalk deepens the married chain one syncPage at a time + this.saveActivePage(); + var slots = page.slots; + var fp = page.fp, pc = page.pc, sp = page.sp; + while (fp >= 0) { + var ctx = slots[fp + Squeak.Frame_context]; + var savedFp = slots[fp + Squeak.Frame_savedFp]; + if (ctx) { + var p = ctx.pointers; + p[Squeak.Context_sender] = savedFp >= 0 ? this.marryFrame(page, savedFp) + : (page.baseCallerCtx || this.nilObj); + p[Squeak.Context_instructionPointer] = + this.encodeSqueakPC(pc, slots[fp + Squeak.Frame_method]); + var stackp = sp - fp - Squeak.Frame_firstTemp + 1; + p[Squeak.Context_stackPointer] = stackp; + for (var i = 0; i < stackp; i++) + p[Squeak.Context_tempFrameStart + i] = slots[fp + Squeak.Frame_firstTemp + i]; + ctx.dirty = true; + } + // move down: caller's pc/sp derive from this frame + pc = slots[fp + Squeak.Frame_savedPc]; + sp = fp - 2 - (slots[fp + Squeak.Frame_flags] & 0xFFFF); + fp = savedFp; + } + }, + storeToMarriedContext: function(ctx, index, value) { + // Smalltalk escribe un campo de un context con frame vivo (terminate, + // unwind, debugger): serializar todo a contexts reales, seguir en un + // base frame fresco, y hacer la escritura sobre el context real + this.flushAllAndContinue(); + ctx.pointers[index] = value; + ctx.dirty = true; + if (ctx.frame != null) { + // la escritura cayó sobre el context re-casado del frame base activo: + // mantener la vista del frame consistente + var page = ctx.frame.page; + if (index === Squeak.Context_sender) page.baseCallerCtx = value; + else if (index === Squeak.Context_instructionPointer && typeof value === "number") + this.pc = this.decodeSqueakPC(value, page.slots[ctx.frame.fp + Squeak.Frame_method]); + else this.warnOnce("stack zone: unusual store to active context field " + index); + } + }, + widowFrameContext: function(ctx) { + ctx.pointers[Squeak.Context_sender] = this.nilObj; + ctx.pointers[Squeak.Context_instructionPointer] = this.nilObj; + ctx.frame = null; + ctx.dirty = true; + }, + flushAllAndContinue: function() { + // serialize every live frame to its (married) context, kill all pages, + // and continue execution on a fresh base frame inflated from the top + this.saveActivePage(); + var top = this.marryFrame(this.zonePage, this.fp); + for (var i = 0; i < this.zonePages.length; i++) { + var page = this.zonePages[i]; + if (!page.live) continue; + // marry every frame so syncPage serializes all of them + for (var fp = page.fp; fp >= 0; fp = page.slots[fp + Squeak.Frame_savedFp]) + this.marryFrame(page, fp); + this.syncPage(page); + for (var fp = page.fp; fp >= 0; fp = page.slots[fp + Squeak.Frame_savedFp]) { + var ctx = page.slots[fp + Squeak.Frame_context]; + if (ctx) ctx.frame = null; + } + page.live = false; + } + this.zonePage = null; + this.makeBaseFrameZ(top); + }, + makeBaseFrameZ: function(ctx) { + // inflate a dormant, resumable context into a fresh page's base frame + var methodField = ctx.pointers[Squeak.Context_method]; + if (this.isSmallInt(methodField)) + throw Error("stack zone: pre-closure BlockContexts not supported"); + var page = this.allocPage(); + var slots = page.slots; + var closure = ctx.pointers[Squeak.Context_closure]; + var stackp = ctx.pointers[Squeak.Context_stackPointer]; + var numArgs = closure && !closure.isNil + ? closure.pointers[Squeak.Closure_numArgs] + : methodField.methodNumArgs(); + slots[Squeak.Frame_savedFp] = -1; + slots[Squeak.Frame_savedPc] = 0; + slots[Squeak.Frame_method] = methodField; + slots[Squeak.Frame_flags] = numArgs | Squeak.Frame_hasContext; + slots[Squeak.Frame_context] = ctx; + slots[Squeak.Frame_closure] = closure; + slots[Squeak.Frame_receiver] = ctx.pointers[Squeak.Context_receiver]; + for (var i = 0; i < stackp; i++) + slots[Squeak.Frame_firstTemp + i] = ctx.pointers[Squeak.Context_tempFrameStart + i]; + page.baseCallerCtx = ctx.pointers[Squeak.Context_sender]; + page.fp = 0; + page.sp = Squeak.Frame_firstTemp + stackp - 1; + page.pc = this.decodeSqueakPC(ctx.pointers[Squeak.Context_instructionPointer], methodField); + ctx.frame = { page: page, fp: 0 }; + ctx.dirty = true; + this.activatePage(page); + }, +}, +'frames: execution', { + loadInitialContextZ: function() { + var schedAssn = this.specialObjects[Squeak.splOb_SchedulerAssociation]; + var sched = schedAssn.pointers[Squeak.Assn_value]; + var proc = sched.pointers[Squeak.ProcSched_activeProcess]; + this.makeBaseFrameZ(proc.pointers[Squeak.Proc_suspendedContext]); + this.reclaimableContextCount = 0; + }, + sendZ: function(selector, argCount, doSuper) { + var newRcvr = this.stack[this.sp - argCount]; + var lookupClass; + if (doSuper) { + lookupClass = this.method.methodClassForSuper(); + lookupClass = lookupClass.superclass(); + } else { + lookupClass = this.getClass(newRcvr); + } + // married-context receivers: refresh their snapshot before Smalltalk looks + if (typeof newRcvr === "object" && newRcvr !== null && newRcvr.frame != null) + this.syncPage(newRcvr.frame.page); + var entry = this.findSelectorInClass(selector, argCount, lookupClass); + if (entry.primIndex) { + this.verifyAtSelector = selector; + this.verifyAtClass = lookupClass; + } + this.executeNewMethod(newRcvr, entry.method, entry.argCount, entry.primIndex, entry.mClass, selector); + }, + sendSuperDirectedZ: function(selector, argCount) { + var lookupClass = this.pop().superclass(); + var newRcvr = this.stack[this.sp - argCount]; + if (typeof newRcvr === "object" && newRcvr !== null && newRcvr.frame != null) + this.syncPage(newRcvr.frame.page); + var entry = this.findSelectorInClass(selector, argCount, lookupClass); + if (entry.primIndex) { + this.verifyAtSelector = selector; + this.verifyAtClass = lookupClass; + } + this.executeNewMethod(newRcvr, entry.method, entry.argCount, entry.primIndex, entry.mClass, selector); + }, + executeNewMethodZ: function(newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel) { + this.sendCount++; + if (newMethod === this.breakOnMethod) + this.breakNow("executing method " + this.printMethod(newMethod, optClass, optSel)); + if (this.logSends) { + var args = this.stack.slice(this.sp + 1 - argumentCount, this.sp + 1); + console.log(this.sendCount + ' ' + this.printMethod(newMethod, optClass, optSel, args)); + } + if (this.breakOnContextChanged) { + this.breakOnContextChanged = false; + this.breakNow(); + } + if (primitiveIndex > 0) + if (this.tryPrimitive(primitiveIndex, argumentCount, newMethod)) + return; //Primitive succeeded -- end of story + var slots = this.zonePage.slots; + var nfp = this.sp + 1; + slots[nfp + Squeak.Frame_savedFp] = this.fp; + slots[nfp + Squeak.Frame_savedPc] = this.pc; + slots[nfp + Squeak.Frame_method] = newMethod; + slots[nfp + Squeak.Frame_flags] = argumentCount; + slots[nfp + Squeak.Frame_context] = null; + slots[nfp + Squeak.Frame_closure] = this.nilObj; + slots[nfp + Squeak.Frame_receiver] = newRcvr; + var tempCount = newMethod.methodTempCount(); + var base = nfp + Squeak.Frame_firstTemp; + for (var i = 0; i < argumentCount; i++) + slots[base + i] = slots[nfp - argumentCount + i]; + for (var i = argumentCount; i < tempCount; i++) + slots[base + i] = this.nilObj; + this.fp = nfp; + this.sp = base + tempCount - 1; + this.pc = 0; + this.method = newMethod; + this.receiver = newRcvr; + this.tempOffset = base; + // check for process switch on full method activation + if (this.interruptCheckCounter-- <= 0) this.checkForInterrupts(); + }, + doBlockReturnZ: function(returnValue) { + // return from a block activation to its caller (never non-local) + var slots = this.zonePage.slots; + var fp = this.fp; + var ctx = slots[fp + Squeak.Frame_context]; + if (ctx) this.widowFrameContext(ctx); + var savedFp = slots[fp + Squeak.Frame_savedFp]; + var numArgs = slots[fp + Squeak.Frame_flags] & 0xFFFF; + if (savedFp >= 0) { + var retSlot = fp - 1 - numArgs; + slots[retSlot] = returnValue; + this.sp = retSlot; + this.pc = slots[fp + Squeak.Frame_savedPc]; + this.fp = savedFp; + this.method = slots[savedFp + Squeak.Frame_method]; + this.receiver = slots[savedFp + Squeak.Frame_receiver]; + this.tempOffset = savedFp + Squeak.Frame_firstTemp; + } else { + this.returnFromBaseFrame(returnValue); + } + if (this.breakOnContextChanged) { + this.breakOnContextChanged = false; + this.breakNow(); + } + }, + doReturnZ: function(returnValue) { + var page = this.zonePage; + var slots = page.slots; + // 1. find home of the active frame (walk closure chain) + var homePage = page, homeFp = this.fp, homeCtx = null; + if (this.hasClosures) { + while (true) { + var closure = homeCtx ? homeCtx.pointers[Squeak.Context_closure] + : homePage.slots[homeFp + Squeak.Frame_closure]; + if (closure.isNil) break; + var outer = closure.pointers[Squeak.Closure_outerContext]; + if (outer.frame != null) { + homePage = outer.frame.page; homeFp = outer.frame.fp; homeCtx = null; + } else { + homeCtx = outer; + } + } + } + // 2. target = sender of home + var targetPage = null, targetFp = -1, targetCtx = null; + if (homeCtx) { + targetCtx = homeCtx.pointers[Squeak.Context_sender]; + } else { + var sfp = homePage.slots[homeFp + Squeak.Frame_savedFp]; + if (sfp >= 0) { targetPage = homePage; targetFp = sfp; } + else targetCtx = homePage.baseCallerCtx || this.nilObj; + } + if (targetCtx) { + if (targetCtx.frame != null) { + targetPage = targetCtx.frame.page; targetFp = targetCtx.frame.fp; targetCtx = null; + } else if (targetCtx.isNil || targetCtx.pointers[Squeak.Context_instructionPointer].isNil) { + return this.cannotReturn(returnValue); + } + } + // 3. unwind scan from caller-of-active down to target + var sPage = page, sFp = slots[this.fp + Squeak.Frame_savedFp], sCtx = null; + if (sFp < 0) { sCtx = page.baseCallerCtx || this.nilObj; sPage = null; } + while (true) { + if (sCtx === null) { + if (sPage === targetPage && sFp === targetFp) break; + var m = sPage.slots[sFp + Squeak.Frame_method]; + if (m.methodPrimitiveIndex() == 198) + return this.aboutToReturnThrough(returnValue, this.marryFrame(sPage, sFp)); + var nextFp = sPage.slots[sFp + Squeak.Frame_savedFp]; + if (nextFp >= 0) { sFp = nextFp; } + else { sCtx = sPage.baseCallerCtx || this.nilObj; sPage = null; } + } else { + if (targetCtx !== null && sCtx === targetCtx) break; + if (sCtx.isNil) return this.cannotReturn(returnValue); + if (sCtx.frame != null) { + sPage = sCtx.frame.page; sFp = sCtx.frame.fp; sCtx = null; + if (sPage === targetPage && sFp === targetFp) break; + continue; + } + if (this.isUnwindMarked(sCtx)) + return this.aboutToReturnThrough(returnValue, sCtx); + sCtx = sCtx.pointers[Squeak.Context_sender]; + } + } + // 4. pop frames from active down to target, widowing married ones + var pPage = page, pFp = this.fp; + while (true) { + var fslots = pPage.slots; + var mCtx = fslots[pFp + Squeak.Frame_context]; + if (mCtx) this.widowFrameContext(mCtx); + var lastPc = fslots[pFp + Squeak.Frame_savedPc]; + var lastNumArgs = fslots[pFp + Squeak.Frame_flags] & 0xFFFF; + var downFp = fslots[pFp + Squeak.Frame_savedFp]; + if (downFp >= 0) { + var retSlot = pFp - 1 - lastNumArgs; + pFp = downFp; + if (pPage === targetPage && pFp === targetFp) { + if (pPage !== this.zonePage) { + this.zonePage = pPage; + this.stack = pPage.slots; + this.temps = pPage.slots; + } + this.fp = pFp; + this.sp = retSlot; + pPage.slots[retSlot] = returnValue; + this.pc = lastPc; + this.method = pPage.slots[pFp + Squeak.Frame_method]; + this.receiver = pPage.slots[pFp + Squeak.Frame_receiver]; + this.tempOffset = pFp + Squeak.Frame_firstTemp; + break; + } + } else { + // popped through the page's base frame: page dies + pPage.live = false; + var cctx = pPage.baseCallerCtx || this.nilObj; + if (cctx.frame != null) { + pPage = cctx.frame.page; + pFp = cctx.frame.fp; + if (pPage === targetPage && pFp === targetFp) { + // landing on the (suspended) top frame of another page + this.activatePage(pPage); + this.push(returnValue); + break; + } + } else { + // dormant context: scan guaranteed it's the target + this.zonePage = null; + this.makeBaseFrameZ(cctx); + this.push(returnValue); + break; + } + } + } + if (this.breakOnContextChanged) { + this.breakOnContextChanged = false; + this.breakNow(); + } + }, + returnFromBaseFrame: function(returnValue) { + // active frame is a base frame; return to its caller context + var page = this.zonePage; + page.live = false; + var cctx = page.baseCallerCtx || this.nilObj; + var ctx = page.slots[this.fp + Squeak.Frame_context]; + if (ctx) this.widowFrameContext(ctx); + if (cctx.frame != null) { + this.activatePage(cctx.frame.page); + this.push(returnValue); + } else { + if (cctx.isNil || cctx.pointers[Squeak.Context_instructionPointer].isNil) + return this.cannotReturn(returnValue); + this.zonePage = null; + this.makeBaseFrameZ(cctx); + this.push(returnValue); + } + }, + exportThisContextZ: function() { + var ctx = this.marryFrame(this.zonePage, this.fp); + this.syncPage(this.zonePage); + this.reclaimableContextCount = 0; + return ctx; + }, + newActiveContextZ: function(newContext) { + // process switch (transferTo) or explicit context activation + this.saveActivePage(); + if (newContext.frame != null) { + this.activatePage(newContext.frame.page); + } else { + this.makeBaseFrameZ(newContext); + } + if (this.breakOnContextChanged) { + this.breakOnContextChanged = false; + this.breakNow(); + } + }, + storeContextRegistersZ: function() { + throw Error("stack zone: storeContextRegisters should not be called"); + }, + fetchContextRegistersZ: function(ctxt) { + throw Error("stack zone: fetchContextRegisters should not be called"); + }, + allocateOrRecycleContextZ: function(needsLarge) { + throw Error("stack zone: allocateOrRecycleContext should not be called"); + }, + activeContextObjZ: function() { + // for the few legacy readers that need the active context as an object + return this.marryFrame(this.zonePage, this.fp); + }, + tryPrimitiveZ: function(primIndex, argCount, newMethod) { + if ((primIndex > 255) && (primIndex < 520)) { + if (primIndex >= 264) {//return instvars + this.popNandPush(1, this.top().pointers[primIndex - 264]); + return true; + } + switch (primIndex) { + case 256: //return self + return true; + case 257: this.popNandPush(1, this.trueObj); //return true + return true; + case 258: this.popNandPush(1, this.falseObj); //return false + return true; + case 259: this.popNandPush(1, this.nilObj); //return nil + return true; + } + this.popNandPush(1, primIndex - 261); //return -1...2 + return true; + } + var sp = this.sp; + var page = this.zonePage, fp = this.fp; // activation identity without touching contexts + var success = this.primHandler.doPrimitive(primIndex, argCount, newMethod); + if (success + && this.sp !== sp - argCount + && page === this.zonePage && fp === this.fp + && primIndex !== 117 && primIndex !== 118 && primIndex !== 218 + && !this.frozen) { + this.warnOnce("stack unbalanced after primitive " + primIndex, "error"); + } + return success; + }, +}); + +Object.extend(Squeak.Primitives.prototype, +'stack zone', { + activateNewClosureMethodZ: function(blockClosure, argCount) { + // old-style (V3) closures: startpc into the home method + var vm = this.vm; + var outer = blockClosure.pointers[Squeak.Closure_outerContext]; + var of = outer.frame; + var method = of != null ? of.page.slots[of.fp + Squeak.Frame_method] + : outer.pointers[Squeak.Context_method]; + var receiver = of != null ? of.page.slots[of.fp + Squeak.Frame_receiver] + : outer.pointers[Squeak.Context_receiver]; + var numCopied = blockClosure.pointers.length - Squeak.Closure_firstCopiedValue; + var slots = vm.zonePage.slots; + var nfp = vm.sp + 1; + slots[nfp + Squeak.Frame_savedFp] = vm.fp; + slots[nfp + Squeak.Frame_savedPc] = vm.pc; + slots[nfp + Squeak.Frame_method] = method; + slots[nfp + Squeak.Frame_flags] = argCount; + slots[nfp + Squeak.Frame_context] = null; + slots[nfp + Squeak.Frame_closure] = blockClosure; + slots[nfp + Squeak.Frame_receiver] = receiver; + var base = nfp + Squeak.Frame_firstTemp; + for (var i = 0; i < argCount; i++) + slots[base + i] = slots[nfp - argCount + i]; + for (var i = 0; i < numCopied; i++) + slots[base + argCount + i] = blockClosure.pointers[Squeak.Closure_firstCopiedValue + i]; + // the initial block instructions nil-out remaining temps (stackp = args+copied) + vm.fp = nfp; + vm.sp = base + argCount + numCopied - 1; + vm.pc = vm.decodeSqueakPC(blockClosure.pointers[Squeak.Closure_startpc], method); + vm.method = method; + vm.receiver = receiver; + vm.tempOffset = base; + }, + activateNewFullClosureZ: function(blockClosure, argCount) { + var vm = this.vm; + var closureMethod = blockClosure.pointers[Squeak.ClosureFull_method]; + var numCopied = blockClosure.pointers.length - Squeak.ClosureFull_firstCopiedValue; + var receiver = blockClosure.pointers[Squeak.ClosureFull_receiver]; + var slots = vm.zonePage.slots; + var nfp = vm.sp + 1; + slots[nfp + Squeak.Frame_savedFp] = vm.fp; + slots[nfp + Squeak.Frame_savedPc] = vm.pc; + slots[nfp + Squeak.Frame_method] = closureMethod; + slots[nfp + Squeak.Frame_flags] = argCount; + slots[nfp + Squeak.Frame_context] = null; + slots[nfp + Squeak.Frame_closure] = blockClosure; + slots[nfp + Squeak.Frame_receiver] = receiver; + var tempCount = closureMethod.methodTempCount(); // args + copied + locals + var base = nfp + Squeak.Frame_firstTemp; + for (var i = 0; i < argCount; i++) + slots[base + i] = slots[nfp - argCount + i]; + for (var i = 0; i < numCopied; i++) + slots[base + argCount + i] = blockClosure.pointers[Squeak.ClosureFull_firstCopiedValue + i]; + for (var i = argCount + numCopied; i < tempCount; i++) + slots[base + i] = vm.nilObj; + vm.fp = nfp; + vm.sp = base + tempCount - 1; + vm.pc = 0; + vm.method = closureMethod; + vm.receiver = receiver; + vm.tempOffset = base; + }, +}); From 912c283b982c30ca5c555e15cf9d9bababe44397 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:53:17 -0300 Subject: [PATCH 09/22] =?UTF-8?q?[perf]=20jit:=20frames-mode=20templates?= =?UTF-8?q?=20=E2=80=94=20stack=20zone=20now=20runs=20jitted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated code in stack-zone mode detects activation changes by (fp, zonePage) instead of context identity, routes receiver inst-var stores through the married-context write-through (with a fresh vm.pc and a resume label, since a flush rebuilds the frame), and handles the at:put: quick prim's flush hazard by re-executing the bytecode idempotently. blockReturn and closure creation now use the mode-neutral doBlockReturn/activeContextObj in both modes (identical semantics in context mode). Frames mode compiles methods again (executeNewMethodZ and full-closure activation call compileIfPossible). Validation: frames+jit produces the IDENTICAL trace to the context-mode jit golden over the full Cuis startup (3.6M sends to quit) — the jit's trace-visible at:-cache fast path behaves the same in both modes. Context mode codegen is byte-identical to before (golden still passes). Bench (20M sends): frames+jit 2.99-3.06M sends/s vs contexts+jit 2.74-2.81M — ~9% faster on a primitive-heavy workload; send-heavy code should gain more (spike: 1.38x on pure send/return). Co-Authored-By: Claude Fable 5 --- jit.js | 73 ++++++++++++++++++++++++++++++++++++----------- vm.interpreter.js | 1 - vm.stackzone.js | 2 ++ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/jit.js b/jit.js index 1ed18b42..a8405648 100644 --- a/jit.js +++ b/jit.js @@ -200,6 +200,12 @@ to single-step. // if a compiler does not support single-stepping, return false return true; }, + activationCheck: function() { + // expression that is true when the send/jump above switched activations + return this.vm.useStackZone + ? "vm.fp !== fp || vm.zonePage !== page" + : "context !== vm.activeContext"; + }, tempIndex: function(n) { // index expression for temp n inside this.source: // context mode: constant (temps at homeContext.pointers[6+n]); @@ -240,7 +246,9 @@ to single-step. this.source.push("// ", optClass, ">>", optSel, "\n"); this.instVarNames = optInstVarNames; this.allVars = ['context', 'stack', 'rcvr', 'inst[', 'temp[', 'lit[']; - this.sourcePos['context'] = this.source.length; this.source.push("var context = vm.activeContext;\n"); + this.sourcePos['context'] = this.source.length; this.source.push(this.vm.useStackZone + ? "var fp = vm.fp, page = vm.zonePage;\n" + : "var context = vm.activeContext;\n"); this.sourcePos['stack'] = this.source.length; this.source.push("var stack = vm.stack;\n"); this.sourcePos['rcvr'] = this.source.length; this.source.push("var rcvr = vm.receiver;\n"); this.sourcePos['inst['] = this.source.length; this.source.push("var inst = rcvr.pointers;\n"); @@ -750,6 +758,16 @@ to single-step. this.generateLabel(); this.needsVar[target] = true; this.needsVar['stack'] = true; + if (this.vm.useStackZone && target === "inst[") { + // un store a un context casado-vivo debe pasar por write-through + // (flush + escritura sobre el context real); pc fresco para el resume + this.needsVar['context'] = true; // fp/page + this.source.push( + "if (rcvr.frame == null) { inst[", arg1, "] = stack[vm.sp]; rcvr.dirty = true; }\n", + "else { vm.pc = ", this.pc, "; vm.storeToMarriedContext(rcvr, ", arg1, ", stack[vm.sp]); if (vm.fp !== fp || vm.zonePage !== page) return; }\n"); + this.needsLabel[this.pc] = true; + return; + } this.source.push(target); if (arg1 !== undefined) { this.source.push(arg1, suffix1); @@ -765,6 +783,15 @@ to single-step. this.generateLabel(); this.needsVar[target] = true; this.needsVar['stack'] = true; + if (this.vm.useStackZone && target === "inst[") { + this.needsVar['context'] = true; // fp/page + this.source.push( + "var iv = stack[vm.sp--];\n", + "if (rcvr.frame == null) { inst[", arg1, "] = iv; rcvr.dirty = true; }\n", + "else { vm.pc = ", this.pc, "; vm.storeToMarriedContext(rcvr, ", arg1, ", iv); if (vm.fp !== fp || vm.zonePage !== page) return; }\n"); + this.needsLabel[this.pc] = true; + return; + } this.source.push(target); if (arg1 !== undefined) { this.source.push(arg1, suffix1); @@ -794,7 +821,7 @@ to single-step. // actually stack === context.pointers but that would look weird this.needsVar['context'] = true; this.source.push( - "vm.pc = ", this.pc, "; vm.doReturn(", retVal, ", context.pointers[0]); return;\n"); + "vm.pc = ", this.pc, "; vm.doBlockReturn(", retVal, "); return;\n"); this.needsBreak = false; // returning anyway this.done = this.pc > this.endPC; }, @@ -807,7 +834,7 @@ to single-step. if (distance < 0) this.source.push( "\nif (vm.interruptCheckCounter-- <= 0) {\n", " vm.checkForInterrupts();\n", - " if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return;\n", + " if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return;\n", "}\n"); if (this.singleStep) this.source.push("\nif (vm.breakOutOfInterpreter) return;\n"); this.source.push("continue;\n"); @@ -839,16 +866,28 @@ to single-step. "var a, b; if ((a=stack[vm.sp-1]).sqClass === vm.specialObjects[7] && a.pointers && typeof (b=stack[vm.sp]) === 'number' && b>0 && b<=a.pointers.length) {\n", " stack[--vm.sp] = a.pointers[b-1];", "} else { var c = vm.primHandler.objectAt(true,true,false); if (vm.primHandler.success) stack[--vm.sp] = c; else {\n", - " vm.pc = ", this.pc, "; vm.sendSpecial(16); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return; }}\n"); + " vm.pc = ", this.pc, "; vm.sendSpecial(16); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return; }}\n"); this.needsLabel[this.pc] = true; return; case 0x1: // at:put: this.needsVar['stack'] = true; + if (this.vm.useStackZone) { + this.needsVar['context'] = true; // fp/page + this.source.push( + "var a, b; if ((a=stack[vm.sp-2]).sqClass === vm.specialObjects[7] && a.pointers && typeof (b=stack[vm.sp-1]) === 'number' && b>0 && b<=a.pointers.length) {\n", + " var c = stack[vm.sp]; stack[vm.sp-=2] = a.pointers[b-1] = c; a.dirty = true;", + "} else { var c = stack[vm.sp]; vm.pc = ", this.prevPC, "; vm.primHandler.objectAtPut(true,true,false); if (vm.fp !== fp || vm.zonePage !== page) return;\n", + " if (vm.primHandler.success) stack[vm.sp-=2] = c; else {\n", + " vm.pc = ", this.pc, "; vm.sendSpecial(17); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return; }}\n"); + this.needsLabel[this.prevPC] = true; + this.needsLabel[this.pc] = true; + return; + } this.source.push( "var a, b; if ((a=stack[vm.sp-2]).sqClass === vm.specialObjects[7] && a.pointers && typeof (b=stack[vm.sp-1]) === 'number' && b>0 && b<=a.pointers.length) {\n", " var c = stack[vm.sp]; stack[vm.sp-=2] = a.pointers[b-1] = c; a.dirty = true;", "} else { vm.primHandler.objectAtPut(true,true,false); if (vm.primHandler.success) stack[vm.sp-=2] = c; else {\n", - " vm.pc = ", this.pc, "; vm.sendSpecial(17); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return; }}\n"); + " vm.pc = ", this.pc, "; vm.sendSpecial(17); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return; }}\n"); this.needsLabel[this.pc] = true; return; case 0x2: // size @@ -856,7 +895,7 @@ to single-step. this.source.push( "if (stack[vm.sp].sqClass === vm.specialObjects[7]) stack[vm.sp] = stack[vm.sp].pointersSize();\n", // Array "else if (stack[vm.sp].sqClass === vm.specialObjects[6]) stack[vm.sp] = stack[vm.sp].bytesSize();\n", // ByteString - "else { vm.pc = ", this.pc, "; vm.sendSpecial(18); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return; }\n"); + "else { vm.pc = ", this.pc, "; vm.sendSpecial(18); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return; }\n"); this.needsLabel[this.pc] = true; return; //case 0x3: return false; // next @@ -897,7 +936,7 @@ to single-step. this.source.push( "vm.pc = ", this.pc, "; if (!vm.primHandler.quickSendOther(rcvr, ", (byte & 0x0F), "))", " vm.sendSpecial(", ((byte & 0x0F) + 16), ");\n", - "if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return;\n"); + "if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return;\n"); this.needsBreak = false; // already checked // if falling back to a full send we need a label for coming back this.needsLabel[this.pc] = true; @@ -914,42 +953,42 @@ to single-step. this.source.push("var a = stack[vm.sp - 1], b = stack[vm.sp];\n", "if (typeof a === 'number' && typeof b === 'number') {\n", " stack[--vm.sp] = vm.primHandler.signed32BitIntegerFor(a + b);\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(0); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(0); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x1: // MINUS - this.needsVar['stack'] = true; this.source.push("var a = stack[vm.sp - 1], b = stack[vm.sp];\n", "if (typeof a === 'number' && typeof b === 'number') {\n", " stack[--vm.sp] = vm.primHandler.signed32BitIntegerFor(a - b);\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(1); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(1); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x2: // LESS < this.needsVar['stack'] = true; this.source.push("var a = stack[vm.sp - 1], b = stack[vm.sp];\n", "if (typeof a === 'number' && typeof b === 'number') {\n", " stack[--vm.sp] = a < b ? vm.trueObj : vm.falseObj;\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(2); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(2); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x3: // GRTR > this.needsVar['stack'] = true; this.source.push("var a = stack[vm.sp - 1], b = stack[vm.sp];\n", "if (typeof a === 'number' && typeof b === 'number') {\n", " stack[--vm.sp] = a > b ? vm.trueObj : vm.falseObj;\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(3); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(3); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x4: // LEQ <= this.needsVar['stack'] = true; this.source.push("var a = stack[vm.sp - 1], b = stack[vm.sp];\n", "if (typeof a === 'number' && typeof b === 'number') {\n", " stack[--vm.sp] = a <= b ? vm.trueObj : vm.falseObj;\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(4); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(4); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x5: // GEQ >= this.needsVar['stack'] = true; this.source.push("var a = stack[vm.sp - 1], b = stack[vm.sp];\n", "if (typeof a === 'number' && typeof b === 'number') {\n", " stack[--vm.sp] = a >= b ? vm.trueObj : vm.falseObj;\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(5); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(5); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x6: // EQU = this.needsVar['stack'] = true; @@ -958,7 +997,7 @@ to single-step. " stack[--vm.sp] = a === b ? vm.trueObj : vm.falseObj;\n", "} else if (a === b && a.float === a.float) {\n", // NaN check " stack[--vm.sp] = vm.trueObj;\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(6); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(6); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x7: // NEQ ~= this.needsVar['stack'] = true; @@ -967,7 +1006,7 @@ to single-step. " stack[--vm.sp] = a !== b ? vm.trueObj : vm.falseObj;\n", "} else if (a === b && a.float === a.float) {\n", // NaN check " stack[--vm.sp] = vm.falseObj;\n", - "} else { vm.pc = ", this.pc, "; vm.sendSpecial(7); if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return}\n"); + "} else { vm.pc = ", this.pc, "; vm.sendSpecial(7); if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return}\n"); return; case 0x8: // TIMES * this.source.push("vm.success = true; vm.resultIsFloat = false; if(!vm.pop2AndPushNumResult(vm.stackIntOrFloat(1) * vm.stackIntOrFloat(0))) { vm.pc = ", this.pc, "; vm.sendSpecial(8); return}\n"); @@ -1010,7 +1049,7 @@ to single-step. } else { this.source.push("; vm.send(", prefix, num, suffix, ", ", numArgs, ", ", superSend, "); "); } - this.source.push("if (context !== vm.activeContext || vm.breakOutOfInterpreter !== false) return;\n"); + this.source.push("if (", this.activationCheck(), " || vm.breakOutOfInterpreter !== false) return;\n"); this.needsBreak = false; // already checked // need a label for coming back after send this.needsLabel[this.pc] = true; @@ -1036,7 +1075,7 @@ to single-step. this.needsVar['stack'] = true; this.source.push( "var closure = vm.instantiateClass(vm.specialObjects[36], ", numCopied, ");\n", - "closure.pointers[0] = context; vm.reclaimableContextCount = 0;\n", + "closure.pointers[0] = vm.activeContextObj(); vm.reclaimableContextCount = 0;\n", "closure.pointers[1] = ", from + this.method.pointers.length * 4 + 1, ";\n", // encodeSqueakPC "closure.pointers[2] = ", numArgs, ";\n"); if (numCopied > 0) { diff --git a/vm.interpreter.js b/vm.interpreter.js index f55caa0d..a534694c 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -36,7 +36,6 @@ Object.subclass('Squeak.Interpreter', this.loadInitialContext(); this.hackImage(); this.initCompiler(); - if (this.useStackZone) this.compiler = null; // jit templates for frames mode not implemented yet console.log('squeak: ready'); }, loadImageState: function() { diff --git a/vm.stackzone.js b/vm.stackzone.js index 12799aac..35ec90d9 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -346,6 +346,7 @@ Object.extend(Squeak.Interpreter.prototype, this.method = newMethod; this.receiver = newRcvr; this.tempOffset = base; + if (!newMethod.compiled) this.compileIfPossible(newMethod, optClass, optSel); // check for process switch on full method activation if (this.interruptCheckCounter-- <= 0) this.checkForInterrupts(); }, @@ -632,5 +633,6 @@ Object.extend(Squeak.Primitives.prototype, vm.method = closureMethod; vm.receiver = receiver; vm.tempOffset = base; + if (!closureMethod.compiled) vm.compileIfPossible(closureMethod); }, }); From f1fda6943deee33eb42bcf8b7b49be4820748430 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:36:04 -0300 Subject: [PATCH 10/22] [perf] stack zone: browser/node entry points, per-frame sync, class gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - squeak.js / squeak_headless.js import vm.stackzone.js; the run/ launcher's generic option parsing already forwards #stackZone from the URL fragment to the interpreter. squeak_node.js gets -stackZone. - syncMarriedContext replaces whole-page sync at the hot call sites (thisContext, married send receivers, reflective reads): syncing one frame is O(frame) for the page top and marries the caller shallowly instead of cascading down the chain. Cuis 6's ensure:-heavy code made the cascade pathological: 68% of JS time in syncPage, 5x slowdown. - married-context probes are gated by a class-identity compare before touching .frame, keeping megamorphic receivers off that load. - makeBaseFrame refuses Context subclasses (the gates compare against MethodContext identity; a married subclass would evade them). - zone stats + marriage-source counters reported by the harness bench. Validated on both image formats: V3 (cuis.image) golden identical in both modes; Spur 32 (Cuis6.2-32) context and frames traces identical. Bench: V3 frames+jit still +9%; Spur frames+jit 1.68M vs 2.05M sends/s (-18%, down from -83% before the sync fix) — remaining gap tracks the 154k closure-creation marriages per 10M sends and their GC pressure; known issue, next optimization target. Co-Authored-By: Claude Fable 5 --- jit.js | 4 +-- perf/harness/difftrace.js | 11 +++++++ squeak.js | 1 + squeak_headless.js | 1 + squeak_node.js | 7 ++++- vm.interpreter.js | 4 ++- vm.stackzone.js | 66 ++++++++++++++++++++++++++++++++++----- 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/jit.js b/jit.js index a8405648..47cd9f3f 100644 --- a/jit.js +++ b/jit.js @@ -763,7 +763,7 @@ to single-step. // (flush + escritura sobre el context real); pc fresco para el resume this.needsVar['context'] = true; // fp/page this.source.push( - "if (rcvr.frame == null) { inst[", arg1, "] = stack[vm.sp]; rcvr.dirty = true; }\n", + "if (rcvr.sqClass !== vm.contextClass_ || rcvr.frame == null) { inst[", arg1, "] = stack[vm.sp]; rcvr.dirty = true; }\n", "else { vm.pc = ", this.pc, "; vm.storeToMarriedContext(rcvr, ", arg1, ", stack[vm.sp]); if (vm.fp !== fp || vm.zonePage !== page) return; }\n"); this.needsLabel[this.pc] = true; return; @@ -787,7 +787,7 @@ to single-step. this.needsVar['context'] = true; // fp/page this.source.push( "var iv = stack[vm.sp--];\n", - "if (rcvr.frame == null) { inst[", arg1, "] = iv; rcvr.dirty = true; }\n", + "if (rcvr.sqClass !== vm.contextClass_ || rcvr.frame == null) { inst[", arg1, "] = iv; rcvr.dirty = true; }\n", "else { vm.pc = ", this.pc, "; vm.storeToMarriedContext(rcvr, ", arg1, ", iv); if (vm.fp !== fp || vm.zonePage !== page) return; }\n"); this.needsLabel[this.pc] = true; return; diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index cd6b03c9..6d169b85 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -394,6 +394,17 @@ image.readFromBuffer(data.buffer, function startRunning() { if (mode === "bench") { console.log("bench: " + vm.sendCount + " sends en " + wallMs.toFixed(0) + " ms (" + (vm.sendCount / (wallMs / 1000) / 1e6).toFixed(2) + "M sends/s), stop: " + stopReason); + if (vm.useStackZone) { + var live = 0, maxSlots = 0; + for (var i = 0; i < vm.zonePages.length; i++) { + if (vm.zonePages[i].live) live++; + if (vm.zonePages[i].slots.length > maxSlots) maxSlots = vm.zonePages[i].slots.length; + } + console.log("zona: pages=" + vm.zonePages.length + " live=" + live + " maxSlots=" + maxSlots + + " married=" + (vm.nMarriedContexts || 0) + " flushAll=" + (vm.nFlushAll || 0) + + " byClosure=" + (vm.nMarryClosure || 0) + " byThisCtx=" + (vm.nMarryThisCtx || 0) + + " bySenderFill=" + (vm.nMarrySenderFill || 0)); + } return; } diff --git a/squeak.js b/squeak.js index fa586fc6..7185bd9b 100644 --- a/squeak.js +++ b/squeak.js @@ -38,6 +38,7 @@ import "./vm.instruction.stream.sista.js"; import "./vm.instruction.printer.js"; import "./vm.primitives.js"; import "./jit.js"; +import "./vm.stackzone.js"; import "./vm.audio.browser.js"; import "./vm.display.js"; import "./vm.display.browser.js"; diff --git a/squeak_headless.js b/squeak_headless.js index b7945c8e..125abe48 100644 --- a/squeak_headless.js +++ b/squeak_headless.js @@ -38,6 +38,7 @@ import "./vm.instruction.stream.sista.js"; import "./vm.instruction.printer.js"; import "./vm.primitives.js"; import "./jit.js"; +import "./vm.stackzone.js"; import "./vm.display.js"; import "./vm.display.headless.js"; // use headless display to prevent image crashing/becoming unresponsive import "./vm.input.js"; diff --git a/squeak_node.js b/squeak_node.js index 505373c2..8811990c 100644 --- a/squeak_node.js +++ b/squeak_node.js @@ -40,6 +40,10 @@ var ignoreQuit = processArgs[0] === "-ignoreQuit"; if (ignoreQuit) { processArgs = processArgs.slice(1); } +var stackZone = processArgs[0] === "-stackZone"; +if (stackZone) { + processArgs = processArgs.slice(1); +} var fullName = processArgs[0]; if (!fullName) { console.error("No image name specified."); @@ -90,6 +94,7 @@ require("./vm.instruction.stream.sista.js"); require("./vm.instruction.printer.js"); require("./vm.primitives.js"); require("./jit.js"); +require("./vm.stackzone.js"); require("./vm.display.js"); require("./vm.display.headless.js"); // use headless display to prevent image crashing/becoming unresponsive require("./vm.input.js"); @@ -133,7 +138,7 @@ fs.readFile(root + imageName + ".image", function(error, data) { // Create fake display and create interpreter var display = { vmOptions: [ "-vm-display-null", "-nodisplay" ] }; - var vm = new Squeak.Interpreter(image, display); + var vm = new Squeak.Interpreter(image, display, stackZone ? { stackZone: true } : {}); function run() { try { vm.interpret(200, function runAgain(ms) { diff --git a/vm.interpreter.js b/vm.interpreter.js index a534694c..c6eb3e8f 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -64,6 +64,7 @@ Object.subclass('Squeak.Interpreter', this.zonePages = null; this.zonePage = null; this.fp = -1; + this.contextClass_ = null; // set by enableStackZone; gates married-context probes this.interruptCheckCounter = 0; this.interruptCheckCounterFeedBackReset = 1000; this.interruptChecksEveryNms = 3; @@ -1141,7 +1142,8 @@ Object.subclass('Squeak.Interpreter', // receiver inst-var store from a bytecode; in stack-zone mode a store // into a married-live context must go through the write-through path var rcvr = this.receiver; - if (rcvr.frame != null) return this.storeToMarriedContext(rcvr, index, value); + if (rcvr.sqClass === this.contextClass_ && rcvr.frame != null) + return this.storeToMarriedContext(rcvr, index, value); rcvr.pointers[index] = value; }, aboutToReturnThrough: function(resultObj, aContext) { diff --git a/vm.stackzone.js b/vm.stackzone.js index 35ec90d9..63333db2 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -45,6 +45,10 @@ Object.extend(Squeak.Interpreter.prototype, // usan activeContextObj() (un defineProperty con getter sobre la instancia // degrada TODOS los accesos a propiedades del vm — 10x+ en el camino caliente) this.activeContext = null; + // cache para gatear los probes .frame: solo instancias de MethodContext + // pueden estar casadas; comparar la clase primero evita LoadICs + // megamórficos sobre receivers arbitrarios (medido: 24% del tiempo) + this.contextClass_ = this.specialObjects[Squeak.splOb_ClassMethodContext]; var vm = this; // rebind execution methods to the frames variants this.send = this.sendZ; @@ -67,19 +71,19 @@ Object.extend(Squeak.Interpreter.prototype, var origObjectAt = ph.objectAt; ph.objectAt = function(cameFromBytecode, convertChars, includeInstVars) { var rcvr = this.stackNonInteger(1); - if (rcvr.frame != null) vm.syncPage(rcvr.frame.page); + if (rcvr.sqClass === vm.contextClass_ && rcvr.frame != null) vm.syncMarriedContext(rcvr); return origObjectAt.call(this, cameFromBytecode, convertChars, includeInstVars); }; var origObjectAtPut = ph.objectAtPut; ph.objectAtPut = function(cameFromBytecode, convertChars, includeInstVars) { var rcvr = this.stackNonInteger(2); - if (rcvr.frame != null) vm.flushAllAndContinue(); + if (rcvr.sqClass === vm.contextClass_ && rcvr.frame != null) vm.flushAllAndContinue(); return origObjectAtPut.call(this, cameFromBytecode, convertChars, includeInstVars); }; var origStoreStackp = ph.primitiveStoreStackp; ph.primitiveStoreStackp = function(argCount) { var ctxt = this.stackNonInteger(1); - if (ctxt.frame != null) vm.flushAllAndContinue(); + if (ctxt.sqClass === vm.contextClass_ && ctxt.frame != null) vm.flushAllAndContinue(); return origStoreStackp.call(this, argCount); }; var origArrayBecome = ph.primitiveArrayBecome; @@ -167,8 +171,45 @@ Object.extend(Squeak.Interpreter.prototype, ctx.dirty = true; slots[fp + Squeak.Frame_context] = ctx; slots[fp + Squeak.Frame_flags] |= Squeak.Frame_hasContext; + this.nMarriedContexts = (this.nMarriedContexts || 0) + 1; return ctx; }, + syncMarriedContext: function(ctx) { + // refresca el snapshot de UN context casado. El tope de página es O(1) + // de ubicar; para frames profundos se busca el callee desde el tope. + // El caller se casa shallow (sin fill): se sincroniza cuando se observe. + var f = ctx.frame; + if (f == null) return; + var page = f.page, fp = f.fp; + this.saveActivePage(); + var slots = page.slots; + var pc, sp; + if (fp === page.fp) { + pc = page.pc; + sp = page.sp; + } else { + // buscar el callee: el frame cuyo savedFp === fp + var t = page.fp; + while (t >= 0 && slots[t + Squeak.Frame_savedFp] !== fp) + t = slots[t + Squeak.Frame_savedFp]; + if (t < 0) throw Error("stack zone: married frame not on its page chain"); + pc = slots[t + Squeak.Frame_savedPc]; + sp = t - 2 - (slots[t + Squeak.Frame_flags] & 0xFFFF); + } + var p = ctx.pointers; + var savedFp = slots[fp + Squeak.Frame_savedFp]; + if (savedFp >= 0 && page.slots[savedFp + Squeak.Frame_context] == null) + this.nMarrySenderFill = (this.nMarrySenderFill || 0) + 1; + p[Squeak.Context_sender] = savedFp >= 0 ? this.marryFrame(page, savedFp) + : (page.baseCallerCtx || this.nilObj); + p[Squeak.Context_instructionPointer] = + this.encodeSqueakPC(pc, slots[fp + Squeak.Frame_method]); + var stackp = sp - fp - Squeak.Frame_firstTemp + 1; + p[Squeak.Context_stackPointer] = stackp; + for (var i = 0; i < stackp; i++) + p[Squeak.Context_tempFrameStart + i] = slots[fp + Squeak.Frame_firstTemp + i]; + ctx.dirty = true; + }, syncPage: function(page) { // refresh the snapshots of all married frames in a page (top to base); // marries callers shallowly to fill sender fields, so a full walk from @@ -223,6 +264,7 @@ Object.extend(Squeak.Interpreter.prototype, flushAllAndContinue: function() { // serialize every live frame to its (married) context, kill all pages, // and continue execution on a fresh base frame inflated from the top + this.nFlushAll = (this.nFlushAll || 0) + 1; this.saveActivePage(); var top = this.marryFrame(this.zonePage, this.fp); for (var i = 0; i < this.zonePages.length; i++) { @@ -246,6 +288,10 @@ Object.extend(Squeak.Interpreter.prototype, var methodField = ctx.pointers[Squeak.Context_method]; if (this.isSmallInt(methodField)) throw Error("stack zone: pre-closure BlockContexts not supported"); + if (ctx.sqClass !== this.contextClass_) + // los gates de sync/write-through comparan contra ClassMethodContext; + // una subclase casada los evadiría silenciosamente + throw Error("stack zone: cannot inflate a " + ctx.sqClass.className()); var page = this.allocPage(); var slots = page.slots; var closure = ctx.pointers[Squeak.Context_closure]; @@ -289,8 +335,9 @@ Object.extend(Squeak.Interpreter.prototype, lookupClass = this.getClass(newRcvr); } // married-context receivers: refresh their snapshot before Smalltalk looks - if (typeof newRcvr === "object" && newRcvr !== null && newRcvr.frame != null) - this.syncPage(newRcvr.frame.page); + // (class-identity gate first: no property probe on ordinary receivers) + if (lookupClass === this.contextClass_ && newRcvr.frame != null) + this.syncMarriedContext(newRcvr); var entry = this.findSelectorInClass(selector, argCount, lookupClass); if (entry.primIndex) { this.verifyAtSelector = selector; @@ -301,8 +348,9 @@ Object.extend(Squeak.Interpreter.prototype, sendSuperDirectedZ: function(selector, argCount) { var lookupClass = this.pop().superclass(); var newRcvr = this.stack[this.sp - argCount]; - if (typeof newRcvr === "object" && newRcvr !== null && newRcvr.frame != null) - this.syncPage(newRcvr.frame.page); + if (typeof newRcvr === "object" && newRcvr !== null + && newRcvr.sqClass === this.contextClass_ && newRcvr.frame != null) + this.syncMarriedContext(newRcvr); var entry = this.findSelectorInClass(selector, argCount, lookupClass); if (entry.primIndex) { this.verifyAtSelector = selector; @@ -507,8 +555,9 @@ Object.extend(Squeak.Interpreter.prototype, } }, exportThisContextZ: function() { + this.nMarryThisCtx = (this.nMarryThisCtx || 0) + 1; var ctx = this.marryFrame(this.zonePage, this.fp); - this.syncPage(this.zonePage); + this.syncMarriedContext(ctx); // solo el tope: O(frame), sin cascada this.reclaimableContextCount = 0; return ctx; }, @@ -536,6 +585,7 @@ Object.extend(Squeak.Interpreter.prototype, }, activeContextObjZ: function() { // for the few legacy readers that need the active context as an object + this.nMarryClosure = (this.nMarryClosure || 0) + 1; return this.marryFrame(this.zonePage, this.fp); }, tryPrimitiveZ: function(primIndex, argCount, newMethod) { From e20bcfa5afe6aaea0f49431901dc360feaa33e0e Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:26:02 -0300 Subject: [PATCH 11/22] [perf] jit2 WIP: stack-to-register compiler with inline caches (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Squeak.Compiler2 compiles V3 methods to JS where operand-stack slots live in JS locals tracked at compile time, spilled to the zone only at send sites and reloaded on trampoline re-entry (a local `entering` flag distinguishes re-entry from internal jumps; reload depth is the label's static stack depth, which also covers mid-method interpreter-to-compiled transitions at loop heads). Monomorphic inline caches per send site bypass findSelectorInClass on class hit. Unsupported bytecodes bail the whole method to jit1. Bugs already caught by the differential oracle and fixed: resume labels past the last jump target were not being generated; closure creation marries the active frame so it must spill first (stale vm.sp corrupted married snapshots and GC roots); the IC send path skipped sendZ's married-context sync gate. One known semantic divergence remains (V3 block temp pattern in WorldState>>runStepMethods, first differs at send ~717679 of the Cuis startup), so jit2 is opt-in: options.jit2 / --jit2 in the harness. Frames mode without jit2 remains golden-identical. Perf preview (20M sends, primitive-heavy workload): frames+jit2 3.16M sends/s vs frames+jit1 2.85M vs contexts+jit1 2.77M (+14% over today's VM) — with 75% method coverage, no direct calls yet, on the least favorable workload. Send-heavy code should gain much more. Also: Dialogo.32bits.image (Cuis 4507, V3 6521) validated in the oracle: frames and contexts traces identical; bench at parity with a known flushAll storm (23k full flushes / 10M sends from married- context stores) to optimize. Co-Authored-By: Claude Fable 5 --- jit2.js | 554 ++++++++++++++++++++++++++++++++++++++ perf/harness/difftrace.js | 58 +++- squeak.js | 1 + squeak_headless.js | 1 + squeak_node.js | 1 + vm.interpreter.js | 2 + vm.stackzone.js | 1 + 7 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 jit2.js diff --git a/jit2.js b/jit2.js new file mode 100644 index 00000000..9d9f4e56 --- /dev/null +++ b/jit2.js @@ -0,0 +1,554 @@ +"use strict"; +/* + * jit2: stack-to-register mapping compiler for stack-zone mode. + * + * Compiles V3(+closures) methods to JS functions where operand-stack slots + * live in JS locals (s0, s1, ...) whose depth is tracked at compile time. + * Slots are spilled to the zone only at send sites (where the frame must be + * observable/resumable) and reloaded on trampoline re-entry, distinguished + * from internal jumps by a function-local `entering` flag. Each send site + * has a monomorphic inline cache (class -> method/primitive), bypassing + * findSelectorInClass on hits. Unsupported bytecodes bail the whole method + * to the jit1 template compiler. + * + * Only active in stack-zone mode (generated code assumes frames). + */ + +Object.subclass('Squeak.Compiler2', +'initialization', { + initialize: function(vm) { + this.vm = vm; + this.fallback = new Squeak.Compiler(vm); + this.bailCount = 0; + this.okCount = 0; + }, +}, +'accessing', { + compile: function(method, optClassObj, optSelObj) { + if (method.compiled === undefined) { + method.compiled = false; // 1st time: defer (same policy as jit1) + return; + } + if (method.methodSignFlag() // Sista: not supported yet + || (typeof process !== "undefined" && process.env && process.env.JIT2BAIL)) { + return this.fallback.compile(method, optClassObj, optSelObj); + } + var clsName = optClassObj && optClassObj.className(), + sel = optSelObj && optSelObj.bytesAsString(); + var fn = this.generate2(method, clsName, sel); + if (fn) { + this.okCount++; + method.compiled = fn; + } else { + this.bailCount++; + return this.fallback.compile(method, optClassObj, optSelObj); + } + }, + enableSingleStepping: function(method, optClass, optSel) { + // debugging always uses the jit1 debug/single-step generator + return this.fallback.enableSingleStepping(method, optClass, optSel); + }, +}, +'generating', { + generate2: function(method, optClass, optSel) { + try { + return this.generateV3R(method, optClass, optSel); + } catch (e) { + if (e && e.bail) return null; + throw e; + } + }, + bail: function(why) { + throw { bail: true, why: why }; + }, + generateV3R: function(method, optClass, optSel) { + this.method = method; + this.pc = 0; + this.endPC = 0; + this.depth = 0; // virtual operand-stack depth + this.maxDepth = 0; + this.knownDepth = true; // false after unconditional jump/return until a label + this.labelDepth = {}; // pc -> expected depth (from forward jumps / resumes) + this.resumeDepth = {}; // pc -> depth to reload on trampoline re-entry + this.needsLabel = {}; + this.sourceParts = []; // list of {pc, code:[]} segments, one per instruction + this.ics = []; + var tempCount = method.methodTempCount(); + this.tempCount = tempCount; + // pass over bytecodes + this.done = false; + while (!this.done) { + this.instStart = this.pc; + this.beginInstruction(); + var b = method.bytes[this.pc++]; + this.generateByte(b); + } + // assemble + var parts = this.sourceParts; + var src = []; + var maxD = this.maxDepth; + var decl = []; + for (var i = 0; i < maxD; i++) decl.push("s" + i); + src.push("var zone = vm.stack, fp = vm.fp, page = vm.zonePage;\n"); + src.push("var rcvr = vm.receiver, inst = rcvr.pointers, lit = vm.method.pointers;\n"); + // spBase = base del stack de operandos de ESTA activación (métodos y + // blocks difieren); en entrada fresca depth=0 así que spBase = vm.sp, + // en re-entrada el prelude del label lo deriva de la depth de resume + src.push("var tB = vm.tempOffset, spBase = vm.sp;\n"); + if (decl.length) src.push("var ", decl.join(", "), ";\n"); + src.push("var entering = true;\n"); + src.push("while (true) switch (vm.pc) {\n"); + this.needsLabel[0] = true; + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + if (this.needsLabel[part.pc]) { + src.push("case ", part.pc, ":\n"); + // profundidad a recargar: la depth estática de este label — + // cubre resumes de sends Y transiciones intérprete->compilado + // a mitad de método (loop heads con contador en el stack) + var rd = this.emittedDepth[part.pc] || 0; + src.push("if (entering) { entering = false; spBase = vm.sp - ", rd, ";"); + for (var k = 0; k < rd; k++) + src.push(" s", k, " = zone[spBase + ", 1 + k, "];"); + src.push(" }\n"); + } + for (var j = 0; j < part.code.length; j++) src.push(part.code[j]); + } + src.push("default: if (vm.jit2Debug) { console.error('jit2 default pc=' + vm.pc + ' mbytes=' + vm.method.bytes.length); console.error('bytes: ' + Array.prototype.join.call(vm.method.bytes, ',')); console.error(String(vm.method.compiled).slice(0, 2000)); vm.jit2Debug = false; } vm.interpretOne(true); return;\n}"); + var funcName = (optClass && optSel) + ? (optClass + "_" + optSel).replace(/[^a-zA-Z0-9_]/g, "ː") + : "R_METHOD"; + var body = "'use strict';\nvar ics = arguments[0];\nreturn function " + funcName + + "(vm) {\n" + src.join("") + "}"; + try { + return new Function(body)(this.ics); + } catch (e) { + this.bail("syntax: " + e.message); + } + }, + beginInstruction: function() { + // labels: if this pc is a known jump target, depths must agree + var expected = this.labelDepth[this.instStart]; + if (!this.knownDepth) { + if (expected === undefined) this.bail("unreachable code with unknown depth"); + this.depth = expected; + this.knownDepth = true; + } else if (expected !== undefined && expected !== this.depth) { + this.bail("stack depth mismatch at label"); + } + if (!this.emittedDepth) this.emittedDepth = {}; + this.emittedDepth[this.instStart] = this.depth; + this.part = { pc: this.instStart, code: [] }; + this.sourceParts.push(this.part); + }, + emit: function(/* ... */) { + for (var i = 0; i < arguments.length; i++) this.part.code.push(arguments[i]); + }, + push: function(expr) { + this.emit("s", this.depth, " = ", expr, ";\n"); + this.depth++; + if (this.depth > this.maxDepth) this.maxDepth = this.depth; + }, + top: function() { return "s" + (this.depth - 1); }, + pop: function() { this.depth--; return "s" + this.depth; }, + slotAt: function(depthFromTop) { return "s" + (this.depth - 1 - depthFromTop); }, + spillAll: function() { + for (var k = 0; k < this.depth; k++) + this.emit("zone[spBase + ", 1 + k, "] = s", k, ";\n"); + this.emit("vm.sp = spBase + ", this.depth, ";\n"); + }, + activationCheck: function() { + return "if (vm.fp !== fp || vm.zonePage !== page || vm.breakOutOfInterpreter !== false) return;\n"; + }, + markJumpTarget: function(dest) { + this.needsLabel[dest] = true; + if (dest > this.endPC) this.endPC = dest; + if (dest <= this.instStart) { // backward: el label ya se emitió + if (this.emittedDepth[dest] !== this.depth) this.bail("backward jump depth mismatch"); + return; + } + var expected = this.labelDepth[dest]; + if (expected === undefined) this.labelDepth[dest] = this.depth; + else if (expected !== this.depth) this.bail("jump depth mismatch"); + }, + markResume: function(pc, depthAfter) { + this.needsLabel[pc] = true; + if (pc > this.endPC) this.endPC = pc; // el label de resume debe generarse + if (this.resumeDepth[pc] !== undefined && this.resumeDepth[pc] !== depthAfter) + this.bail("conflicting resume depths"); + this.resumeDepth[pc] = depthAfter; + if (pc <= this.instStart) { // backward (interrupt check de back-jump) + if (this.emittedDepth[pc] !== depthAfter) this.bail("backward resume depth mismatch"); + return; + } + var expected = this.labelDepth[pc]; + if (expected === undefined) this.labelDepth[pc] = depthAfter; + else if (expected !== depthAfter) this.bail("resume depth mismatch"); + }, + endOfSegment: function() { + // after return/unconditional jump: depth unknown until next label + this.knownDepth = false; + this.done = this.pc > this.endPC; + }, +}, +'bytecodes', { + generateByte: function(byte) { + var b2, b3; + switch (byte & 0xF8) { + case 0x00: case 0x08: // push inst var + this.push("inst[" + (byte & 0x0F) + "]"); break; + case 0x10: case 0x18: // push temp + this.push("zone[tB + " + (byte & 0xF) + "]"); break; + case 0x20: case 0x28: case 0x30: case 0x38: // push literal + this.push("lit[" + (1 + (byte & 0x1F)) + "]"); break; + case 0x40: case 0x48: case 0x50: case 0x58: // push literal indirect + this.push("lit[" + (1 + (byte & 0x1F)) + "].pointers[1]"); break; + case 0x60: // storeAndPop inst var + this.generateStoreInst(byte & 7, true); break; + case 0x68: // storeAndPop temp + this.emit("zone[tB + ", byte & 7, "] = ", this.pop(), ";\n"); break; + case 0x70: // quick pushes + switch (byte) { + case 0x70: this.push("rcvr"); break; + case 0x71: this.push("vm.trueObj"); break; + case 0x72: this.push("vm.falseObj"); break; + case 0x73: this.push("vm.nilObj"); break; + case 0x74: this.push("-1"); break; + case 0x75: this.push("0"); break; + case 0x76: this.push("1"); break; + case 0x77: this.push("2"); break; + } + break; + case 0x78: // quick returns + switch (byte) { + case 0x78: this.generateReturn("rcvr"); break; + case 0x79: this.generateReturn("vm.trueObj"); break; + case 0x7A: this.generateReturn("vm.falseObj"); break; + case 0x7B: this.generateReturn("vm.nilObj"); break; + case 0x7C: this.generateReturn(this.pop()); break; + case 0x7D: this.generateBlockReturn(); break; + default: this.bail("unusedBytecode " + byte); + } + break; + case 0x80: case 0x88: + this.generateExtended(byte); break; + case 0x90: // short jump + this.generateJump((byte & 7) + 1); break; + case 0x98: // short conditional jump (jumpIfFalse) + this.generateJumpIf(false, (byte & 7) + 1); break; + case 0xA0: // long jump + b2 = this.method.bytes[this.pc++]; + this.generateJump(((byte & 7) - 4) * 256 + b2); break; + case 0xA8: // long conditional jump + b2 = this.method.bytes[this.pc++]; + this.generateJumpIf(byte < 0xAC, (byte & 3) * 256 + b2); break; + case 0xB0: case 0xB8: // arithmetic special sends + this.generateNumericOp(byte); break; + case 0xC0: case 0xC8: // quick prims + this.generateQuickPrim(byte); break; + case 0xD0: case 0xD8: // send literal selector, 0 args + this.generateSend(1 + (byte & 0xF), 0); break; + case 0xE0: case 0xE8: // 1 arg + this.generateSend(1 + (byte & 0xF), 1); break; + case 0xF0: case 0xF8: // 2 args + this.generateSend(1 + (byte & 0xF), 2); break; + default: this.bail("bytecode " + byte); + } + }, + generateExtended: function(byte) { + var b2, b3; + switch (byte) { + case 0x80: // extended push + b2 = this.method.bytes[this.pc++]; + switch (b2 >> 6) { + case 0: this.push("inst[" + (b2 & 0x3F) + "]"); break; + case 1: this.push("zone[tB + " + (b2 & 0x3F) + "]"); break; + case 2: this.push("lit[" + (1 + (b2 & 0x3F)) + "]"); break; + case 3: this.push("lit[" + (1 + (b2 & 0x3F)) + "].pointers[1]"); break; + } + break; + case 0x81: // extended store (no pop) + b2 = this.method.bytes[this.pc++]; + switch (b2 >> 6) { + case 0: this.generateStoreInst(b2 & 0x3F, false); break; + case 1: this.emit("zone[tB + ", b2 & 0x3F, "] = ", this.top(), ";\n"); break; + case 2: this.bail("store into literal"); + case 3: this.emit("var assoc = lit[", 1 + (b2 & 0x3F), "]; assoc.dirty = true; assoc.pointers[1] = ", this.top(), ";\n"); break; + } + break; + case 0x82: // extended store-pop + b2 = this.method.bytes[this.pc++]; + switch (b2 >> 6) { + case 0: this.generateStoreInst(b2 & 0x3F, true); break; + case 1: this.emit("zone[tB + ", b2 & 0x3F, "] = ", this.pop(), ";\n"); break; + case 2: this.bail("store into literal"); + case 3: this.emit("var assoc = lit[", 1 + (b2 & 0x3F), "]; assoc.dirty = true; assoc.pointers[1] = ", this.pop(), ";\n"); break; + } + break; + case 0x83: // single extended send + b2 = this.method.bytes[this.pc++]; + this.generateSend(1 + (b2 & 31), b2 >> 5); + break; + case 0x84: // double extended do-anything + b2 = this.method.bytes[this.pc++]; + b3 = this.method.bytes[this.pc++]; + switch (b2 >> 5) { + case 0: this.generateSend(1 + b3, b2 & 31); break; + case 2: this.push("inst[" + b3 + "]"); break; + case 3: this.push("lit[" + (1 + b3) + "]"); break; + case 4: this.push("lit[" + (1 + b3) + "].pointers[1]"); break; + default: this.bail("doubleExtended op " + (b2 >> 5)); + } + break; + case 0x87: // pop + this.depth--; break; + case 0x88: // dup + this.push(this.top()); break; + case 0x89: // thisContext + this.spillAll(); + this.push("vm.exportThisContext()"); + break; + case 0x8F: // pushClosureCopy + this.generateClosureCopy(); break; + default: + this.bail("extended " + byte); // 0x85/0x86 super, 0x8A-0x8E: jit1 + } + }, + generateStoreInst: function(index, popIt) { + var val = popIt ? this.pop() : this.top(); + // married-context write-through, same protocol as jit1 frames templates + this.emit("if (rcvr.sqClass !== vm.contextClass_ || rcvr.frame == null) { inst[", index, "] = ", val, "; rcvr.dirty = true; }\n"); + this.emit("else { "); + this.spillAll(); + this.emit("vm.pc = ", this.pc, "; vm.storeToMarriedContext(rcvr, ", index, ", ", val, "); ", + this.activationCheck(), " }\n"); + this.markResume(this.pc, this.depth); + }, + generateReturn: function(what) { + this.emit("vm.pc = ", this.pc, "; vm.doReturn(", what, "); return;\n"); + this.endOfSegment(); + }, + generateBlockReturn: function() { + this.emit("vm.pc = ", this.pc, "; vm.doBlockReturn(", this.pop(), "); return;\n"); + this.endOfSegment(); + }, + generateJump: function(distance) { + var dest = this.pc + distance; + if (distance < 0) { + // backward jump: interrupt check; a process switch must find the + // frame resumable, so spill live slots (loop back-edges: usually 0) + this.spillAll(); + this.emit("if (vm.interruptCheckCounter-- <= 0) {\n", + " vm.pc = ", dest, "; vm.checkForInterrupts();\n", + " ", this.activationCheck(), "}\n"); + this.markResume(dest, this.depth); + } + this.markJumpTarget(dest); + this.emit("vm.pc = ", dest, "; continue;\n"); + this.endOfSegment(); + }, + generateJumpIf: function(condition, distance) { + var dest = this.pc + distance; + var cond = this.pop(); + this.emit("if (", cond, " === vm.", condition, "Obj) { vm.pc = ", dest, "; continue; }\n"); + this.emit("else if (", cond, " !== vm.", !condition, "Obj) {\n"); + this.depth++; this.spillAll(); this.depth--; // cond back on stack as receiver + this.emit(" vm.pc = ", this.pc, "; vm.send(vm.specialObjects[25], 0, false); return; }\n"); + this.markJumpTarget(dest); + this.markResume(this.pc, this.depth); + }, + generateSend: function(litIndex, argCount) { + var ic = this.ics.length; + this.ics.push({ c: null, m: null, a: 0, p: 0, k: null }); + var rcvrSlot = this.slotAt(argCount); + this.spillAll(); + this.emit("vm.pc = ", this.pc, ";\n"); + this.emit("var ic = ics[", ic, "], rc = typeof ", rcvrSlot, " === 'number' ? vm.smallIntClass_ : ", rcvrSlot, ".sqClass;\n"); + this.emit("if (rc !== ic.c) vm.jit2FillIC(ic, lit[", litIndex, "], ", argCount, ", rc);\n"); + // receivers que son contexts casados: refrescar snapshot (mismo gate que sendZ) + this.emit("if (rc === vm.contextClass_ && ", rcvrSlot, ".frame != null) vm.syncMarriedContext(", rcvrSlot, ");\n"); + this.emit("if (ic.p) { vm.verifyAtSelector = lit[", litIndex, "]; vm.verifyAtClass = rc; }\n"); + this.emit("vm.executeNewMethod(", rcvrSlot, ", ic.m, ic.a, ic.p, ic.k, lit[", litIndex, "]);\n"); + this.emit(this.activationCheck()); + this.depth -= argCount + 1; + this.push("zone[vm.sp]"); // result (fast-path prim; resume reloads instead) + this.markResume(this.pc, this.depth); + }, + generateNumericOp: function(byte) { + var a, b; + switch (byte & 0xF) { + case 0x0: case 0x1: { // + - + var op = (byte & 0xF) === 0 ? "+" : "-"; + b = this.pop(); a = this.top(); + this.emit("if (typeof ", a, " === 'number' && typeof ", b, " === 'number') ", + a, " = vm.primHandler.signed32BitIntegerFor(", a, " ", op, " ", b, ");\n"); + this.generateNumericFallback(a, b, byte & 0xF); + break; + } + case 0x2: case 0x3: case 0x4: case 0x5: { // < > <= >= + var cmp = ["<", ">", "<=", ">="][(byte & 0xF) - 2]; + b = this.pop(); a = this.top(); + this.emit("if (typeof ", a, " === 'number' && typeof ", b, " === 'number') ", + a, " = ", a, " ", cmp, " ", b, " ? vm.trueObj : vm.falseObj;\n"); + this.generateNumericFallback(a, b, byte & 0xF); + break; + } + case 0x6: case 0x7: { // = ~= + var eq = (byte & 0xF) === 6; + b = this.pop(); a = this.top(); + this.emit("if (typeof ", a, " === 'number' && typeof ", b, " === 'number') ", + a, " = ", a, " ", eq ? "===" : "!==", " ", b, " ? vm.trueObj : vm.falseObj;\n"); + this.emit("else if (", a, " === ", b, " && ", a, ".float === ", a, ".float) ", + a, " = vm.", eq ? "true" : "false", "Obj;\n"); + this.generateNumericFallback(a, b, byte & 0xF); + break; + } + case 0xE: case 0xF: { // bitAnd: bitOr: + var bop = (byte & 0xF) === 0xE ? "&" : "|"; + b = this.pop(); a = this.top(); + this.emit("if (typeof ", a, " === 'number' && typeof ", b, " === 'number' && (", a, "|0) === ", a, " && (", b, "|0) === ", b, ") ", + a, " = ", a, " ", bop, " ", b, ";\n"); + this.generateNumericFallback(a, b, byte & 0xF); + break; + } + default: { // * / \\ @ bitShift: // : usar el camino genérico de vm + b = this.pop(); a = this.top(); + this.generateNumericSlow(byte & 0xF); + break; + } + } + }, + generateNumericFallback: function(a, b, opIndex) { + // a/b son locals; a ya tiene el resultado si el fast path aplicó. + // Si no aplicó, mandar el send especial (stack: rcvr, arg). + this.emit("else {\n"); + this.depth++; this.spillAll(); this.depth--; + this.emit(" vm.pc = ", this.pc, "; vm.sendSpecial(", opIndex, "); ", this.activationCheck()); + this.emit(" ", a, " = zone[vm.sp];\n}\n"); + this.markResume(this.pc, this.depth); + }, + generateNumericSlow: function(opIndex) { + // sin fast path inline: siempre via camino del vm (pop2AndPush... via sendSpecial-like) + this.depth++; this.spillAll(); this.depth--; + var a = "s" + (this.depth - 1); + this.emit("vm.pc = ", this.pc, "; vm.sp = spBase + ", this.depth + 1, ";\n"); + // replicar jit1: success+pop2AndPushResult según op + switch (opIndex) { + case 0x8: this.emit("vm.success = true; vm.resultIsFloat = false; if (!vm.pop2AndPushNumResult(vm.stackIntOrFloat(1) * vm.stackIntOrFloat(0))) { vm.sendSpecial(8); ", this.activationCheck(), "}\n"); break; + case 0x9: this.emit("vm.success = true; if (!vm.pop2AndPushIntResult(vm.quickDivide(vm.stackInteger(1), vm.stackInteger(0)))) { vm.sendSpecial(9); ", this.activationCheck(), "}\n"); break; + case 0xA: this.emit("vm.success = true; if (!vm.pop2AndPushIntResult(vm.mod(vm.stackInteger(1), vm.stackInteger(0)))) { vm.sendSpecial(10); ", this.activationCheck(), "}\n"); break; + case 0xB: this.emit("vm.success = true; if (!vm.primHandler.primitiveMakePoint(1, true)) { vm.sendSpecial(11); ", this.activationCheck(), "}\n"); break; + case 0xC: this.emit("vm.success = true; if (!vm.pop2AndPushIntResult(vm.safeShift(vm.stackInteger(1), vm.stackInteger(0)))) { vm.sendSpecial(12); ", this.activationCheck(), "}\n"); break; + case 0xD: this.emit("vm.success = true; if (!vm.pop2AndPushIntResult(vm.div(vm.stackInteger(1), vm.stackInteger(0)))) { vm.sendSpecial(13); ", this.activationCheck(), "}\n"); break; + default: this.bail("numeric op " + opIndex); + } + this.emit(a, " = zone[vm.sp];\n"); + this.markResume(this.pc, this.depth); + }, + generateQuickPrim: function(byte) { + var lo = byte & 0xF; + switch (lo) { + case 0x6: { // == + var b = this.pop(), a = this.top(); + this.emit(a, " = ", a, " === ", b, " ? vm.trueObj : vm.falseObj;\n"); + return; + } + case 0x7: { // class + var t = this.top(); + this.emit(t, " = typeof ", t, " === 'number' ? vm.specialObjects[5] : ", t, ".sqClass;\n"); + return; + } + case 0x0: { // at: + var idx = this.slotAt(0), arr = this.slotAt(1); + this.emit("if (", arr, ".sqClass === vm.specialObjects[7] && ", arr, ".pointers && typeof ", idx, " === 'number' && ", idx, " > 0 && ", idx, " <= ", arr, ".pointers.length) {\n", + " ", arr, " = ", arr, ".pointers[", idx, " - 1];\n} else {\n"); + this.spillAll(); + this.emit(" var c = vm.primHandler.objectAt(true,true,false); if (vm.primHandler.success) { vm.sp -= 1; ", arr, " = c; } else {\n", + " vm.pc = ", this.pc, "; vm.sendSpecial(16); ", this.activationCheck(), + " ", arr, " = zone[vm.sp]; }\n}\n"); + this.depth--; + this.markResume(this.pc, this.depth); + return; + } + case 0x1: { // at:put: + var val = this.slotAt(0), idx = this.slotAt(1), arr = this.slotAt(2); + this.emit("if (", arr, ".sqClass === vm.specialObjects[7] && ", arr, ".pointers && typeof ", idx, " === 'number' && ", idx, " > 0 && ", idx, " <= ", arr, ".pointers.length) {\n", + " ", arr, ".pointers[", idx, " - 1] = ", val, "; ", arr, ".dirty = true; ", arr, " = ", val, ";\n} else {\n"); + this.spillAll(); + // puede flushear (context casado): pc de re-ejecución idempotente + this.emit(" vm.pc = ", this.instStart, "; vm.primHandler.objectAtPut(true,true,false); if (vm.fp !== fp || vm.zonePage !== page) return;\n", + " if (vm.primHandler.success) { vm.sp -= 2; ", arr, " = ", val, "; } else {\n", + " vm.pc = ", this.pc, "; vm.sendSpecial(17); ", this.activationCheck(), + " ", arr, " = zone[vm.sp]; }\n}\n"); + this.depth -= 2; + this.needsLabel[this.instStart] = true; + this.labelDepth[this.instStart] = this.depth + 2; + this.resumeDepth[this.instStart] = this.depth + 2; + this.markResume(this.pc, this.depth); + return; + } + case 0x2: { // size + var t = this.top(); + this.emit("if (", t, ".sqClass === vm.specialObjects[7]) ", t, " = ", t, ".pointersSize();\n", + "else if (", t, ".sqClass === vm.specialObjects[6]) ", t, " = ", t, ".bytesSize();\n", + "else {\n"); + this.spillAll(); + this.emit(" vm.pc = ", this.pc, "; vm.sendSpecial(18); ", this.activationCheck(), + " ", t, " = zone[vm.sp];\n}\n"); + this.markResume(this.pc, this.depth); + return; + } + case 0x9: case 0xA: case 0xB: { // value value: do: + this.spillAll(); + var rcvrSlot = this.slotAt(lo === 0x9 ? 0 : 1); + this.emit("vm.pc = ", this.pc, "; if (!vm.primHandler.quickSendOther(", rcvrSlot, ", ", lo, ")) vm.sendSpecial(", lo + 16, "); return;\n"); + this.depth -= (lo === 0x9 ? 0 : 1); + this.markResume(this.pc, this.depth); + this.endOfSegment(); + // tras el return el resultado queda en zone: el resume lo recarga + return; + } + default: // next nextPut: atEnd blockCopy: new new: x y -> jit1 + this.bail("quick prim " + lo); + } + }, + generateClosureCopy: function() { + var b = this.method.bytes; + var numArgsNumCopied = b[this.pc++], + numArgs = numArgsNumCopied & 0xF, + numCopied = numArgsNumCopied >> 4, + blockSize = b[this.pc++] * 256 + b[this.pc++]; + var from = this.pc, to = from + blockSize; + // marry del frame activo: la zona y vm.sp deben reflejar el estado real + // (el snapshot del context casado y el GC leen vm.sp) + this.spillAll(); + this.emit("var closure = vm.instantiateClass(vm.specialObjects[36], ", numCopied, ");\n"); + this.emit("closure.pointers[0] = vm.activeContextObj(); vm.reclaimableContextCount = 0;\n"); + this.emit("closure.pointers[1] = ", from + this.method.pointers.length * 4 + 1, ";\n"); + this.emit("closure.pointers[2] = ", numArgs, ";\n"); + for (var i = 0; i < numCopied; i++) + this.emit("closure.pointers[", 3 + i, "] = ", this.slotAt(numCopied - i - 1), ";\n"); + this.depth -= numCopied; + this.push("closure"); + this.emit("vm.pc = ", to, "; continue;\n"); + this.markJumpTarget(to); + // el cuerpo del block arranca con stack vacío (activación fresca) + this.needsLabel[from] = true; + this.labelDepth[from] = 0; + this.resumeDepth[from] = 0; + if (to > this.endPC) this.endPC = to; + this.endOfSegment(); + }, +}); + +// vm-side support +Object.extend(Squeak.Interpreter.prototype, 'jit2 support', { + jit2FillIC: function(ic, selector, argCount, lookupClass) { + var entry = this.findSelectorInClass(selector, argCount, lookupClass); + ic.c = lookupClass; + ic.m = entry.method; + ic.a = entry.argCount; + ic.p = entry.primIndex; + ic.k = entry.mClass; + }, +}); diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 6d169b85..4afd2164 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -22,6 +22,7 @@ var mode = args.indexOf("--golden") >= 0 ? "golden" : args.indexOf("--bench") >= 0 ? "bench" : "check"; var useFrames = args.indexOf("--frames") >= 0; // correr con el stack zone activado +var useJit2 = args.indexOf("--jit2") >= 0; // stack-to-register jit (requiere --frames) var noJit = args.indexOf("--nojit") >= 0; // apagar el jit (para aislar divergencias jit vs frames) function argValue(name, deflt) { var i = args.indexOf(name); @@ -105,7 +106,7 @@ Object.assign(self, { "globals.js", "vm.js", "vm.object.js", "vm.object.spur.js", "vm.image.js", "vm.interpreter.js", "vm.interpreter.proxy.js", "vm.instruction.stream.js", "vm.instruction.stream.sista.js", "vm.instruction.printer.js", "vm.primitives.js", - "jit.js", "vm.display.js", "vm.display.headless.js", "vm.input.js", + "jit.js", "jit2.js", "vm.display.js", "vm.display.headless.js", "vm.input.js", "vm.input.headless.js", "vm.plugins.js", "vm.plugins.file.node.js", "vm.stackzone.js", ].forEach(function(f) { require(path.join(repoRoot, f)); }); @@ -156,8 +157,44 @@ var data = fs.readFileSync(imagePath); var image = new Squeak.Image(imagePath.replace(/\.image$/, "")); image.readFromBuffer(data.buffer, function startRunning() { var display = { vmOptions: ["-vm-display-null", "-nodisplay"] }; - var vm = new Squeak.Interpreter(image, display, useFrames ? { stackZone: true } : {}); + var vm = new Squeak.Interpreter(image, display, useFrames ? { stackZone: true, jit2: useJit2 } : {}); if (noJit) vm.compiler = null; + if (process.env.JIT2DBG) vm.jit2Debug = true; + if (process.env.SEMDBG) { + var origSS = vm.primHandler.synchronousSignal; + vm.primHandler.synchronousSignal = function(sema) { + if (vm.sendCount >= 717670 && vm.sendCount <= 717685) + console.error("SIG s=" + vm.sendCount + " semaHash=" + sema.hash + + " excess=" + sema.pointers[Squeak.Sema_excessSignals] + + " empty=" + this.isEmptyList(sema) + + " firstLink=" + (sema.pointers[Squeak.LinkedList_firstLink].isNil ? "nil" : "proc:" + sema.pointers[Squeak.LinkedList_firstLink].hash)); + return origSS.call(this, sema); + }; + var origWait = vm.primHandler.primitiveWait || null; + } + if (process.env.GCDBG) { + var origFGR = vm.frameGCRoots; + var fgrCalls = 0; + vm.frameGCRoots = function() { + var roots = origFGR.call(vm); + if (++fgrCalls <= 2) { + var desc = []; + for (var i = 0; i < vm.zonePages.length; i++) { + var pg = vm.zonePages[i]; + desc.push((pg.live ? "L" : "d") + " fp=" + pg.fp + " sp=" + pg.sp + " len=" + pg.slots.length); + } + console.error("FGR#" + fgrCalls + " s=" + vm.sendCount + " roots=" + roots.length + " | " + desc.join(" | ")); + } + return roots; + }; + } + if (process.env.CHKDBG) { + var origCFI = vm.checkForInterrupts; + vm.checkForInterrupts = function() { + console.error("CHK s=" + vm.sendCount + " vms=" + virtualMs + " reset=" + vm.interruptCheckCounterFeedBackReset + " wake=" + vm.nextWakeupTick); + return origCFI.call(vm); + }; + } if (process.env.ZDBG9) { // volcar bytes+literales del método activado cuando mbytes coincide var z9size = parseInt(process.env.ZDBG9_SIZE), z9From = parseInt(process.env.ZDBG9_FROM || "0"); @@ -332,11 +369,19 @@ image.readFromBuffer(data.buffer, function startRunning() { mix(vm.sendCount); mix(newMethod._traceId); mix(vm.pc); - if (logLines) logLines.push("s=" + vm.sendCount + " h=" + newMethod._traceId + " pc=" + vm.pc); + if (logLines) logLines.push("s=" + vm.sendCount + " h=" + newMethod._traceId + " pc=" + vm.pc + " vms=" + virtualMs + " alloc=" + (vm.image.newSpaceCount + vm.image.allocationCount)); } - if (logLines && vm.sendCount >= logFrom && vm.sendCount <= logTo) + if (logLines && vm.sendCount >= logFrom && vm.sendCount <= logTo) { + if (vm.method && vm.method._traceId === undefined) { + var fp2 = vm.method.bytes ? vm.method.bytes.length : 0; + if (vm.method.bytes) for (var bj = 0; bj < Math.min(vm.method.bytes.length, 16); bj++) + fp2 = ((fp2 * 31) + vm.method.bytes[bj]) | 0; + vm.method._traceId = fp2; + } logLines.push("S=" + vm.sendCount + " h=" + newMethod._traceId + " pc=" + vm.pc + " args=" + argumentCount + " prim=" + primitiveIndex + + " cm=" + (vm.method ? vm.method._traceId : "?") + " rcls=" + vm.getClass(newRcvr).className() + " sel=" + (optSel && optSel.bytesAsString ? optSel.bytesAsString() : "?")); + } return origENM.call(vm, newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel); }; } @@ -408,6 +453,11 @@ image.readFromBuffer(data.buffer, function startRunning() { return; } + if (vm.compiler && vm.compiler.okCount !== undefined) + console.log("jit2: ok=" + vm.compiler.okCount + " bail=" + vm.compiler.bailCount); + if (vm.useStackZone) + console.log("married=" + (vm.nMarriedContexts||0) + " byClosure=" + (vm.nMarryClosure||0) + + " byThisCtx=" + (vm.nMarryThisCtx||0) + " bySenderFill=" + (vm.nMarrySenderFill||0)); console.log("trace: sends=" + report.sendCount + " slices=" + report.slices + " stop=" + report.stopReason + " hash=" + report.hash + " virtualMs=" + report.virtualMs + " (wall " + wallMs.toFixed(0) + " ms)"); diff --git a/squeak.js b/squeak.js index 7185bd9b..2e0fbd0a 100644 --- a/squeak.js +++ b/squeak.js @@ -39,6 +39,7 @@ import "./vm.instruction.printer.js"; import "./vm.primitives.js"; import "./jit.js"; import "./vm.stackzone.js"; +import "./jit2.js"; import "./vm.audio.browser.js"; import "./vm.display.js"; import "./vm.display.browser.js"; diff --git a/squeak_headless.js b/squeak_headless.js index 125abe48..03937427 100644 --- a/squeak_headless.js +++ b/squeak_headless.js @@ -39,6 +39,7 @@ import "./vm.instruction.printer.js"; import "./vm.primitives.js"; import "./jit.js"; import "./vm.stackzone.js"; +import "./jit2.js"; import "./vm.display.js"; import "./vm.display.headless.js"; // use headless display to prevent image crashing/becoming unresponsive import "./vm.input.js"; diff --git a/squeak_node.js b/squeak_node.js index 8811990c..80c16001 100644 --- a/squeak_node.js +++ b/squeak_node.js @@ -95,6 +95,7 @@ require("./vm.instruction.printer.js"); require("./vm.primitives.js"); require("./jit.js"); require("./vm.stackzone.js"); +require("./jit2.js"); require("./vm.display.js"); require("./vm.display.headless.js"); // use headless display to prevent image crashing/becoming unresponsive require("./vm.input.js"); diff --git a/vm.interpreter.js b/vm.interpreter.js index c6eb3e8f..87df7e34 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -36,6 +36,8 @@ Object.subclass('Squeak.Interpreter', this.loadInitialContext(); this.hackImage(); this.initCompiler(); + if (this.useStackZone && this.options.jit2 && this.compiler && Squeak.Compiler2) + this.compiler = new Squeak.Compiler2(this); // stack-to-register jit (WIP, opt-in) console.log('squeak: ready'); }, loadImageState: function() { diff --git a/vm.stackzone.js b/vm.stackzone.js index 63333db2..f870c44e 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -49,6 +49,7 @@ Object.extend(Squeak.Interpreter.prototype, // pueden estar casadas; comparar la clase primero evita LoadICs // megamórficos sobre receivers arbitrarios (medido: 24% del tiempo) this.contextClass_ = this.specialObjects[Squeak.splOb_ClassMethodContext]; + this.smallIntClass_ = this.specialObjects[Squeak.splOb_ClassInteger]; var vm = this; // rebind execution methods to the frames variants this.send = this.sendZ; From b4df359c9d17f7de14bbd27a0410c2384bff40ee Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:10:14 -0300 Subject: [PATCH 12/22] =?UTF-8?q?[perf]=20jit2:=20fix=20V3=20block=20stack?= =?UTF-8?q?-temp=20aliasing=20=E2=80=94=20golden-identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the remaining divergence: in V3 blocks, temps beyond numArgs+numCopied are allocated as operand-stack slots (the pushNil prologue), so a block's storeTemp writes the same physical slot that jit2 maps to a JS local — the local kept the stale value and the next spill clobbered the stored one. Found by per-method bisection (JIT2SEL=div,rem) down to SharedQueue>>nextOrNil, whose dequeued item was overwritten with nil, making the World's event queue look empty. v1 fix: methods whose blocks access temps at or beyond their args+copied ceiling bail to jit1 (correct, loses ~125 methods; a pinned-zone-slot refinement can recover them later). Validation: frames+jit2 now produces the IDENTICAL trace to the context-mode golden over the full Cuis startup, and identical hashes to context mode on Dialogo.32bits.image. Bench (20M sends): contexts 2.78M / frames+jit1 2.87M / frames+jit2 3.16M sends/s (+14%), with 1370 methods compiled and correctness fully intact. Also adds JIT2SEL bisection switch and marriage-source counters to the harness debug tooling. Co-Authored-By: Claude Fable 5 --- jit2.js | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/jit2.js b/jit2.js index 9d9f4e56..90faef81 100644 --- a/jit2.js +++ b/jit2.js @@ -33,6 +33,18 @@ Object.subclass('Squeak.Compiler2', || (typeof process !== "undefined" && process.env && process.env.JIT2BAIL)) { return this.fallback.compile(method, optClassObj, optSelObj); } + // bisección de bugs: JIT2SEL="div,resto" compila con jit2 solo métodos + // cuyo fingerprint % div === alguno de los restos listados + if (typeof process !== "undefined" && process.env && process.env.JIT2SEL) { + var parts = process.env.JIT2SEL.split(",").map(Number); + var div = parts[0]; + var fpr = method.bytes ? method.bytes.length : 0; + if (method.bytes) for (var bi = 0; bi < Math.min(method.bytes.length, 16); bi++) + fpr = ((fpr * 31) + method.bytes[bi]) | 0; + var rem = ((fpr % div) + div) % div; + if (parts.indexOf(rem, 1) < 0) + return this.fallback.compile(method, optClassObj, optSelObj); + } var clsName = optClassObj && optClassObj.className(), sel = optSelObj && optSelObj.bytesAsString(); var fn = this.generate2(method, clsName, sel); @@ -75,6 +87,10 @@ Object.subclass('Squeak.Compiler2', this.ics = []; var tempCount = method.methodTempCount(); this.tempCount = tempCount; + this.blockRegions = []; // {from, to, ceil}: dentro de un block V3, los temps + // con índice >= args+copied ALIASAN slots del stack de + // operandos (los aloca el pushNil inicial del block); + // jit2 los tiene en locals, así que esos métodos bailean // pass over bytecodes this.done = false; while (!this.done) { @@ -152,6 +168,16 @@ Object.subclass('Squeak.Compiler2', top: function() { return "s" + (this.depth - 1); }, pop: function() { this.depth--; return "s" + this.depth; }, slotAt: function(depthFromTop) { return "s" + (this.depth - 1 - depthFromTop); }, + checkTempAccess: function(index) { + // buscar la región de block MÁS INTERNA que contenga el pc actual + for (var i = this.blockRegions.length - 1; i >= 0; i--) { + var r = this.blockRegions[i]; + if (this.instStart >= r.from && this.instStart < r.to) { + if (index >= r.ceil) this.bail("block stack-temp aliasing"); + return; + } + } + }, spillAll: function() { for (var k = 0; k < this.depth; k++) this.emit("zone[spBase + ", 1 + k, "] = s", k, ";\n"); @@ -198,6 +224,7 @@ Object.subclass('Squeak.Compiler2', case 0x00: case 0x08: // push inst var this.push("inst[" + (byte & 0x0F) + "]"); break; case 0x10: case 0x18: // push temp + this.checkTempAccess(byte & 0xF); this.push("zone[tB + " + (byte & 0xF) + "]"); break; case 0x20: case 0x28: case 0x30: case 0x38: // push literal this.push("lit[" + (1 + (byte & 0x1F)) + "]"); break; @@ -206,6 +233,7 @@ Object.subclass('Squeak.Compiler2', case 0x60: // storeAndPop inst var this.generateStoreInst(byte & 7, true); break; case 0x68: // storeAndPop temp + this.checkTempAccess(byte & 7); this.emit("zone[tB + ", byte & 7, "] = ", this.pop(), ";\n"); break; case 0x70: // quick pushes switch (byte) { @@ -262,7 +290,7 @@ Object.subclass('Squeak.Compiler2', b2 = this.method.bytes[this.pc++]; switch (b2 >> 6) { case 0: this.push("inst[" + (b2 & 0x3F) + "]"); break; - case 1: this.push("zone[tB + " + (b2 & 0x3F) + "]"); break; + case 1: this.checkTempAccess(b2 & 0x3F); this.push("zone[tB + " + (b2 & 0x3F) + "]"); break; case 2: this.push("lit[" + (1 + (b2 & 0x3F)) + "]"); break; case 3: this.push("lit[" + (1 + (b2 & 0x3F)) + "].pointers[1]"); break; } @@ -271,7 +299,7 @@ Object.subclass('Squeak.Compiler2', b2 = this.method.bytes[this.pc++]; switch (b2 >> 6) { case 0: this.generateStoreInst(b2 & 0x3F, false); break; - case 1: this.emit("zone[tB + ", b2 & 0x3F, "] = ", this.top(), ";\n"); break; + case 1: this.checkTempAccess(b2 & 0x3F); this.emit("zone[tB + ", b2 & 0x3F, "] = ", this.top(), ";\n"); break; case 2: this.bail("store into literal"); case 3: this.emit("var assoc = lit[", 1 + (b2 & 0x3F), "]; assoc.dirty = true; assoc.pointers[1] = ", this.top(), ";\n"); break; } @@ -280,7 +308,7 @@ Object.subclass('Squeak.Compiler2', b2 = this.method.bytes[this.pc++]; switch (b2 >> 6) { case 0: this.generateStoreInst(b2 & 0x3F, true); break; - case 1: this.emit("zone[tB + ", b2 & 0x3F, "] = ", this.pop(), ";\n"); break; + case 1: this.checkTempAccess(b2 & 0x3F); this.emit("zone[tB + ", b2 & 0x3F, "] = ", this.pop(), ";\n"); break; case 2: this.bail("store into literal"); case 3: this.emit("var assoc = lit[", 1 + (b2 & 0x3F), "]; assoc.dirty = true; assoc.pointers[1] = ", this.pop(), ";\n"); break; } @@ -519,6 +547,7 @@ Object.subclass('Squeak.Compiler2', numCopied = numArgsNumCopied >> 4, blockSize = b[this.pc++] * 256 + b[this.pc++]; var from = this.pc, to = from + blockSize; + this.blockRegions.push({ from: from, to: to, ceil: numArgs + numCopied }); // marry del frame activo: la zona y vm.sp deben reflejar el estado real // (el snapshot del context casado y el GC leen vm.sp) this.spillAll(); From a093c172419566dad2d5490484d3e439121e2fdf Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:54:56 -0300 Subject: [PATCH 13/22] [perf] direct JS calls between activations (frames mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeNewMethodZ and the closure activations now invoke the callee's compiled function as a plain JS call instead of returning to the trampoline. The JS stack never holds suspended state: every level returns as soon as its Smalltalk continuation leaves its frame (the existing activation checks), so a process switch unwinds the whole chain with one compare+return per level — no exceptions needed. Depth-capped at 512 for the JS stack limit. Because this lives in the activation path, interpreter, jit1 and jit2 senders all get it with zero template changes. Validation: golden-identical on the full Cuis startup, hashes identical on Dialogo. Perf: neutral on real workloads (startup wall 2.39s vs 2.39-2.50s; the trampoline hop was not the dominant send cost — frame materialization is). Kept because it is the structural prerequisite for frameless leaf calls (args as JS arguments, Cog-style frameless methods), which is where the send-heavy 2.5x actually lives. Also: -jit2 flag for squeak_node.js; the run/ launcher already forwards #jit2 from the URL fragment. Co-Authored-By: Claude Fable 5 --- squeak_node.js | 6 +++++- vm.interpreter.js | 1 + vm.stackzone.js | 23 +++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/squeak_node.js b/squeak_node.js index 80c16001..2cd2a0e3 100644 --- a/squeak_node.js +++ b/squeak_node.js @@ -44,6 +44,10 @@ var stackZone = processArgs[0] === "-stackZone"; if (stackZone) { processArgs = processArgs.slice(1); } +var jit2 = processArgs[0] === "-jit2"; +if (jit2) { + processArgs = processArgs.slice(1); +} var fullName = processArgs[0]; if (!fullName) { console.error("No image name specified."); @@ -139,7 +143,7 @@ fs.readFile(root + imageName + ".image", function(error, data) { // Create fake display and create interpreter var display = { vmOptions: [ "-vm-display-null", "-nodisplay" ] }; - var vm = new Squeak.Interpreter(image, display, stackZone ? { stackZone: true } : {}); + var vm = new Squeak.Interpreter(image, display, stackZone ? { stackZone: true, jit2: jit2 } : {}); function run() { try { vm.interpret(200, function runAgain(ms) { diff --git a/vm.interpreter.js b/vm.interpreter.js index 87df7e34..aeb8048f 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -62,6 +62,7 @@ Object.subclass('Squeak.Interpreter', this.temps = null; // temp base array (homeContext.pointers; a zone page once stack frames land) this.tempOffset = Squeak.Context_tempFrameStart; // index of temp 0 in this.temps // stack-zone state, declared here for stable object shape even when unused + this.jsDepth = 0; // profundidad de llamadas JS directas (modo frames) this.useStackZone = false; this.zonePages = null; this.zonePage = null; diff --git a/vm.stackzone.js b/vm.stackzone.js index f870c44e..1a76d0eb 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -396,8 +396,21 @@ Object.extend(Squeak.Interpreter.prototype, this.receiver = newRcvr; this.tempOffset = base; if (!newMethod.compiled) this.compileIfPossible(newMethod, optClass, optSel); + var myPage = this.zonePage; // check for process switch on full method activation if (this.interruptCheckCounter-- <= 0) this.checkForInterrupts(); + // direct call: run the callee as a plain JS call instead of returning to + // the trampoline. The JS stack never holds suspended state (every level + // returns as soon as its Smalltalk continuation leaves its frame, via the + // activation checks), so a process switch simply unwinds the whole chain + // one compare+return per level. Depth-capped for the JS stack limit. + if (newMethod.compiled && this.jsDepth < 512 + && this.fp === nfp && this.zonePage === myPage + && this.breakOutOfInterpreter === false) { + this.jsDepth++; + newMethod.compiled(this); + this.jsDepth--; + } }, doBlockReturnZ: function(returnValue) { // return from a block activation to its caller (never non-local) @@ -655,6 +668,11 @@ Object.extend(Squeak.Primitives.prototype, vm.method = method; vm.receiver = receiver; vm.tempOffset = base; + if (method.compiled && vm.jsDepth < 512 && vm.breakOutOfInterpreter === false) { + vm.jsDepth++; + method.compiled(vm); + vm.jsDepth--; + } }, activateNewFullClosureZ: function(blockClosure, argCount) { var vm = this.vm; @@ -685,5 +703,10 @@ Object.extend(Squeak.Primitives.prototype, vm.receiver = receiver; vm.tempOffset = base; if (!closureMethod.compiled) vm.compileIfPossible(closureMethod); + if (closureMethod.compiled && vm.jsDepth < 512 && vm.breakOutOfInterpreter === false) { + vm.jsDepth++; + closureMethod.compiled(vm); + vm.jsDepth--; + } }, }); From 573026de57572ff76ea87619d8a473d3106eebdf Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:46:26 -0300 Subject: [PATCH 14/22] =?UTF-8?q?[perf]=20jit2:=20frameless=20leaf=20calls?= =?UTF-8?q?=20=E2=80=94=20golden-identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaf-eligible methods (prim-less; pushes, temp/inst stores, smallint arithmetic/comparisons, ==/class, forward jumps, returns; no sends, no closures, no loops, no allocations — arithmetic overflow deopts instead of allocating so a deopt-restart never double-allocates; inst stores bail if any later deopt point exists) get a second compiled form that takes (vm, rcvr, args...) as plain JS arguments and returns the result or a deopt sentinel. jit2 send sites with an IC hit call it with NO spill, NO frame, NO zone traffic. Trace parity vs the golden is exact: sendCount++ on the leaf path (-- on deopt; the framed retry re-increments); leaves only run when interruptCheckCounter > 0 and decrement it, so check cadence matches the framed path; a harness hook samples successful leaf sends with the same (sendCount, method, pc) tuples as the executeNewMethod wrapper. 228 leaf forms out of 1370 jit2 methods on the Cuis startup; full-run trace identical to the context-mode golden. Co-Authored-By: Claude Fable 5 --- jit2.js | 211 +++++++++++++++++++++++++++++++++++++- perf/harness/difftrace.js | 38 ++++++- vm.interpreter.js | 4 + vm.stackzone.js | 1 + 4 files changed, 249 insertions(+), 5 deletions(-) diff --git a/jit2.js b/jit2.js index 90faef81..4383be9d 100644 --- a/jit2.js +++ b/jit2.js @@ -14,6 +14,8 @@ * Only active in stack-zone mode (generated code assumes frames). */ +Squeak.LEAF_DEOPT = { leafDeopt: true }; // sentinel: el leaf no pudo, re-ejecutar framed + Object.subclass('Squeak.Compiler2', 'initialization', { initialize: function(vm) { @@ -51,8 +53,12 @@ Object.subclass('Squeak.Compiler2', if (fn) { this.okCount++; method.compiled = fn; + method.compiledLeaf = this.generateLeaf(method, + ((clsName || "M") + "_" + (sel || "m")).replace(/[^a-zA-Z0-9_]/g, "ː")) || null; + if (method.compiledLeaf) this.leafCount = (this.leafCount || 0) + 1; } else { this.bailCount++; + method.compiledLeaf = null; return this.fallback.compile(method, optClassObj, optSelObj); } }, @@ -62,6 +68,189 @@ Object.subclass('Squeak.Compiler2', }, }, 'generating', { + generateLeaf: function(method, funcName) { + // Forma "leaf" de un método: función JS pura que recibe (vm, rcvr, args...) + // y devuelve el resultado o Squeak.LEAF_DEOPT. Sin frame, sin zona, sin + // alocaciones (el overflow aritmético deoptimiza — el restart re-ejecuta + // el camino framed y ahí sí se aloca, exactamente una vez). Stores a inst + // vars permitidos solo si no hay puntos de deopt posteriores (restart-safe: + // los temps son locals JS, invisibles). + if (method.methodPrimitiveIndex() !== 0) return null; + var bytes = method.bytes; + var numArgs = method.methodNumArgs(); + var tempCount = method.methodTempCount(); + var src = []; + var depth = 0, maxDepth = 0; + var pc = 0, endPC = 0; + var labelDepth = {}, emitted = {}; + var knownDepth = true; + var instStoreSeen = false, deoptSeen = false; + var MIN = Squeak.MinSmallInt, MAX = Squeak.MaxSmallInt; + function push(expr) { src.push("v" + depth + " = " + expr + ";\n"); depth++; if (depth > maxDepth) maxDepth = depth; } + function pop() { depth--; return "v" + depth; } + function top() { return "v" + (depth - 1); } + function deopt() { deoptSeen = true; if (instStoreSeen) throw { bail: true }; return "return Squeak.LEAF_DEOPT;\n"; } + function jumpTo(dest, atDepth) { + if (dest <= pc) throw { bail: true }; // sin loops: trabajo acotado, sin interrupt checks + if (labelDepth[dest] === undefined) labelDepth[dest] = atDepth; + else if (labelDepth[dest] !== atDepth) throw { bail: true }; + if (dest > endPC) endPC = dest; + } + try { + var done = false; + while (!done) { + var instStart = pc; + if (!knownDepth) { + if (labelDepth[instStart] === undefined) throw { bail: true }; + depth = labelDepth[instStart]; + knownDepth = true; + } else if (labelDepth[instStart] !== undefined && labelDepth[instStart] !== depth) { + throw { bail: true }; + } + if (labelDepth[instStart] !== undefined) src.push("case " + instStart + ":\n"); + emitted[instStart] = depth; + var b = bytes[pc++], b2; + switch (b & 0xF8) { + case 0x00: case 0x08: push("inst[" + (b & 0xF) + "]"); break; + case 0x10: case 0x18: push("t" + (b & 0xF)); break; + case 0x20: case 0x28: case 0x30: case 0x38: push("lit[" + (1 + (b & 0x1F)) + "]"); break; + case 0x40: case 0x48: case 0x50: case 0x58: push("lit[" + (1 + (b & 0x1F)) + "].pointers[1]"); break; + case 0x60: // storeAndPop inst (receiver garantizado no-casado por el gate del call site) + if (deoptSeen) throw { bail: true }; // conservador: store tras posible deopt previo re-ejecutable? el restart re-ejecuta TODO: stores previos a deopts posteriores son el problema; deopts previos ya retornaron. Permitir. + instStoreSeen = true; + src.push("inst[" + (b & 7) + "] = " + pop() + "; rcvr.dirty = true;\n"); break; + case 0x68: src.push("t" + (b & 7) + " = " + pop() + ";\n"); break; + case 0x70: + switch (b) { + case 0x70: push("rcvr"); break; + case 0x71: push("vm.trueObj"); break; + case 0x72: push("vm.falseObj"); break; + case 0x73: push("vm.nilObj"); break; + case 0x74: push("-1"); break; + case 0x75: push("0"); break; + case 0x76: push("1"); break; + case 0x77: push("2"); break; + } + break; + case 0x78: + switch (b) { + case 0x78: src.push("return rcvr;\n"); break; + case 0x79: src.push("return vm.trueObj;\n"); break; + case 0x7A: src.push("return vm.falseObj;\n"); break; + case 0x7B: src.push("return vm.nilObj;\n"); break; + case 0x7C: src.push("return " + pop() + ";\n"); break; + default: throw { bail: true }; + } + knownDepth = false; + done = pc > endPC; + break; + case 0x80: case 0x88: + switch (b) { + case 0x80: + b2 = bytes[pc++]; + switch (b2 >> 6) { + case 0: push("inst[" + (b2 & 0x3F) + "]"); break; + case 1: push("t" + (b2 & 0x3F)); break; + case 2: push("lit[" + (1 + (b2 & 0x3F)) + "]"); break; + case 3: push("lit[" + (1 + (b2 & 0x3F)) + "].pointers[1]"); break; + } + break; + case 0x81: + b2 = bytes[pc++]; + if ((b2 >> 6) === 1) { src.push("t" + (b2 & 0x3F) + " = " + top() + ";\n"); break; } + if ((b2 >> 6) === 0) { instStoreSeen = true; src.push("inst[" + (b2 & 0x3F) + "] = " + top() + "; rcvr.dirty = true;\n"); break; } + throw { bail: true }; + case 0x82: + b2 = bytes[pc++]; + if ((b2 >> 6) === 1) { src.push("t" + (b2 & 0x3F) + " = " + pop() + ";\n"); break; } + if ((b2 >> 6) === 0) { instStoreSeen = true; src.push("inst[" + (b2 & 0x3F) + "] = " + pop() + "; rcvr.dirty = true;\n"); break; } + throw { bail: true }; + case 0x87: depth--; break; + case 0x88: push(top()); break; + default: throw { bail: true }; + } + break; + case 0x90: { var d = (b & 7) + 1; jumpTo(pc + d, depth); src.push("{ pcx = " + (pc + d) + "; continue; }\n"); knownDepth = false; done = pc > endPC; break; } + case 0x98: { var d = (b & 7) + 1, dest = pc + d, c = pop(); + jumpTo(dest, depth); + src.push("if (" + c + " === vm.falseObj) { pcx = " + dest + "; continue; }\n"); + src.push("else if (" + c + " !== vm.trueObj) " + deopt()); + break; } + case 0xA0: { b2 = bytes[pc++]; var d = ((b & 7) - 4) * 256 + b2; jumpTo(pc + d, depth); src.push("{ pcx = " + (pc + d) + "; continue; }\n"); knownDepth = false; done = pc > endPC; break; } + case 0xA8: { b2 = bytes[pc++]; var d = (b & 3) * 256 + b2, dest = pc + d, c = pop(), ifTrue = b < 0xAC; + jumpTo(dest, depth); + src.push("if (" + c + " === vm." + ifTrue + "Obj) { pcx = " + dest + "; continue; }\n"); + src.push("else if (" + c + " !== vm." + !ifTrue + "Obj) " + deopt()); + break; } + case 0xB0: case 0xB8: { // aritmética: smallint-in smallint-out, si no deopt + var op = b & 0xF, bb = pop(), aa = top(); + var guard = "if (typeof " + aa + " !== 'number' || typeof " + bb + " !== 'number') " + deopt(); + switch (op) { + case 0x0: case 0x1: { + var o = op === 0 ? "+" : "-"; + src.push(guard); + src.push("var r" + depth + " = " + aa + " " + o + " " + bb + "; if (r" + depth + " < " + MIN + " || r" + depth + " > " + MAX + ") " + deopt()); + src.push(aa + " = r" + depth + ";\n"); + break; + } + case 0x2: case 0x3: case 0x4: case 0x5: { + var cmp = ["<", ">", "<=", ">="][op - 2]; + src.push(guard); + src.push(aa + " = " + aa + " " + cmp + " " + bb + " ? vm.trueObj : vm.falseObj;\n"); + break; + } + case 0x6: case 0x7: { + src.push(guard); + src.push(aa + " = " + aa + " " + (op === 6 ? "===" : "!==") + " " + bb + " ? vm.trueObj : vm.falseObj;\n"); + break; + } + case 0xE: case 0xF: { + src.push(guard); + src.push(aa + " = " + aa + " " + (op === 0xE ? "&" : "|") + " " + bb + ";\n"); + break; + } + default: throw { bail: true }; + } + break; + } + case 0xC0: case 0xC8: { + var lo = b & 0xF; + if (lo === 0x6) { var b6 = pop(), a6 = top(); src.push(a6 + " = " + a6 + " === " + b6 + " ? vm.trueObj : vm.falseObj;\n"); break; } + if (lo === 0x7) { var t7 = top(); src.push(t7 + " = typeof " + t7 + " === 'number' ? vm.specialObjects[5] : " + t7 + ".sqClass;\n"); break; } + throw { bail: true }; + } + default: throw { bail: true }; // sends reales, closures, thisContext: no-leaf + } + } + } catch (e) { + if (e && e.bail) return null; + throw e; + } + // ensamblar + var head = []; + var params = ["vm", "rcvr"]; + for (var i = 0; i < numArgs; i++) params.push("t" + i); + var decls = []; + for (var i = numArgs; i < tempCount; i++) decls.push("t" + i + " = vm.nilObj"); + for (var i = 0; i < maxDepth; i++) decls.push("v" + i); + head.push("'use strict';\nvar lit = arguments[0];\nreturn function LEAF_" + funcName + "(" + params.join(", ") + ") {\n"); + head.push("var inst = rcvr.pointers;\n"); + if (decls.length) head.push("var " + decls.join(", ") + ";\n"); + var hasLabels = Object.keys(labelDepth).length > 0; + var body; + if (hasLabels) { + body = "var pcx = 0;\nwhile (true) switch (pcx) {\ncase 0:\n" + src.join("") + "default: return Squeak.LEAF_DEOPT;\n}\n"; + } else { + body = src.join("") + "return Squeak.LEAF_DEOPT;\n"; + } + // lit se pasa via bind para no leer method.pointers por llamada + var full = head.join("") + body + "}"; + try { + return new Function(full)(method.pointers); // literales cerrados en la función + } catch (e) { + return null; + } + }, generate2: function(method, optClass, optSel) { try { return this.generateV3R(method, optClass, optSel); @@ -389,17 +578,33 @@ Object.subclass('Squeak.Compiler2', var ic = this.ics.length; this.ics.push({ c: null, m: null, a: 0, p: 0, k: null }); var rcvrSlot = this.slotAt(argCount); - this.spillAll(); + var argSlots = []; + for (var i = argCount - 1; i >= 0; i--) argSlots.push(this.slotAt(i)); this.emit("vm.pc = ", this.pc, ";\n"); this.emit("var ic = ics[", ic, "], rc = typeof ", rcvrSlot, " === 'number' ? vm.smallIntClass_ : ", rcvrSlot, ".sqClass;\n"); this.emit("if (rc !== ic.c) vm.jit2FillIC(ic, lit[", litIndex, "], ", argCount, ", rc);\n"); - // receivers que son contexts casados: refrescar snapshot (mismo gate que sendZ) + // leaf fast path: sin spill, sin frame, args como argumentos JS. Solo con + // interruptCheckCounter > 0 (paridad exacta de cadencia con el golden: + // vencido => camino framed, donde executeNewMethod chequea como siempre) + this.emit("var lr = vm.LEAF_DEOPT_;\n"); + this.emit("if (ic.a === ", argCount, " && ic.m.compiledLeaf != null && rc !== vm.contextClass_ && vm.interruptCheckCounter > 0) {\n"); + this.emit(" var sc = vm.sendCount++; vm.interruptCheckCounter--;\n"); + this.emit(" lr = ic.m.compiledLeaf(vm, ", [rcvrSlot].concat(argSlots).join(", "), ");\n"); + this.emit(" if (lr === vm.LEAF_DEOPT_) { vm.sendCount--; vm.interruptCheckCounter++; vm.nLeafDeopts++; }\n"); + this.emit(" else { vm.nLeafCalls++; if (vm.jit2LeafHook) vm.jit2LeafHook(ic.m, sc, ", rcvrSlot, ", lit[", litIndex, "]); }\n"); + this.emit("}\n"); + var resultSlot = "s" + (this.depth - argCount - 1); + this.emit("if (lr !== vm.LEAF_DEOPT_) { ", resultSlot, " = lr; }\n"); + this.emit("else {\n"); + this.spillAll(); this.emit("if (rc === vm.contextClass_ && ", rcvrSlot, ".frame != null) vm.syncMarriedContext(", rcvrSlot, ");\n"); this.emit("if (ic.p) { vm.verifyAtSelector = lit[", litIndex, "]; vm.verifyAtClass = rc; }\n"); this.emit("vm.executeNewMethod(", rcvrSlot, ", ic.m, ic.a, ic.p, ic.k, lit[", litIndex, "]);\n"); this.emit(this.activationCheck()); + this.emit(resultSlot, " = zone[vm.sp];\n"); + this.emit("}\n"); this.depth -= argCount + 1; - this.push("zone[vm.sp]"); // result (fast-path prim; resume reloads instead) + this.depth++; if (this.depth > this.maxDepth) this.maxDepth = this.depth; this.markResume(this.pc, this.depth); }, generateNumericOp: function(byte) { diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 4afd2164..6bd108e6 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -136,10 +136,15 @@ Object.extend(Squeak.Primitives.prototype, { // Driver síncrono + hash de traza // --------------------------------------------------------------------------- var hash = 2166136261 >>> 0; // FNV-1a +var mixLog = process.env.MIXLOG ? [] : null; function mix(v) { + if (mixLog) mixLog.push(v); hash = (hash ^ (v >>> 0)) >>> 0; hash = Math.imul(hash, 16777619) >>> 0; } +process.on("exit", function() { + if (mixLog) require("fs").writeFileSync(process.env.MIXLOG, mixLog.join("\n") + "\n"); +}); // La imagen escribe artefactos junto a sí misma (crea el .changes si falta, // logs de debug). Limpiarlos antes de correr para que toda corrida parta del @@ -219,7 +224,9 @@ image.readFromBuffer(data.buffer, function startRunning() { var z7id = parseInt(process.env.ZDBG7_ID), z7From = parseInt(process.env.ZDBG7_FROM || "0"); var chainDesc = function() { var desc = []; - if (vm.useStackZone) { + if (vm.useStackZone) + console.log("leafCalls=" + vm.nLeafCalls + " deopts=" + vm.nLeafDeopts + " hookFires=" + (vm.jit2HookFires||0)); + if (vm.useStackZone) { var page = vm.zonePage, fp = vm.fp, hops = 0; while (fp >= 0 && hops++ < 20) { var m = page.slots[fp + Squeak.Frame_method]; @@ -354,6 +361,30 @@ image.readFromBuffer(data.buffer, function startRunning() { // Muestreo en checkpoints fijos de sendCount: independiente de la // representación (contexts/frames) Y de la cadencia de interrupciones // (que difiere con/sin jit). Es la señal que entra al hash. + var traceIdOf = function(m) { + if (m._traceId === undefined) { + var fpv = m.bytes ? m.bytes.length : 0; + if (m.bytes) for (var bi = 0; bi < Math.min(m.bytes.length, 16); bi++) + fpv = ((fpv * 31) + m.bytes[bi]) | 0; + m._traceId = fpv; + } + return m._traceId; + }; + // leaf-sends: mismo muestreo que executeNewMethod (sc = sendCount pre-incremento) + vm.jit2HookFires = 0; + vm.jit2LeafHook = function(method, sc, rcvr, sel) { + vm.jit2HookFires++; + if (sc < maxSends && (sc & 4095) === 0) { + mix(sc); + mix(traceIdOf(method)); + mix(vm.pc); + if (logLines) logLines.push("s=" + sc + " h=" + traceIdOf(method) + " pc=" + vm.pc); + } + if (logLines && sc >= logFrom && sc <= logTo) + logLines.push("S=" + sc + " h=" + traceIdOf(method) + " pc=" + vm.pc + " args=? prim=0" + + " cm=" + (vm.method ? traceIdOf(vm.method) : "?") + + " rcls=" + vm.getClass(rcvr).className() + " sel=" + (sel && sel.bytesAsString ? sel.bytesAsString() : "?") + " [leaf]"); + }; var origENM = vm.executeNewMethod; vm.executeNewMethod = function(newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel) { // método identificado por fingerprint de contenido (los oops temporales @@ -440,6 +471,8 @@ image.readFromBuffer(data.buffer, function startRunning() { console.log("bench: " + vm.sendCount + " sends en " + wallMs.toFixed(0) + " ms (" + (vm.sendCount / (wallMs / 1000) / 1e6).toFixed(2) + "M sends/s), stop: " + stopReason); if (vm.useStackZone) { + console.log("leaf calls=" + vm.nLeafCalls + " deopts=" + vm.nLeafDeopts + + " (de " + vm.sendCount + " sends)"); var live = 0, maxSlots = 0; for (var i = 0; i < vm.zonePages.length; i++) { if (vm.zonePages[i].live) live++; @@ -454,7 +487,8 @@ image.readFromBuffer(data.buffer, function startRunning() { } if (vm.compiler && vm.compiler.okCount !== undefined) - console.log("jit2: ok=" + vm.compiler.okCount + " bail=" + vm.compiler.bailCount); + console.log("jit2: ok=" + vm.compiler.okCount + " bail=" + vm.compiler.bailCount + + " leaves=" + (vm.compiler.leafCount || 0)); if (vm.useStackZone) console.log("married=" + (vm.nMarriedContexts||0) + " byClosure=" + (vm.nMarryClosure||0) + " byThisCtx=" + (vm.nMarryThisCtx||0) + " bySenderFill=" + (vm.nMarrySenderFill||0)); diff --git a/vm.interpreter.js b/vm.interpreter.js index aeb8048f..300422cb 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -63,6 +63,10 @@ Object.subclass('Squeak.Interpreter', this.tempOffset = Squeak.Context_tempFrameStart; // index of temp 0 in this.temps // stack-zone state, declared here for stable object shape even when unused this.jsDepth = 0; // profundidad de llamadas JS directas (modo frames) + this.LEAF_DEOPT_ = null; + this.jit2LeafHook = null; + this.nLeafCalls = 0; + this.nLeafDeopts = 0; this.useStackZone = false; this.zonePages = null; this.zonePage = null; diff --git a/vm.stackzone.js b/vm.stackzone.js index 1a76d0eb..296740d5 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -50,6 +50,7 @@ Object.extend(Squeak.Interpreter.prototype, // megamórficos sobre receivers arbitrarios (medido: 24% del tiempo) this.contextClass_ = this.specialObjects[Squeak.splOb_ClassMethodContext]; this.smallIntClass_ = this.specialObjects[Squeak.splOb_ClassInteger]; + this.LEAF_DEOPT_ = Squeak.LEAF_DEOPT; var vm = this; // rebind execution methods to the frames variants this.send = this.sendZ; From 2908dd58de7b4063484078a3da4b64d00116bddd Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:48:19 -0300 Subject: [PATCH 15/22] [perf] harness: leaf call counters in jit2 report line Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 6bd108e6..8f5ce88e 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -224,9 +224,7 @@ image.readFromBuffer(data.buffer, function startRunning() { var z7id = parseInt(process.env.ZDBG7_ID), z7From = parseInt(process.env.ZDBG7_FROM || "0"); var chainDesc = function() { var desc = []; - if (vm.useStackZone) - console.log("leafCalls=" + vm.nLeafCalls + " deopts=" + vm.nLeafDeopts + " hookFires=" + (vm.jit2HookFires||0)); - if (vm.useStackZone) { + if (vm.useStackZone) { var page = vm.zonePage, fp = vm.fp, hops = 0; while (fp >= 0 && hops++ < 20) { var m = page.slots[fp + Squeak.Frame_method]; @@ -488,7 +486,8 @@ image.readFromBuffer(data.buffer, function startRunning() { if (vm.compiler && vm.compiler.okCount !== undefined) console.log("jit2: ok=" + vm.compiler.okCount + " bail=" + vm.compiler.bailCount - + " leaves=" + (vm.compiler.leafCount || 0)); + + " leaves=" + (vm.compiler.leafCount || 0) + + " leafCalls=" + vm.nLeafCalls + " deopts=" + vm.nLeafDeopts); if (vm.useStackZone) console.log("married=" + (vm.nMarriedContexts||0) + " byClosure=" + (vm.nMarryClosure||0) + " byThisCtx=" + (vm.nMarryThisCtx||0) + " bySenderFill=" + (vm.nMarrySenderFill||0)); From 16b8e5de1c619638c112886d6e3d92d22b6a8220 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:01:39 -0300 Subject: [PATCH 16/22] [perf] cache resolved named primitives on the method (frames mode) Every named-primitive call (prim 117) was rebuilding two JS strings from Smalltalk bytes (module + function name), looking the module up in a dictionary and doing a string-keyed function lookup. tryPrimitiveZ now resolves once and caches a bound wrapper on the CompiledMethod (method.primFn), preserving the namedPrimitive protocol exactly (success flag reset, interpreterProxy.argCount/primitiveName, primMethod, boolean-vs-success return, unbalance warning). Missing modules/functions keep the slow path so warnings behave identically. FloatArrayPlugin is denylisted: cached, it diverges the trace when combined with any other cached plugin (undiagnosed global-state interaction, possibly the at-cache); its slow path is correct. Bisection tooling: PRIMFN_SKIP=mod1,mod2 or * disables caching. Golden-identical on Cuis startup and Dialogo. Bench (20M sends): 3.22M sends/s vs 2.75M contexts baseline (+17%, best so far). Co-Authored-By: Claude Fable 5 --- vm.stackzone.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/vm.stackzone.js b/vm.stackzone.js index 296740d5..831dccae 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -604,6 +604,26 @@ Object.extend(Squeak.Interpreter.prototype, return this.marryFrame(this.zonePage, this.fp); }, tryPrimitiveZ: function(primIndex, argCount, newMethod) { + if (primIndex === 117) { + // prim con nombre: cachear la función resuelta en el método + // (el camino normal reconstruye los strings de módulo/función y + // hace lookups por nombre EN CADA llamada) + var fn = newMethod.primFn; + if (fn === undefined) fn = this.primHandler.resolveNamedPrim(newMethod); + if (fn !== null) { + var sp117 = this.sp; + var page117 = this.zonePage, fp117 = this.fp; + var ok = fn(argCount); + if (ok + && this.sp !== sp117 - argCount + && page117 === this.zonePage && fp117 === this.fp + && !this.frozen) { + this.warnOnce("stack unbalanced after primitive 117/cached", "error"); + } + return ok; + } + // sin resolver (módulo/función faltante): camino lento con warnings + } if ((primIndex > 255) && (primIndex < 520)) { if (primIndex >= 264) {//return instvars this.popNandPush(1, this.top().pointers[primIndex - 264]); @@ -638,6 +658,45 @@ Object.extend(Squeak.Interpreter.prototype, Object.extend(Squeak.Primitives.prototype, 'stack zone', { + resolveNamedPrim: function(method) { + // resolver módulo+función del prim 117 una sola vez y cachear en el + // método un wrapper que replica el protocolo de namedPrimitive + method.primFn = null; + if (method.pointersSize() < 2) return null; + var firstLiteral = method.pointers[1]; + if (firstLiteral.pointersSize() !== 4) return null; + var moduleName = firstLiteral.pointers[0].bytesAsString(); + var functionName = firstLiteral.pointers[1].bytesAsString(); + // denylist: FloatArrayPlugin cacheado diverge en combinación con otros + // plugins cacheados (interacción con estado global aún sin diagnosticar, + // posiblemente el at-cache); su camino lento es correcto + if (moduleName === "FloatArrayPlugin") return null; + if (typeof process !== "undefined" && process.env && process.env.PRIMFN_SKIP + && (process.env.PRIMFN_SKIP === "*" || process.env.PRIMFN_SKIP.split(",").indexOf(moduleName) >= 0)) return null; + var mod = moduleName === "" ? this : this.loadedModules[moduleName]; + if (mod === undefined) { + mod = this.loadModule(moduleName); + this.loadedModules[moduleName] = mod; + } + if (!mod) return null; + var primitive = mod[functionName]; + var ph = this, proxy = this.interpreterProxy; + var inner; + if (typeof primitive === "function") inner = primitive.bind(mod); + else if (typeof primitive === "string") inner = this[primitive].bind(this); + else return null; // missing: que el camino lento warnee + var wrapper = function(argCount) { + ph.success = true; + proxy.argCount = argCount; + proxy.primitiveName = functionName; + ph.primMethod = method; + var r = inner(argCount); + if (r === true || r === false) return r; + return ph.success; + }; + method.primFn = wrapper; + return wrapper; + }, activateNewClosureMethodZ: function(blockClosure, argCount) { // old-style (V3) closures: startpc into the home method var vm = this.vm; From 76340823fe51a00e9e6f2ec7f90f4cf24bd8af3d Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:16:31 -0300 Subject: [PATCH 17/22] [perf] primFn: fix stale success flag poisoning module loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadModule checks interpreterProxy.failed() after initialiseModule, which reads the AMBIENT primHandler.success flag. The original 117 path always runs after doPrimitive resets success=true on entry, but resolveNamedPrim resolves before that point, so a stale success=false from a previously failed primitive made the module load report "initialization failed", get cached as null, and every named prim of that module fail forever (Smalltalk fallbacks ran instead — correct but trace-divergent, and slower). This also explains the phantom "interaction between cached plugins": which module hit the stale flag depended on which loads shifted to resolve time. Fix: reset success before loadModule in resolveNamedPrim. The FloatArrayPlugin denylist is removed — it was just the first victim. Golden-identical with all plugins cached; Dialogo identical. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 25 +++++++++++++++++++++++++ vm.stackzone.js | 10 ++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 8f5ce88e..92b7c66d 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -219,6 +219,31 @@ image.readFromBuffer(data.buffer, function startRunning() { return origENM9.call(vm, newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel); }; } + if (process.env.PFNDBG) vm.primFnDebug = true; + if (process.env.FADBG) { + // loguear llamadas a FloatArrayPlugin.primitiveAt: (sc, sp-antes, success-después, sp-después) + var faCount = 0; + var patchFA = function(mod) { + if (!mod || mod._faPatched) return !!mod; + mod._faPatched = true; + var orig = mod.primitiveAt; + mod.primitiveAt = function(argCount) { + var spBefore = vm.sp; + var r = orig.call(this, argCount); + if (++faCount <= 40) + console.error("FA#" + faCount + " s=" + vm.sendCount + " spB=" + spBefore + + " r=" + r + " succ=" + vm.primHandler.success + " spA=" + vm.sp); + return r; + }; + return true; + }; + var origLMD = vm.primHandler.loadModule.bind(vm.primHandler); + vm.primHandler.loadModule = function(name) { + var m = origLMD(name); + if (name === "FloatArrayPlugin") patchFA(m); + return m; + }; + } if (process.env.ZDBG7) { // dump de cadena al activar un método específico (por _traceId) var z7id = parseInt(process.env.ZDBG7_ID), z7From = parseInt(process.env.ZDBG7_FROM || "0"); diff --git a/vm.stackzone.js b/vm.stackzone.js index 831dccae..7d5f6228 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -659,6 +659,7 @@ Object.extend(Squeak.Interpreter.prototype, Object.extend(Squeak.Primitives.prototype, 'stack zone', { resolveNamedPrim: function(method) { + if (this.vm.primFnDebug) console.error("RESOLVE intento"); // resolver módulo+función del prim 117 una sola vez y cachear en el // método un wrapper que replica el protocolo de namedPrimitive method.primFn = null; @@ -667,14 +668,15 @@ Object.extend(Squeak.Primitives.prototype, if (firstLiteral.pointersSize() !== 4) return null; var moduleName = firstLiteral.pointers[0].bytesAsString(); var functionName = firstLiteral.pointers[1].bytesAsString(); - // denylist: FloatArrayPlugin cacheado diverge en combinación con otros - // plugins cacheados (interacción con estado global aún sin diagnosticar, - // posiblemente el at-cache); su camino lento es correcto - if (moduleName === "FloatArrayPlugin") return null; if (typeof process !== "undefined" && process.env && process.env.PRIMFN_SKIP && (process.env.PRIMFN_SKIP === "*" || process.env.PRIMFN_SKIP.split(",").indexOf(moduleName) >= 0)) return null; var mod = moduleName === "" ? this : this.loadedModules[moduleName]; if (mod === undefined) { + // loadModule chequea interpreterProxy.failed() tras initialiseModule, + // que lee el flag `success` AMBIENTE — en el camino original doPrimitive + // lo resetea al entrar; acá resolvemos antes de ese punto, así que un + // success=false stale del primitivo anterior hacía "fallar" la carga + this.success = true; mod = this.loadModule(moduleName); this.loadedModules[moduleName] = mod; } From 0b57fee81e2a7b7fcf307a66cf9f59a1cd4709dc Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:57:08 -0300 Subject: [PATCH 18/22] =?UTF-8?q?[perf]=20jit2:=20recover=20bails=20?= =?UTF-8?q?=E2=80=94=20super=20sends,=20closure=20temps,=20remote=20vecs,?= =?UTF-8?q?=20quick=20prims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage jumps from 1370/485 to 1779/76 (96%) on the Cuis startup: - super sends (0x85, 0x84-op1) with a statically-filled IC (the lookup class is fixed per site: method class's superclass), plus 0x86 second extended sends. Gotcha fixed: verifyAtClass for super sends must be the lookup class, not the receiver class — using rc poisoned the at:-cache cacheability test (makeAtCacheInfo) for super at: sends - closure indirection temps (0x8A) built from mapped locals - remote temp vector push/store/storePop (0x8C-0x8E) - generic quick prims next/atEnd/new/x/y/nextPut:/new: as direct full sends (quickSendOther always fails for these) - JIT2NO=feature,... bisection gates for future debugging Golden-identical; Dialogo identical. Co-Authored-By: Claude Fable 5 --- jit2.js | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/jit2.js b/jit2.js index 4383be9d..35d0bac3 100644 --- a/jit2.js +++ b/jit2.js @@ -259,6 +259,10 @@ Object.subclass('Squeak.Compiler2', throw e; } }, + envNo: function(tag) { + return typeof process !== "undefined" && process.env && process.env.JIT2NO + && process.env.JIT2NO.split(",").indexOf(tag) >= 0; + }, bail: function(why) { throw { bail: true, why: why }; }, @@ -511,12 +515,23 @@ Object.subclass('Squeak.Compiler2', b3 = this.method.bytes[this.pc++]; switch (b2 >> 5) { case 0: this.generateSend(1 + b3, b2 & 31); break; + case 1: if (this.envNo("super")) this.bail("super"); this.generateSend(1 + b3, b2 & 31, true); break; case 2: this.push("inst[" + b3 + "]"); break; case 3: this.push("lit[" + (1 + b3) + "]"); break; case 4: this.push("lit[" + (1 + b3) + "].pointers[1]"); break; default: this.bail("doubleExtended op " + (b2 >> 5)); } break; + case 0x85: // single extended send to super + if (this.envNo("super")) this.bail("super"); + b2 = this.method.bytes[this.pc++]; + this.generateSend(1 + (b2 & 31), b2 >> 5, true); + break; + case 0x86: // second extended send + if (this.envNo("send2")) this.bail("send2"); + b2 = this.method.bytes[this.pc++]; + this.generateSend(1 + (b2 & 0x3F), b2 >> 6); + break; case 0x87: // pop this.depth--; break; case 0x88: // dup @@ -525,10 +540,44 @@ Object.subclass('Squeak.Compiler2', this.spillAll(); this.push("vm.exportThisContext()"); break; + case 0x8A: { // closure temps (array de indirección) + if (this.envNo("ctemps")) this.bail("ctemps"); + b2 = this.method.bytes[this.pc++]; + var popValues = b2 > 127, count = b2 & 127; + this.emit("var arr = vm.instantiateClass(vm.specialObjects[7], ", count, ");\n"); + if (popValues) { + for (var ci = 0; ci < count; ci++) + this.emit("arr.pointers[", ci, "] = ", this.slotAt(count - ci - 1), ";\n"); + this.depth -= count; + } + this.push("arr"); + break; + } + case 0x8C: + if (this.envNo("vec")) this.bail("vec"); + // remote push from temp vector + b2 = this.method.bytes[this.pc++]; + b3 = this.method.bytes[this.pc++]; + this.push("zone[tB + " + b3 + "].pointers[" + b2 + "]"); + break; + case 0x8D: { // remote store into temp vector + if (this.envNo("vec")) this.bail("vec"); + b2 = this.method.bytes[this.pc++]; + b3 = this.method.bytes[this.pc++]; + this.emit("var tv = zone[tB + ", b3, "]; tv.pointers[", b2, "] = ", this.top(), "; tv.dirty = true;\n"); + break; + } + case 0x8E: { // remote store and pop + if (this.envNo("vec")) this.bail("vec"); + b2 = this.method.bytes[this.pc++]; + b3 = this.method.bytes[this.pc++]; + this.emit("var tv = zone[tB + ", b3, "]; tv.pointers[", b2, "] = ", this.pop(), "; tv.dirty = true;\n"); + break; + } case 0x8F: // pushClosureCopy this.generateClosureCopy(); break; default: - this.bail("extended " + byte); // 0x85/0x86 super, 0x8A-0x8E: jit1 + this.bail("extended " + byte); // 0x8B callPrimitive: jit1 } }, generateStoreInst: function(index, popIt) { @@ -574,7 +623,7 @@ Object.subclass('Squeak.Compiler2', this.markJumpTarget(dest); this.markResume(this.pc, this.depth); }, - generateSend: function(litIndex, argCount) { + generateSend: function(litIndex, argCount, superFlag) { var ic = this.ics.length; this.ics.push({ c: null, m: null, a: 0, p: 0, k: null }); var rcvrSlot = this.slotAt(argCount); @@ -582,7 +631,13 @@ Object.subclass('Squeak.Compiler2', for (var i = argCount - 1; i >= 0; i--) argSlots.push(this.slotAt(i)); this.emit("vm.pc = ", this.pc, ";\n"); this.emit("var ic = ics[", ic, "], rc = typeof ", rcvrSlot, " === 'number' ? vm.smallIntClass_ : ", rcvrSlot, ".sqClass;\n"); - this.emit("if (rc !== ic.c) vm.jit2FillIC(ic, lit[", litIndex, "], ", argCount, ", rc);\n"); + if (superFlag) { + // super: la clase de lookup es estática (superclase de la clase del + // método) — el IC se llena una sola vez y vale para todo receiver + this.emit("if (ic.c === null) vm.jit2FillSuperIC(ic, lit[", litIndex, "], ", argCount, ");\n"); + } else { + this.emit("if (rc !== ic.c) vm.jit2FillIC(ic, lit[", litIndex, "], ", argCount, ", rc);\n"); + } // leaf fast path: sin spill, sin frame, args como argumentos JS. Solo con // interruptCheckCounter > 0 (paridad exacta de cadencia con el golden: // vencido => camino framed, donde executeNewMethod chequea como siempre) @@ -598,7 +653,7 @@ Object.subclass('Squeak.Compiler2', this.emit("else {\n"); this.spillAll(); this.emit("if (rc === vm.contextClass_ && ", rcvrSlot, ".frame != null) vm.syncMarriedContext(", rcvrSlot, ");\n"); - this.emit("if (ic.p) { vm.verifyAtSelector = lit[", litIndex, "]; vm.verifyAtClass = rc; }\n"); + this.emit("if (ic.p) { vm.verifyAtSelector = lit[", litIndex, "]; vm.verifyAtClass = ", superFlag ? "ic.c" : "rc", "; }\n"); this.emit("vm.executeNewMethod(", rcvrSlot, ", ic.m, ic.a, ic.p, ic.k, lit[", litIndex, "]);\n"); this.emit(this.activationCheck()); this.emit(resultSlot, " = zone[vm.sp];\n"); @@ -741,7 +796,24 @@ Object.subclass('Squeak.Compiler2', // tras el return el resultado queda en zone: el resume lo recarga return; } - default: // next nextPut: atEnd blockCopy: new new: x y -> jit1 + case 0x3: case 0x5: case 0xC: case 0xE: case 0xF: { // next atEnd new x y (0 args) + if (this.envNo("qp0")) this.bail("qp0"); + this.spillAll(); // receiver ya está en el stack virtual + this.emit("vm.primHandler.success = true; vm.pc = ", this.pc, "; vm.sendSpecial(", lo + 16, "); ", this.activationCheck()); + this.emit(this.top(), " = zone[vm.sp];\n"); + this.markResume(this.pc, this.depth); + return; + } + case 0x4: case 0xD: { // nextPut: new: (1 arg) + if (this.envNo("qp1")) this.bail("qp1"); + this.spillAll(); + this.emit("vm.primHandler.success = true; vm.pc = ", this.pc, "; vm.sendSpecial(", lo + 16, "); ", this.activationCheck()); + this.depth--; + this.emit(this.top(), " = zone[vm.sp];\n"); + this.markResume(this.pc, this.depth); + return; + } + default: // blockCopy: do: -> jit1 this.bail("quick prim " + lo); } }, @@ -777,6 +849,11 @@ Object.subclass('Squeak.Compiler2', // vm-side support Object.extend(Squeak.Interpreter.prototype, 'jit2 support', { + jit2FillSuperIC: function(ic, selector, argCount) { + // super: lookup estático en la superclase de la clase del método activo + var lookupClass = this.method.methodClassForSuper().superclass(); + this.jit2FillIC(ic, selector, argCount, lookupClass); + }, jit2FillIC: function(ic, selector, argCount, lookupClass) { var entry = this.findSelectorInClass(selector, argCount, lookupClass); ic.c = lookupClass; From df2f6ddaf2666aa6dd8c492f7de6e4c3710681c2 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:01:18 -0300 Subject: [PATCH 19/22] [perf] uniform object shapes from birth in per-class constructors The generated per-class constructors now predeclare oop/hash/dirty/ mark/nextObject, so lazily-added properties (dirty on first store, mark/nextObject during GC) no longer fork hidden classes per object. Covers freshly instantiated objects and image-loaded ones (rebuilt via renameFromImage's `new instProto`). Small win (~1%); also records the negative result of the flat-single-shape experiment (~7% slower). Co-Authored-By: Claude Fable 5 --- vm.object.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vm.object.js b/vm.object.js index add14611..cca04567 100644 --- a/vm.object.js +++ b/vm.object.js @@ -482,6 +482,10 @@ Object.subclass('Squeak.Object', classInstProto: function(className) { if (this.instProto) return this.instProto; var proto = this.defaultInst(); // in case below fails + // Nota de experimento (2026-07-11): una única shape plana para todos los + // objetos (en vez de constructores por clase) midió ~7% MÁS LENTO en el + // bench frames+jit2 — los maps por clase + ICs polimórficos de V8 ya + // rinden bien; no repetir sin nueva evidencia. try { if (!className) className = this.className(); var safeName = className.replace(/[^A-Za-z0-9]/g,'_'); @@ -490,7 +494,7 @@ Object.subclass('Squeak.Object', else if (safeName === "False") safeName = "false_"; else safeName = ((/^[AEIOU]/.test(safeName)) ? 'an' : 'a') + safeName; // fail okay if no eval() - proto = new Function("return function " + safeName + "() {};")(); + proto = new Function("return function " + safeName + "() { this.oop = 0; this.hash = 0; this.dirty = false; this.mark = false; this.nextObject = null; };")(); proto.prototype = this.defaultInst().prototype; } catch(e) {} Object.defineProperty(this, 'instProto', { value: proto }); From 50207d4a1530326de2af24860325c26d6966f98f Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:06:11 -0300 Subject: [PATCH 20/22] [perf] page-granular flush for married-context stores + page eviction Dialogo's UI loop does ~23k sender-stores into married contexts per 10M sends (terminate-style chain surgery); each one flushed EVERY live page. storeToMarriedContext now flushes only the owning page. That exposed a page leak the full flush had been masking: pages of terminated processes stay live-but-unreachable (termination cuts the chains at the context level, never touching the page), growing the zone to 23k pages and making allocPage scans O(n). Fix: when no dead page exists and the zone holds 32+ pages, allocPage evicts a suspended page by flushing it (always correct: resuming re-inflates via makeBaseFrame) and reuses it. Zone now capped at 32 pages on Dialogo. Golden and Dialogo traces identical. Dialogo bench 3.05M sends/s (2.99M before; 2.16M with the leak). PAGEDBG/SMCDBG harness counters. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 6 +++++ vm.stackzone.js | 49 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 92b7c66d..dbe9ab2b 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -220,6 +220,8 @@ image.readFromBuffer(data.buffer, function startRunning() { }; } if (process.env.PFNDBG) vm.primFnDebug = true; + if (process.env.PAGEDBG) { vm.pageStats = {fresh:0, reused:0, flushActive:0, flushSusp:0, flushDead:0}; process.on("exit", function(){ console.error("PAGES:", JSON.stringify(vm.pageStats)); }); } + if (process.env.SMCDBG) { vm.smcStats = {}; process.on("exit", function() { console.error("SMC stores por índice:", JSON.stringify(vm.smcStats)); }); } if (process.env.FADBG) { // loguear llamadas a FloatArrayPlugin.primitiveAt: (sc, sp-antes, success-después, sp-después) var faCount = 0; @@ -501,6 +503,10 @@ image.readFromBuffer(data.buffer, function startRunning() { if (vm.zonePages[i].live) live++; if (vm.zonePages[i].slots.length > maxSlots) maxSlots = vm.zonePages[i].slots.length; } + var vacias = 0; + for (var i = 0; i < vm.zonePages.length; i++) + if (vm.zonePages[i].live && vm.zonePages[i].fp < 0) vacias++; + console.log("pages live vacías (fp<0): " + vacias + " flushPage=" + (vm.nFlushPage || 0)); console.log("zona: pages=" + vm.zonePages.length + " live=" + live + " maxSlots=" + maxSlots + " married=" + (vm.nMarriedContexts || 0) + " flushAll=" + (vm.nFlushAll || 0) + " byClosure=" + (vm.nMarryClosure || 0) + " byThisCtx=" + (vm.nMarryThisCtx || 0) diff --git a/vm.stackzone.js b/vm.stackzone.js index 7d5f6228..08141ec4 100644 --- a/vm.stackzone.js +++ b/vm.stackzone.js @@ -112,10 +112,29 @@ Object.extend(Squeak.Interpreter.prototype, page.slots.length = 0; page.live = true; page.baseCallerCtx = null; + if (this.pageStats) this.pageStats.reused++; return page; } + if (this.zonePages.length >= 32) { + // sin muertas y con muchas vivas: páginas de procesos terminados + // quedan vivas-inalcanzables (el terminate corta cadenas a nivel + // context). Flushear una suspendida es siempre correcto (su resume + // re-infla via makeBaseFrame) y la vuelve reutilizable. + for (var i = 0; i < this.zonePages.length; i++) { + var victim = this.zonePages[i]; + if (victim.live && victim !== this.zonePage) { + this.flushPageAndContinue(victim); + victim.slots.length = 0; + victim.live = true; + victim.baseCallerCtx = null; + if (this.pageStats) this.pageStats.evicted = (this.pageStats.evicted || 0) + 1; + return victim; + } + } + } var fresh = { slots: [], fp: -1, sp: -1, pc: -1, live: true, baseCallerCtx: null }; this.zonePages.push(fresh); + if (this.pageStats) this.pageStats.fresh++; return fresh; }, activatePage: function(page) { @@ -241,10 +260,11 @@ Object.extend(Squeak.Interpreter.prototype, } }, storeToMarriedContext: function(ctx, index, value) { + if (this.smcStats) this.smcStats[index] = (this.smcStats[index] || 0) + 1; // Smalltalk escribe un campo de un context con frame vivo (terminate, - // unwind, debugger): serializar todo a contexts reales, seguir en un - // base frame fresco, y hacer la escritura sobre el context real - this.flushAllAndContinue(); + // unwind, debugger): serializar SU página a contexts reales, seguir en + // un base frame fresco si era la activa, y escribir sobre el context real + this.flushPageAndContinue(ctx.frame.page); ctx.pointers[index] = value; ctx.dirty = true; if (ctx.frame != null) { @@ -263,6 +283,29 @@ Object.extend(Squeak.Interpreter.prototype, ctx.frame = null; ctx.dirty = true; }, + flushPageAndContinue: function(page) { + // serializar SOLO una página a contexts reales (stores a contexts casados + // suelen ser cirugía de cadena en una página; flushear todas — Dialogo + // hacía 23k flushes totales por 10M sends — era el martillo grande) + this.nFlushPage = (this.nFlushPage || 0) + 1; + this.saveActivePage(); + var isActive = page === this.zonePage; + if (this.pageStats) { this.pageStats.flushActive += isActive ? 1 : 0; this.pageStats.flushSusp += isActive ? 0 : 1; if (!page.live) this.pageStats.flushDead++; } + var top = isActive ? this.marryFrame(page, this.fp) : null; + if (!isActive && page.fp >= 0) this.marryFrame(page, page.fp); + for (var fp = page.fp; fp >= 0; fp = page.slots[fp + Squeak.Frame_savedFp]) + this.marryFrame(page, fp); + this.syncPage(page); + for (var fp = page.fp; fp >= 0; fp = page.slots[fp + Squeak.Frame_savedFp]) { + var ctx = page.slots[fp + Squeak.Frame_context]; + if (ctx) ctx.frame = null; + } + page.live = false; + if (isActive) { + this.zonePage = null; + this.makeBaseFrameZ(top); + } + }, flushAllAndContinue: function() { // serialize every live frame to its (married) context, kill all pages, // and continue execution on a fresh base frame inflated from the top From 46c42f798ce4ea39cea787d2a5112ad76b939a71 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:22:42 -0300 Subject: [PATCH 21/22] [perf] treat empty-string option values as enabled for stackZone/jit2 The run/ launcher rewrites fragment options as key= (empty value), which parsed as falsy and silently disabled the flags in the browser. Co-Authored-By: Claude Fable 5 --- vm.interpreter.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vm.interpreter.js b/vm.interpreter.js index 300422cb..fc9e692b 100644 --- a/vm.interpreter.js +++ b/vm.interpreter.js @@ -32,11 +32,15 @@ Object.subclass('Squeak.Interpreter', this.primHandler = new Squeak.Primitives(this, display); this.loadImageState(); this.initVMState(); - if (this.options.stackZone && this.enableStackZone) this.enableStackZone(); + // presencia del flag alcanza: el launcher puede reescribir #stackZone + // como stackZone= (valor string vacío, falsy) + if (this.options.stackZone !== undefined && this.options.stackZone !== false + && this.enableStackZone) this.enableStackZone(); this.loadInitialContext(); this.hackImage(); this.initCompiler(); - if (this.useStackZone && this.options.jit2 && this.compiler && Squeak.Compiler2) + if (this.useStackZone && this.options.jit2 !== undefined && this.options.jit2 !== false + && this.compiler && Squeak.Compiler2) this.compiler = new Squeak.Compiler2(this); // stack-to-register jit (WIP, opt-in) console.log('squeak: ready'); }, From 2b553799152f3c88efaca68744b3b34d8c45bf4c Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:56:51 -0300 Subject: [PATCH 22/22] [perf] harness: FREEZE_SIM reproduces browser FilePlugin freeze pattern headless Wraps all Node FilePlugin primitives in vm.freeze + setImmediate unfreeze, mimicking the browser's async IndexedDB file access. Main loop is now async and yields to the event loop while frozen (interpret gets a noop thenDo, required by freeze). SSDBG counts enableSingleStepping calls. Reproduces the accumulative frames+jit2 slowdown seen in the browser (semantics identical: ctx and frames hashes match under FREEZE_SIM). Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 56 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index dbe9ab2b..8c7e498d 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -220,6 +220,47 @@ image.readFromBuffer(data.buffer, function startRunning() { }; } if (process.env.PFNDBG) vm.primFnDebug = true; + if (process.env.SSDBG) { + var c2 = vm.compiler; + if (c2) { + var origESS = c2.enableSingleStepping.bind(c2); + c2.enableSingleStepping = function(method, optClass, optSel) { + vm.nSingleStep = (vm.nSingleStep || 0) + 1; + if (vm.nSingleStep <= 5) console.error("SINGLESTEP #" + vm.nSingleStep + " s=" + vm.sendCount + " pc=" + vm.pc + " mbytes=" + (method.bytes ? method.bytes.length : "?")); + return origESS(method, optClass, optSel); + }; + } + } + if (process.env.FREEZE_SIM) { + // replica el patrón del FilePlugin del browser (fileContentsDo): + // el primitivo congela el VM y retorna true sin efecto de stack; un + // callback diferido hace unfreeze() y LUEGO aplica el efecto original + var wrapFrozen = function(mod, fnName) { + var orig = mod[fnName].bind(mod); + mod[fnName] = function(argCount) { + if (vm.frozen) return orig(argCount); // ya diferido: ejecutar directo + vm.nFreezeSim = (vm.nFreezeSim || 0) + 1; + vm.freeze(function(unfreeze) { + setImmediate(function() { + unfreeze(); + orig(argCount); + }); + }); + return true; + }; + }; + var origLM2 = vm.primHandler.loadModule.bind(vm.primHandler); + vm.primHandler.loadModule = function(name) { + var m = origLM2(name); + if (name === "FilePlugin" && m && !m._frozenSim) { + m._frozenSim = true; + for (var k in m) + if (typeof m[k] === "function" && /^primitive/.test(k)) + wrapFrozen(m, k); + } + return m; + }; + } if (process.env.PAGEDBG) { vm.pageStats = {fresh:0, reused:0, flushActive:0, flushSusp:0, flushDead:0}; process.on("exit", function(){ console.error("PAGES:", JSON.stringify(vm.pageStats)); }); } if (process.env.SMCDBG) { vm.smcStats = {}; process.on("exit", function() { console.error("SMC stores por índice:", JSON.stringify(vm.smcStats)); }); } if (process.env.FADBG) { @@ -441,12 +482,23 @@ image.readFromBuffer(data.buffer, function startRunning() { return origENM.call(vm, newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel); }; } + var noop = function() {}; var wallStart = process.hrtime.bigint(); clockRunning = true; + mainLoop(); + async function mainLoop() { try { + var frozenYields = 0; while (vm.sendCount < maxSends) { if (display.quitFlag) { stopReason = "quit"; break; } - var result = vm.interpret(5); + if (vm.frozen) { + // ceder el event loop para que el unfreeze diferido corra + if (++frozenYields > 1000000) { stopReason = "frozen-livelock"; break; } + await new Promise(function(r) { setImmediate(r); }); + continue; + } + var result = vm.interpret(5, noop); // thenDo: freeze necesita continueFunc + if (result === "frozen") continue; slices++; // el hash se alimenta solo de los checkpoints por sendCount (arriba); // los límites de slice dependen de la cadencia de interrupciones, @@ -522,6 +574,7 @@ image.readFromBuffer(data.buffer, function startRunning() { if (vm.useStackZone) console.log("married=" + (vm.nMarriedContexts||0) + " byClosure=" + (vm.nMarryClosure||0) + " byThisCtx=" + (vm.nMarryThisCtx||0) + " bySenderFill=" + (vm.nMarrySenderFill||0)); + if (vm.nFreezeSim) console.log("freezes simulados: " + vm.nFreezeSim + " singleSteps: " + (vm.nSingleStep || 0)); console.log("trace: sends=" + report.sendCount + " slices=" + report.slices + " stop=" + report.stopReason + " hash=" + report.hash + " virtualMs=" + report.virtualMs + " (wall " + wallMs.toFixed(0) + " ms)"); @@ -549,4 +602,5 @@ image.readFromBuffer(data.buffer, function startRunning() { process.exit(1); } } + } // fin mainLoop });