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/99] [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/99] [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/99] [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/99] [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/99] [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/99] [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/99] [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/99] [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/99] =?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/99] [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/99] [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/99] =?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/99] [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/99] =?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/99] [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/99] [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/99] [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/99] =?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/99] [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/99] [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/99] [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/99] [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 }); From 5a87afed2aaa18bd8436dfda0fbdcf7cae52315e Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:02:30 -0300 Subject: [PATCH 23/99] =?UTF-8?q?[perf]=20harness:=20--ui=20mode=20?= =?UTF-8?q?=E2=80=94=20offscreen=20display=20+=20event=20injection=20+=20d?= =?UTF-8?q?rawing=20fingerprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old oracle could only run boot-to-quit: headless reported no screen (primitiveScreenSize=false, image quits — hence -ignoreQuit) and delivered no input events. So it never exercised the interactive code paths where the browser actually crashed (resume:through:/terminateTo: under the async file freeze + reentrant event delivery). --ui installs a measuring headless display: reports a real (offscreen) screen size, accepts the Display form, drains an event queue. Morphic's World comes up and BitBlt draws into the Display Form in object memory (no canvas needed). A deterministic synthetic event script (mouse sweep, click, drag, keyboard), scheduled by sendCount, drives the interactive machinery; --events FILE.json replays a recorded browser trace for pixel-accurate scenarios. Adds a representation-independent drawing fingerprint (FNV hash of the Display Form bits) that also computes identically in the browser, so headless-vs-browser output is directly checkable — the signal the trace-hash oracle lacked. Verified on Dialogo.32bits.image: the World renders the full 1024x768 UI headless, and ctx vs frames+jit2 are byte-identical in BOTH the trace hash and the drawing hash, with and without events. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 48 ++++++++- perf/harness/uidisplay.js | 207 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 perf/harness/uidisplay.js diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 8c7e498d..e9322353 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -24,11 +24,14 @@ var mode = args.indexOf("--golden") >= 0 ? "golden" 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) +var useUI = args.indexOf("--ui") >= 0; // display offscreen real: levanta el World y ejercita la UI (ver uidisplay.js) 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 uiW = parseInt(argValue("--width", "1024"), 10); +var uiH = parseInt(argValue("--height", "768"), 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 checkpoint, para ubicar divergencias @@ -161,7 +164,13 @@ fs.readdirSync(imageDir).forEach(function(f) { 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 uiMod = null, display; + if (useUI) { + uiMod = require(path.join(__dirname, "uidisplay.js")); + display = uiMod.install(Squeak, { width: uiW, height: uiH }); + } else { + display = { vmOptions: ["-vm-display-null", "-nodisplay"] }; + } var vm = new Squeak.Interpreter(image, display, useFrames ? { stackZone: true, jit2: useJit2 } : {}); if (noJit) vm.compiler = null; if (process.env.JIT2DBG) vm.jit2Debug = true; @@ -482,6 +491,35 @@ image.readFromBuffer(data.buffer, function startRunning() { return origENM.call(vm, newRcvr, newMethod, argumentCount, primitiveIndex, optClass, optSel); }; } + // Agenda de eventos de entrada (solo --ui). Determinista: cada evento se + // inyecta al cruzar su umbral de sendCount, idéntico entre representaciones. + var evSched = null, evIdx = 0; + if (useUI) { + var evFile = argValue("--events", null); + if (evFile) { + // JSON: [ {at, ev:[type,ts,...]}, ... ] o [ [type,relMs,...], ... ] con --evfrom + var raw = JSON.parse(fs.readFileSync(path.resolve(evFile), "utf8")); + evSched = raw.map(function(e, i) { + return Array.isArray(e) ? { at: Math.floor(maxSends * (0.4 + 0.5 * i / raw.length)), ev: e } : e; + }); + } else { + evSched = uiMod.syntheticScript(uiW, uiH, Math.floor(maxSends * 0.5), Math.floor(maxSends * 0.9)); + } + console.log("eventos agendados: " + evSched.length + (evFile ? " (de " + evFile + ")" : " (sintéticos)")); + } + function injectDueEvents() { + if (!evSched) return; + while (evIdx < evSched.length && vm.sendCount >= evSched[evIdx].at) { + var e = evSched[evIdx++].ev.slice(); + e[1] = vm.primHandler.millisecondClockValue(); // ts en el dominio del reloj del VM + // reflejar posición/botones en el display para los prims de polling + if (e[0] === 1) { display.mouseX = e[2]; display.mouseY = e[3]; display.buttons = e[4]; } + display.eventQueue.push(e); + if (display.signalInputEvent) display.signalInputEvent(); + display.idle = 0; + } + } + var noop = function() {}; var wallStart = process.hrtime.bigint(); clockRunning = true; @@ -497,6 +535,7 @@ image.readFromBuffer(data.buffer, function startRunning() { await new Promise(function(r) { setImmediate(r); }); continue; } + injectDueEvents(); var result = vm.interpret(5, noop); // thenDo: freeze necesita continueFunc if (result === "frozen") continue; slices++; @@ -534,6 +573,7 @@ image.readFromBuffer(data.buffer, function startRunning() { } } + var fp = uiMod ? uiMod.displayFingerprint(vm) : null; var report = { image: path.relative(repoRoot, imagePath), maxSends: maxSends, @@ -542,6 +582,7 @@ image.readFromBuffer(data.buffer, function startRunning() { stopReason: stopReason, hash: hash.toString(16), virtualMs: virtualMs, + displayHash: fp ? fp.hash : undefined, }; if (mode === "bench") { @@ -575,6 +616,10 @@ image.readFromBuffer(data.buffer, function startRunning() { 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)); + if (fp) console.log("display: " + fp.w + "x" + fp.h + " depth=" + fp.depth + + " hash=" + fp.hash + " nonzero=" + fp.nonzero + "/" + (fp.words || "?") + " words" + + " damage=" + (display.damage ? JSON.stringify(display.damage) : "none") + + " eventsLeft=" + (display.eventQueue ? display.eventQueue.length : "?")); console.log("trace: sends=" + report.sendCount + " slices=" + report.slices + " stop=" + report.stopReason + " hash=" + report.hash + " virtualMs=" + report.virtualMs + " (wall " + wallMs.toFixed(0) + " ms)"); @@ -592,6 +637,7 @@ image.readFromBuffer(data.buffer, function startRunning() { // dependen de la cadencia de interrupciones y la granularidad del slice, // no de la semántica var keys = ["image", "maxSends", "stopReason", "hash"]; + if (golden.displayHash !== undefined && report.displayHash !== undefined) keys.push("displayHash"); 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/perf/harness/uidisplay.js b/perf/harness/uidisplay.js new file mode 100644 index 00000000..2052e23a --- /dev/null +++ b/perf/harness/uidisplay.js @@ -0,0 +1,207 @@ +"use strict"; +// Display+input headless "que mide" para el oráculo diferencial. +// +// El display headless normal (vm.display.headless.js) reporta que NO hay +// pantalla (primitiveScreenSize devuelve false) y no entrega eventos, así que +// la imagen se cierra al arrancar (por eso existe -ignoreQuit) y el oráculo +// solo cubría el boot-hasta-quit. Esta variante reporta una pantalla real +// (offscreen), acepta el Display form y entrega eventos desde una cola, de modo +// que el World de Morphic LEVANTA y corre su loop: BitBlt dibuja al Display Form +// en memoria de objetos (no hace falta canvas). Con eso el oráculo ejercita el +// código interactivo — el mismo donde crashea el browser — y podemos hashear los +// bits del dibujo como fingerprint de estado final (idéntico en browser). +// +// install(Squeak, {width, height}) parchea Squeak.Primitives.prototype con las +// versiones "que miden" (ganan sobre las headless requeridas antes) y devuelve +// el objeto display para pasar al Interpreter. Llamar SOLO en modo --ui: el +// parche del prototipo es global. + +var EventTypeNone = 0; + +function makeDisplayObject(width, height) { + var display = { + width: width, + height: height, + // el cursor arranca en el centro + mouseX: width >> 1, + mouseY: height >> 1, + buttons: 0, + keys: [], + eventQueue: [], // [ [type, ms, ...campos], ... ] en dominio de reloj del VM + signalInputEvent: null, // seteado por primitiveInputSemaphore + idle: 0, + deferDisplayUpdates: false, + damage: null, // {l,t,r,b} acumulado por showDisplayRect (para saber si dibujó) + vmOptions: ["-headless"], + }; + display.getNextEvent = function(evtBuf, timeOffset) { + var evt = display.eventQueue.shift(); + if (!evt) { evtBuf[0] = EventTypeNone; return; } + evtBuf[0] = evt[0]; + // los timestamps ya están en el dominio de reloj del VM; sin offset + evtBuf[1] = (evt[1] - 0) & 0x1FFFFFFF; // MillisecondClockMask + for (var i = 2; i < evt.length; i++) evtBuf[i] = evt[i]; + }; + return display; +} + +function install(Squeak, opts) { + opts = opts || {}; + var width = opts.width || 1024, height = opts.height || 768; + var display = makeDisplayObject(width, height); + + Object.extend(Squeak.Primitives.prototype, "measuring-display", { + // --- display --- + primitiveScreenSize: function(argCount) { + return this.popNandPushIfOK(argCount + 1, this.makePointWithXandY(this.display.width, this.display.height)); + }, + primitiveScreenDepth: function(argCount) { + return this.popNandPushIfOK(argCount + 1, 32); + }, + primitiveScreenScaleFactor: function(argCount) { + return this.popNandPushIfOK(argCount + 1, 1); + }, + primitiveBeDisplay: function(argCount) { + var displayObj = this.vm.stackValue(0); + this.vm.specialObjects[Squeak.splOb_TheDisplay] = displayObj; + this.vm.popN(argCount); // return self + return true; + }, + primitiveReverseDisplay: function(argCount) { return true; }, + primitiveDeferDisplayUpdates: function(argCount) { + var flag = this.stackBoolean(0); + if (!this.success) return false; + this.display.deferDisplayUpdates = flag; + this.vm.popN(argCount); + return true; + }, + primitiveForceDisplayUpdate: function(argCount) { + this.vm.popN(argCount); + return true; + }, + primitiveShowDisplayRect: function(argCount) { + // registrar el rect como "dibujado" (BitBlt ya escribió al Display Form + // en memoria); no hay canvas al que copiar + var d = this.display; + var r = { l: this.stackInteger(3), t: this.stackInteger(1), r: this.stackInteger(2), b: this.stackInteger(0) }; + if (this.success) { + if (!d.damage) d.damage = { l: r.l, t: r.t, r: r.r, b: r.b }; + else { d.damage.l = Math.min(d.damage.l, r.l); d.damage.t = Math.min(d.damage.t, r.t); + d.damage.r = Math.max(d.damage.r, r.r); d.damage.b = Math.max(d.damage.b, r.b); } + } + this.vm.popN(argCount); + return true; + }, + primitiveSetFullScreen: function(argCount) { this.vm.popN(argCount); return true; }, + primitiveTestDisplayDepth: function(argCount) { + // responder true para las profundidades que soporta el image (todas acá) + return this.popNandPushIfOK(argCount + 1, this.vm.trueObj); + }, + + // --- input --- + primitiveInputSemaphore: function(argCount) { + var semaIndex = this.stackInteger(0); + if (!this.success) return false; + this.inputEventSemaIndex = semaIndex; + this.display.signalInputEvent = function() { + this.signalSemaphoreWithIndex(this.inputEventSemaIndex); + }.bind(this); + return this.popNIfOK(argCount); + }, + primitiveInputWord: function(argCount) { return this.popNandPushIfOK(1, 0); }, + primitiveGetNextEvent: function(argCount) { + this.display.idle++; + var evtBuf = this.stackNonInteger(0); + this.display.getNextEvent(evtBuf.pointers, this.vm.startupTime); + return this.popNIfOK(argCount); + }, + primitiveMouseButtons: function(argCount) { + this.popNandPushIfOK(argCount + 1, this.ensureSmallInt(this.display.buttons)); + if (this.display.idle++ > 20) this.vm.goIdle(); + return true; + }, + primitiveMousePoint: function(argCount) { + return this.popNandPushIfOK(argCount + 1, + this.makePointWithXandY(this.ensureSmallInt(this.display.mouseX), this.ensureSmallInt(this.display.mouseY))); + }, + primitiveKeyboardNext: function(argCount) { + return this.popNandPushIfOK(argCount + 1, + this.display.keys.length ? this.ensureSmallInt(this.display.keys.shift()) : this.vm.nilObj); + }, + primitiveKeyboardPeek: function(argCount) { + return this.popNandPushIfOK(argCount + 1, + this.display.keys.length ? this.ensureSmallInt(this.display.keys[0] || 0) : this.vm.nilObj); + }, + primitiveBeep: function(argCount) { return true; }, + primitiveClipboardText: function(argCount) { return false; }, + }); + + return display; +} + +// Hash FNV-1a de los bits del Display Form (fingerprint del dibujo). +// Devuelve {hash, nonzero, w, h, depth} o null si no hay Display todavía. +function displayFingerprint(vm) { + var disp = vm.specialObjects[Squeak.splOb_TheDisplay]; + if (!disp || disp.isNil || !disp.pointers) return null; + // DisplayScreen/Form: pointers = [bits, width, height, depth, ...] + var bits = disp.pointers[0]; + var w = disp.pointers[1], h = disp.pointers[2], depth = disp.pointers[3]; + if (!bits || !bits.words) return { hash: "nobits", nonzero: 0, w: w, h: h, depth: depth }; + var words = bits.words; + var hash = 2166136261 >>> 0, nonzero = 0; + for (var i = 0; i < words.length; i++) { + var v = words[i] >>> 0; + if (v !== 0) nonzero++; + hash = (hash ^ (v & 0xffff)) >>> 0; hash = Math.imul(hash, 16777619) >>> 0; + hash = (hash ^ (v >>> 16)) >>> 0; hash = Math.imul(hash, 16777619) >>> 0; + } + return { hash: hash.toString(16), nonzero: nonzero, words: words.length, w: w, h: h, depth: depth }; +} + +// Constantes de evento (espejo de Squeak.events / vm.input.js) +var Ev = { + Mouse: 1, Keyboard: 2, + Red: 4, Yellow: 2, Blue: 1, // botones + Shift: 8, Ctrl: 16, Alt: 32, Cmd: 64, + KeyChar: 0, KeyDown: 1, KeyUp: 2, +}; + +// Script sintético de interacción, agendado por sendCount (determinista e +// idéntico entre representaciones). No apunta a botones concretos de Dialogo +// (no conocemos su layout) pero ejercita la maquinaria interactiva pesada que +// el boot estático nunca toca: tracking del hand, hit-testing de morphs, +// cursor, drag (down→move→up) y teclado. Para "dibujar" con coordenadas reales, +// pasar --events con una traza grabada del browser. +// from/to: ventana de sendCount donde ocurre la interacción +// devuelve [ {at, ev:[type, tsPlaceholder, ...]}, ... ] ordenado por at +function syntheticScript(width, height, from, to) { + var sched = []; + var span = Math.max(1, to - from); + var cx = width >> 1, cy = height >> 1; + var t = from; + var step = Math.max(1, Math.floor(span / 64)); + function moveTo(x, y, buttons) { sched.push({ at: t, ev: [Ev.Mouse, 0, x | 0, y | 0, buttons | 0, 0] }); t += step; } + // 1) barrido diagonal (hand tracking + hit-test en toda la pantalla) + for (var i = 0; i <= 16; i++) moveTo(50 + (width - 100) * i / 16, 50 + (height - 100) * i / 16, 0); + // 2) mover al centro y hacer click (down/up) — dispara menús/halos según el morph + moveTo(cx, cy, 0); + moveTo(cx, cy, Ev.Red); // mouse down (botón izquierdo) + moveTo(cx, cy, 0); // mouse up + // 3) drag: down, arrastrar en L, up (maquinaria de drag/pickup) + moveTo(cx - 200, cy - 100, Ev.Red); + for (var j = 1; j <= 12; j++) moveTo(cx - 200 + j * 30, cy - 100, Ev.Red); + for (var k = 1; k <= 8; k++) moveTo(cx + 160, cy - 100 + k * 25, Ev.Red); + moveTo(cx + 160, cy + 100, 0); // soltar + // 4) teclado: unas teclas (macRoman == unicode para ASCII) + "hola".split("").forEach(function(ch) { + var u = ch.charCodeAt(0); + sched.push({ at: t, ev: [Ev.Keyboard, 0, u, Ev.KeyChar, 0, u] }); t += step; + }); + return sched; +} + +module.exports = { + install: install, displayFingerprint: displayFingerprint, + EventTypeNone: EventTypeNone, syntheticScript: syntheticScript, Ev: Ev, +}; From 4da632ae333712efb2cc6b4134e84b525635da55 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:08:43 -0300 Subject: [PATCH 24/99] [perf] harness: UNWINDDBG coverage probe (unwind/terminate/process-switch counts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers 'does the workload even reach the paths where the browser crashed?' Counts executeNewMethod activations for a selector watchlist (terminateTo:, resume:through:, aboutToReturn:through:, ...) plus process switches and married- context resumes. On the --ui Dialogo workload: terminateTo: fires 2813x and married contexts resume 20415x (both ctx==frames), but resume:through: never fires — the synthetic script doesn't navigate Dialogo's specific menus, so the exact crash needs a recorded --events trace. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index e9322353..43118c1d 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -229,6 +229,29 @@ image.readFromBuffer(data.buffer, function startRunning() { }; } if (process.env.PFNDBG) vm.primFnDebug = true; + if (process.env.UNWINDDBG) { + // cobertura: contar activaciones por selector (watchlist de unwind/terminación) + // + process switches + resumes de contextos casados. Responde "¿el workload + // siquiera toca los paths donde crashea el browser?" + var watch = (process.env.UNWINDDBG_SEL || + "terminateTo:,resume:through:,resume:,aboutToReturn:through:,cannotReturn:,valueUninterruptably") + .split(","); + var counts = {}; watch.forEach(function(s){ counts[s]=0; }); + counts[""] = 0; + counts[""] = 0; + var origENMu = vm.executeNewMethod; + vm.executeNewMethod = function(r, m, ac, pi, oc, sel) { + if (sel && sel.bytesAsString) { var s = sel.bytesAsString(); if (counts[s] !== undefined) counts[s]++; } + return origENMu.call(vm, r, m, ac, pi, oc, sel); + }; + var origTTu = vm.primHandler.transferTo; + vm.primHandler.transferTo = function(p){ counts[""]++; return origTTu.call(this,p); }; + if (vm.newActiveContext) { + var origNACu = vm.newActiveContext; + vm.newActiveContext = function(c){ if (vm.useStackZone && c && c.frame != null) counts[""]++; return origNACu.call(vm,c); }; + } + process.on("exit", function(){ console.error("UNWIND cobertura:", JSON.stringify(counts)); }); + } if (process.env.SSDBG) { var c2 = vm.compiler; if (c2) { From 4f59aa6ae6ebcc83757b29be434f663ddee294ef Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:30:17 -0300 Subject: [PATCH 25/99] [perf] browser event recorder (#record) + harness --events replay with sizing #record installs an event recorder in the browser display: captures each mouse/ wheel/keyboard/drag event at the enqueue point together with the VM sendCount, so the timing/interleaving can be reproduced headless. SqueakJS.downloadEvents() dumps {width, height, events} (display resolution included so the headless replay uses the same size and coordinates land on the same morphs). difftrace --events FILE.json replays it: sizes the offscreen display from the recording, rebases browser sendCounts to the replay (first event at --evstart, inter-event gaps capped at --evgap to compress user pauses). Verified end-to-end with a synthetic recording. Workflow: record a session in plain ctx mode (no stackZone/jit2, so it completes without the crash), then replay headless in BOTH modes to catch the divergence. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 36 ++++++++++++++++++++++++++------ squeak.js | 43 +++++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 43118c1d..ad55d3d3 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -32,6 +32,16 @@ function argValue(name, deflt) { var maxSends = parseInt(argValue("--sends", "20000000"), 10); var uiW = parseInt(argValue("--width", "1024"), 10); var uiH = parseInt(argValue("--height", "768"), 10); +// Grabación de eventos (--events): la leemos temprano para dimensionar el display +// headless igual que el browser donde se grabó (coordenadas → mismos morphs). +var evData = null; +if (useUI && argValue("--events", null)) { + evData = JSON.parse(fs.readFileSync(path.resolve(argValue("--events", null)), "utf8")); + if (!Array.isArray(evData)) { // wrapper {width, height, events} + if (args.indexOf("--width") < 0 && evData.width) uiW = evData.width; + if (args.indexOf("--height") < 0 && evData.height) uiH = evData.height; + } +} 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 checkpoint, para ubicar divergencias @@ -520,15 +530,29 @@ image.readFromBuffer(data.buffer, function startRunning() { if (useUI) { var evFile = argValue("--events", null); if (evFile) { - // JSON: [ {at, ev:[type,ts,...]}, ... ] o [ [type,relMs,...], ... ] con --evfrom - var raw = JSON.parse(fs.readFileSync(path.resolve(evFile), "utf8")); - evSched = raw.map(function(e, i) { - return Array.isArray(e) ? { at: Math.floor(maxSends * (0.4 + 0.5 * i / raw.length)), ev: e } : e; - }); + // Grabado con #record del browser: [ {at: sendCount, ev:[type,ts,...]}, ... ]. + // Se rebasa: el 1er evento cae en --evstart y los deltas de sendCount del + // browser se preservan pero capados a --evgap (comprime las pausas del + // usuario). También acepta el formato plano [ [type,ms,...], ... ]. + var raw = Array.isArray(evData) ? evData : evData.events; + var evStart = parseInt(argValue("--evstart", "800000"), 10); + var evGapCap = parseInt(argValue("--evgap", "250000"), 10); + if (raw.length && Array.isArray(raw[0])) { + evSched = raw.map(function(e, i) { return { at: Math.floor(maxSends * (0.3 + 0.6 * i / raw.length)), ev: e }; }); + } else { + var acc = evStart, prev = raw.length ? raw[0].at : 0; + evSched = raw.map(function(e) { + var gap = Math.min(Math.max(0, e.at - prev), evGapCap); + acc += gap; prev = e.at; + return { at: acc, ev: e.ev }; + }); + } } else { evSched = uiMod.syntheticScript(uiW, uiH, Math.floor(maxSends * 0.5), Math.floor(maxSends * 0.9)); } - console.log("eventos agendados: " + evSched.length + (evFile ? " (de " + evFile + ")" : " (sintéticos)")); + var lastAt = evSched.length ? evSched[evSched.length - 1].at : 0; + console.log("eventos agendados: " + evSched.length + (evFile ? " (de " + evFile + ", último en send " + lastAt + ")" : " (sintéticos)") + + (lastAt > maxSends ? " ⚠️ subí --sends a >" + lastAt + " para no cortar la interacción" : "")); } function injectDueEvents() { if (!evSched) return; diff --git a/squeak.js b/squeak.js index 2e0fbd0a..396c45e6 100644 --- a/squeak.js +++ b/squeak.js @@ -222,14 +222,16 @@ function recordMouseEvent(what, evt, canvas, display, options) { } display.buttons = buttons | recordModifiers(evt, display); if (display.eventQueue) { - display.eventQueue.push([ + var sqEvt = [ Squeak.EventTypeMouse, evt.timeStamp, // converted to Squeak time in makeSqueakEvent() display.mouseX, display.mouseY, display.buttons & Squeak.Mouse_All, display.buttons >> 3, - ]); + ]; + display.eventQueue.push(sqEvt); + if (display.recordedEvents) display.recordedEvents.push({at: SqueakJS.vm && SqueakJS.vm.sendCount || 0, ev: sqEvt}); if (display.signalInputEvent) display.signalInputEvent(); } @@ -255,6 +257,7 @@ function recordWheelEvent(evt, display) { display.buttons >> 3, ]; display.eventQueue.push(squeakEvt); + if (display.recordedEvents) display.recordedEvents.push({at: SqueakJS.vm && SqueakJS.vm.sendCount || 0, ev: squeakEvt}); if (display.signalInputEvent) display.signalInputEvent(); display.idle = 0; @@ -289,14 +292,16 @@ function recordKeyboardEvent(unicode, timestamp, display) { var macCode = UnicodeToMacRoman[unicode] || (unicode < 128 ? unicode : 0); var modifiersAndKey = (display.buttons >> 3) << 8 | macCode; if (display.eventQueue) { - display.eventQueue.push([ + var sqKeyEvt = [ Squeak.EventTypeKeyboard, timestamp, // converted to Squeak time in makeSqueakEvent() macCode, // MacRoman Squeak.EventKeyChar, display.buttons >> 3, unicode, // Unicode - ]); + ]; + display.eventQueue.push(sqKeyEvt); + if (display.recordedEvents) display.recordedEvents.push({at: SqueakJS.vm && SqueakJS.vm.sendCount || 0, ev: sqKeyEvt}); if (display.signalInputEvent) display.signalInputEvent(); // There are some old images that use both event-based @@ -316,7 +321,7 @@ function recordKeyboardEvent(unicode, timestamp, display) { function recordDragDropEvent(type, evt, canvas, display) { if (!display.vm || !display.eventQueue) return; updateMousePos(evt, canvas, display); - display.eventQueue.push([ + var sqDropEvt = [ Squeak.EventTypeDragDropFiles, evt.timeStamp, // converted to Squeak time in makeSqueakEvent() type, @@ -324,7 +329,9 @@ function recordDragDropEvent(type, evt, canvas, display) { display.mouseY, display.buttons >> 3, display.droppedFiles.length, - ]); + ]; + display.eventQueue.push(sqDropEvt); + if (display.recordedEvents) display.recordedEvents.push({at: SqueakJS.vm && SqueakJS.vm.sendCount || 0, ev: sqDropEvt}); if (display.signalInputEvent) display.signalInputEvent(); display.idle = 0; @@ -384,6 +391,30 @@ function createSqueakDisplay(canvas, options) { display.cursorCanvas && display.cursorCanvas.classList.add("pixelated"); } + if (options.record) { + // Grabador de eventos para el oráculo diferencial (perf/harness --events). + // Captura cada evento en el punto de encolado junto con el sendCount, para + // reproducir el timing/interleaving headless. Volcar con SqueakJS.downloadEvents(). + display.recordedEvents = []; + SqueakJS.downloadEvents = function(filename) { + // capturar la resolución real del Display (splOb_TheDisplay=14) para que + // el replay headless use el mismo tamaño y las coordenadas peguen en los + // mismos morphs + var disp = SqueakJS.vm && SqueakJS.vm.specialObjects && SqueakJS.vm.specialObjects[14]; + var w = disp && disp.pointers ? disp.pointers[1] : (display.width || 0); + var h = disp && disp.pointers ? disp.pointers[2] : (display.height || 0); + var payload = { width: w, height: h, events: display.recordedEvents }; + var blob = new Blob([JSON.stringify(payload)], {type: "application/json"}); + var a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename || "dialogo-events.json"; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + console.log("SqueakJS: descargados " + display.recordedEvents.length + " eventos (display " + w + "x" + h + ")"); + return display.recordedEvents.length; + }; + console.log("SqueakJS: grabador de eventos ACTIVO (#record). Hacé la interacción y luego ejecutá SqueakJS.downloadEvents() en la consola."); + } + display.reset = function() { display.eventQueue = null; display.signalInputEvent = null; From 2396d9e5906b934705d98d97f5b517141d29b05f Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:47:38 -0300 Subject: [PATCH 26/99] =?UTF-8?q?[perf]=20dialogo.sh=20=E2=80=94=20one-com?= =?UTF-8?q?mand=20automated=20headless=20workload=20(compare/prof/run)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialogo auto-loads its projects from the run directory at startup, so the boot with its file environment is a representative send-heavy workload with no user interaction needed. This script drives it headless without opening a browser: dialogo.sh compare [sends] ctx vs frames+jit2: wall, sends/s, correctness dialogo.sh prof [ctx|frames] [n] top JS hotspots (node --prof, auto-cleaned) dialogo.sh run [mode] [sends] single run, full output Prepares a stable run dir (/tmp/dialogo-run) from ~/Desktop/Dialogo/Archivos and stabilizes the recorded event trace there. compare verifies trace+display hashes match across modes and prints the perf delta. On the real auto-load workload: frames+jit2 is +7.5% vs ctx (send-heavy, unlike the synthetic pure-render workload which was BitBlt-bound). Dominant remaining cost in frames mode is the V8 LoadIC family (~23%) — megamorphic property access. Co-Authored-By: Claude Fable 5 --- perf/harness/dialogo.sh | 105 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100755 perf/harness/dialogo.sh diff --git a/perf/harness/dialogo.sh b/perf/harness/dialogo.sh new file mode 100755 index 00000000..889395bd --- /dev/null +++ b/perf/harness/dialogo.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# Workload automático de Dialogo para el oráculo/bench — sin abrir el browser. +# +# La imagen de Dialogo auto-carga sus proyectos del directorio al arrancar, así +# que el boot con los archivos al lado ya es un workload representativo (carga de +# proyectos + render), reproducible y determinista. Este script prepara un +# entorno estable (fuera del scratchpad, que se borra entre sesiones) y corre el +# workload headless comparando modos. +# +# perf/harness/dialogo.sh compare [sends] ctx vs frames+jit2: wall + correctitud +# perf/harness/dialogo.sh prof [ctx|frames] [sends] top hotspots (node --prof) +# perf/harness/dialogo.sh run [ctx|frames] [sends] una corrida, salida completa +# +# Env: DIALOGO_SRC (fuente, default ~/Desktop/Dialogo/Archivos), DIALOGO_RUN +# (dir de corrida, default /tmp/dialogo-run), EVENTS (grabación, default +# ~/Downloads/dialogo-events.json si existe; "none" para desactivar). +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +SRC="${DIALOGO_SRC:-$HOME/Desktop/Dialogo/Archivos}" +RUN="${DIALOGO_RUN:-/tmp/dialogo-run}" +IMG="$RUN/Dialogo.32bits.image" +EVENTS="${EVENTS:-$RUN/events.json}" + +prepare() { + if [ ! -f "$IMG" ]; then + echo "preparando entorno en $RUN (copiando de $SRC)…" >&2 + mkdir -p "$RUN" + cp "$SRC/Dialogo.32bits.image" "$RUN/" + [ -d "$SRC/dictionaries" ] && cp -R "$SRC/dictionaries" "$RUN/" + [ -d "$SRC/projects" ] && cp -R "$SRC/projects" "$RUN/" + fi + # estabilizar la grabación de eventos en el dir de corrida (sobrevive a que + # se borre Downloads); se puede regrabar y volver a copiar + if [ ! -f "$RUN/events.json" ] && [ -f "$HOME/Downloads/dialogo-events.json" ]; then + cp "$HOME/Downloads/dialogo-events.json" "$RUN/events.json" + fi + # limpiar artefactos de la corrida anterior (el harness también lo hace) + rm -f "$RUN"/*.changes "$RUN"/CuisDebug-*.log 2>/dev/null || true +} + +evflag() { + if [ "$EVENTS" != "none" ] && [ -f "$EVENTS" ]; then echo "--events $EVENTS"; fi +} + +# corre un modo, devuelve la línea "trace:" y "display:" por stdout +run_one() { # $1=sends, $2..=flags + local sends="$1"; shift + node "$REPO/perf/harness/difftrace.js" --ui $(evflag) "$@" --image "$IMG" --sends "$sends" 2>&1 +} + +# mide best-of-2 de un modo; imprime "wall|traceHash|dispHash|sendCount" +measure() { # $1=sends $2..=flags + local sends="$1"; shift + local best=99999999 out="" o w + for i in 1 2; do + o=$(run_one "$sends" "$@") + w=$(echo "$o" | grep -oE "wall [0-9]+ ms" | grep -oE "[0-9]+") + if [ -n "$w" ] && [ "$w" -lt "$best" ]; then best=$w; out="$o"; fi + done + local th=$(echo "$out" | grep "^trace:" | grep -oE "hash=[0-9a-fx]+" | cut -d= -f2) + local dh=$(echo "$out" | grep "^display:" | grep -oE "hash=[0-9a-fx]+" | cut -d= -f2) + local sc=$(echo "$out" | grep "^trace:" | grep -oE "sends=[0-9]+" | cut -d= -f2) + echo "$best|$th|$dh|$sc" +} + +cmd="${1:-compare}" +prepare + +case "$cmd" in +compare) + SENDS="${2:-20000000}" + echo "workload: Dialogo auto-load, $SENDS sends, eventos=$([ -n "$(evflag)" ] && echo sí || echo no)" + ctx=$(measure "$SENDS"); frm=$(measure "$SENDS" --frames --jit2) + cw=${ctx%%|*}; ct=$(echo "$ctx"|cut -d'|' -f2); cd=$(echo "$ctx"|cut -d'|' -f3); cs=$(echo "$ctx"|cut -d'|' -f4) + fw=${frm%%|*}; ft=$(echo "$frm"|cut -d'|' -f2); fd=$(echo "$frm"|cut -d'|' -f3); fs=$(echo "$frm"|cut -d'|' -f4) + printf "%-14s %10s %10s %12s %s\n" "modo" "wall(ms)" "sends/s" "traceHash" "displayHash" + printf "%-14s %10s %9.2fM %12s %s\n" "ctx" "$cw" "$(awk "BEGIN{print $cs/($cw/1000)/1e6}")" "$ct" "$cd" + printf "%-14s %10s %9.2fM %12s %s\n" "frames+jit2" "$fw" "$(awk "BEGIN{print $fs/($fw/1000)/1e6}")" "$ft" "$fd" + echo "---" + if [ "$ct" = "$ft" ] && [ "$cd" = "$fd" ]; then + echo "✓ CORRECTO: traza y dibujo idénticos entre ctx y frames+jit2" + else + echo "✗ DIVERGE: ctx trace=$ct disp=$cd vs frames trace=$ft disp=$fd" + fi + awk "BEGIN{printf \"perf: frames+jit2 %+.1f%% vs ctx (%s vs %s ms)\n\", ($cw-$fw)/$cw*100, $fw, $cw}" + ;; + +run) + mode="${2:-frames}"; SENDS="${3:-20000000}" + [ "$mode" = "ctx" ] && run_one "$SENDS" || run_one "$SENDS" --frames --jit2 + ;; + +prof) + mode="${2:-frames}"; SENDS="${3:-20000000}" + PDIR="$RUN/prof"; mkdir -p "$PDIR"; rm -f "$PDIR"/isolate-*.log + flags=""; [ "$mode" = "frames" ] && flags="--frames --jit2" + ( cd "$PDIR" && node --prof "$REPO/perf/harness/difftrace.js" --ui $(evflag) $flags --image "$IMG" --sends "$SENDS" >/dev/null 2>&1 + node --prof-process isolate-*.log > proc.txt 2>/dev/null ) + echo "== $mode ($SENDS sends): top JS (nonlib %) ==" + awk '/\[JavaScript\]:/{f=1; next} /^ \[/{f=0} f' "$PDIR/proc.txt" | head -24 + rm -f "$PDIR"/isolate-*.log + ;; + +*) echo "uso: dialogo.sh {compare|run|prof} …"; exit 1 ;; +esac From 33d26f711c72601bff73b2197ddb058d8ebbcd63 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:10:42 -0300 Subject: [PATCH 27/99] [perf] implement missing LargeInteger arithmetic primitives (21,22,29-33,20,34-37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqueakJS never implemented the LargeInteger arithmetic primitives — add (21), subtract (22), multiply (29), divide (30), floored div/mod (32/31), quo/rem (33/20), and bit-and/or/xor/shift (34-37) all did warnOnce + return false, forcing every operation to fall back to its Smalltalk implementation (hundreds of sends each: digitAdd:, normalize, ...). Only the comparisons (23-28) existed. Implemented with JS BigInt (exact for any size). Operands may be SmallInteger or LargeInteger; results normalize back to SmallInteger when in range. Squeak semantics: // /\ are floored (sign of divisor), quo:/rem: truncated (sign of dividend), / (30) succeeds only when exact, div-by-zero and negative bit-op operands fall back. primHandler.largeIntPrims=false restores the old fallback. On the real Dialogo workload (its clock/Delay scheduling is heavy on large-int time arithmetic): LargeInteger receiver sends drop 6.9% -> 2.6%, and the same image-work takes 18% fewer sends / 18% less wall (1.22x). Verified: math matches BigInt + Squeak reference (//,\,quo,rem,invariants,byte round-trip); ctx==frames identical; display bit-identical with prims ON vs OFF at equal image-time; no regression on the Cuis boot trace. Co-Authored-By: Claude Fable 5 --- vm.primitives.js | 131 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 12 deletions(-) diff --git a/vm.primitives.js b/vm.primitives.js index 7fd7a3ed..48a3dbf4 100644 --- a/vm.primitives.js +++ b/vm.primitives.js @@ -102,24 +102,24 @@ Object.subclass('Squeak.Primitives', case 19: return false; // Guard primitive for simulation -- *must* fail // LargeInteger Primitives (20-39) // 32-bit logic is aliased to Integer prims above - case 20: this.vm.warnOnce("missing primitive: 20 (primitiveRemLargeIntegers)"); return false; - case 21: this.vm.warnOnce("missing primitive: 21 (primitiveAddLargeIntegers)"); return false; - case 22: this.vm.warnOnce("missing primitive: 22 (primitiveSubtractLargeIntegers)"); return false; + case 20: return this.primitiveRemLargeIntegers(argCount); + case 21: return this.primitiveAddLargeIntegers(argCount); + case 22: return this.primitiveSubtractLargeIntegers(argCount); case 23: return this.primitiveLessThanLargeIntegers(argCount); case 24: return this.primitiveGreaterThanLargeIntegers(argCount); case 25: return this.primitiveLessOrEqualLargeIntegers(argCount); case 26: return this.primitiveGreaterOrEqualLargeIntegers(argCount); case 27: return this.primitiveEqualLargeIntegers(argCount); case 28: return this.primitiveNotEqualLargeIntegers(argCount); - case 29: this.vm.warnOnce("missing primitive: 29 (primitiveMultiplyLargeIntegers)"); return false; - case 30: this.vm.warnOnce("missing primitive: 30 (primitiveDivideLargeIntegers)"); return false; - case 31: this.vm.warnOnce("missing primitive: 31 (primitiveModLargeIntegers)"); return false; - case 32: this.vm.warnOnce("missing primitive: 32 (primitiveDivLargeIntegers)"); return false; - case 33: this.vm.warnOnce("missing primitive: 33 (primitiveQuoLargeIntegers)"); return false; - case 34: this.vm.warnOnce("missing primitive: 34 (primitiveBitAndLargeIntegers)"); return false; - case 35: this.vm.warnOnce("missing primitive: 35 (primitiveBitOrLargeIntegers)"); return false; - case 36: this.vm.warnOnce("missing primitive: 36 (primitiveBitXorLargeIntegers)"); return false; - case 37: this.vm.warnOnce("missing primitive: 37 (primitiveBitShiftLargeIntegers)"); return false; + case 29: return this.primitiveMultiplyLargeIntegers(argCount); + case 30: return this.primitiveDivideLargeIntegers(argCount); + case 31: return this.primitiveModLargeIntegers(argCount); + case 32: return this.primitiveDivLargeIntegers(argCount); + case 33: return this.primitiveQuoLargeIntegers(argCount); + case 34: return this.primitiveBitAndLargeIntegers(argCount); + case 35: return this.primitiveBitOrLargeIntegers(argCount); + case 36: return this.primitiveBitXorLargeIntegers(argCount); + case 37: return this.primitiveBitShiftLargeIntegers(argCount); case 38: return this.popNandPushIfOK(argCount+1, this.objectAt(false,false,false)); // Float basicAt case 39: return this.popNandPushIfOK(argCount+1, this.objectAtPut(false,false,false)); // Float basicAtPut // Float Primitives (40-59) @@ -812,6 +812,113 @@ Object.subclass('Squeak.Primitives', primitiveNotEqualLargeIntegers: function(argCount) { return this.popNandPushBoolIfOK(argCount+1, this.stackSigned53BitInt(1) !== this.stackSigned53BitInt(0)); }, + // --- Aritmética LargeInteger (21,22,29,30-33,20) y bit-ops (34-37) --- + // Faltaban en SqueakJS: cada operación caía a la implementación en Smalltalk + // (cientos de sends: digitAdd:, normalize, ...). Acá se hacen con BigInt, que + // es exacto para cualquier tamaño y respeta las semánticas de Squeak. Los + // operandos pueden ser SmallInteger o LargeInteger; el resultado se normaliza + // a SmallInteger si entra en rango. Toggle: primHandler.largeIntPrims=false + // restaura el fallback (para medir A/B). + bigIntFromStackInt: function(nDeep) { + var v = this.vm.stackValue(nDeep); + if (typeof v === "number") return BigInt(v); // SmallInteger + var isPos = this.isA(v, Squeak.splOb_ClassLargePositiveInteger); + if ((isPos || this.isA(v, Squeak.splOb_ClassLargeNegativeInteger)) && v.bytes) { + var bytes = v.bytes, val = 0n; + for (var i = bytes.length - 1; i >= 0; i--) val = (val << 8n) | BigInt(bytes[i]); + return isPos ? val : -val; + } + this.success = false; + return 0n; + }, + squeakIntFromBigInt: function(b) { + if (b >= BigInt(Squeak.MinSmallInt) && b <= BigInt(Squeak.MaxSmallInt)) return Number(b); + var neg = b < 0n, mag = neg ? -b : b, bytes = []; + while (mag > 0n) { bytes.push(Number(mag & 255n)); mag >>= 8n; } + if (bytes.length === 0) bytes.push(0); + var cls = this.vm.specialObjects[neg ? Squeak.splOb_ClassLargeNegativeInteger : Squeak.splOb_ClassLargePositiveInteger], + obj = this.vm.instantiateClass(cls, bytes.length); + for (var i = 0; i < bytes.length; i++) obj.bytes[i] = bytes[i]; + return obj; + }, + primitiveAddLargeIntegers: function(argCount) { + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a + b)); + }, + primitiveSubtractLargeIntegers: function(argCount) { + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a - b)); + }, + primitiveMultiplyLargeIntegers: function(argCount) { + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a * b)); + }, + primitiveDivideLargeIntegers: function(argCount) { // 30: / exacto (falla si no divide) + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || b === 0n || a % b !== 0n) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a / b)); + }, + primitiveDivLargeIntegers: function(argCount) { // 32: // división con piso + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || b === 0n) return false; + var q = a / b, r = a % b; + if (r !== 0n && ((r < 0n) !== (b < 0n))) q -= 1n; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(q)); + }, + primitiveModLargeIntegers: function(argCount) { // 31: \\ módulo con piso (signo del divisor) + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || b === 0n) return false; + var r = a % b; + if (r !== 0n && ((r < 0n) !== (b < 0n))) r += b; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(r)); + }, + primitiveQuoLargeIntegers: function(argCount) { // 33: quo: truncado hacia cero + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || b === 0n) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a / b)); + }, + primitiveRemLargeIntegers: function(argCount) { // 20: rem: (signo del dividendo) + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || b === 0n) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a % b)); + }, + primitiveBitAndLargeIntegers: function(argCount) { // 34 + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || a < 0n || b < 0n) return false; // negativos: two's complement infinito, fallback + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a & b)); + }, + primitiveBitOrLargeIntegers: function(argCount) { // 35 + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || a < 0n || b < 0n) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a | b)); + }, + primitiveBitXorLargeIntegers: function(argCount) { // 36 + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), b = this.bigIntFromStackInt(0); + if (!this.success || a < 0n || b < 0n) return false; + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(a ^ b)); + }, + primitiveBitShiftLargeIntegers: function(argCount) { // 37 + if (this.largeIntPrims === false) return false; + var a = this.bigIntFromStackInt(1), shift = this.vm.stackValue(0); + if (typeof shift !== "number" || a < 0n) { this.success = false; return false; } + if (!this.success) return false; + var s = BigInt(shift), res = s >= 0n ? (a << s) : (a >> -s); + return this.popNandPushIfOK(argCount+1, this.squeakIntFromBigInt(res)); + }, }, 'utils', { floatOrInt: function(obj) { From d0ad7c941497e0516d1d291de93662a2c1827be9 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:10:43 -0300 Subject: [PATCH 28/99] [perf] harness: HOTSEL (hot Smalltalk methods/classes), --until-ms, LARGEINT toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HOTSEL=1 prints the top methods and receiver-classes by activation — how the real image actually spends its sends (surfaced that LargeInteger arithmetic + timer/Delay scheduling dominate, not interpreter overhead). --until-ms stops at a virtual-time target to measure 'time for the same work' (needed for opts that cut sends-per-op rather than cost-per-send). LARGEINT=0 disables the LargeInteger primitives for A/B. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index ad55d3d3..1b6e3caa 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -30,6 +30,7 @@ function argValue(name, deflt) { return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : deflt; } var maxSends = parseInt(argValue("--sends", "20000000"), 10); +var untilMs = parseInt(argValue("--until-ms", "0"), 10); // parar al alcanzar este tiempo-virtual (mide "tiempo para el mismo trabajo") var uiW = parseInt(argValue("--width", "1024"), 10); var uiH = parseInt(argValue("--height", "768"), 10); // Grabación de eventos (--events): la leemos temprano para dimensionar el display @@ -183,6 +184,7 @@ image.readFromBuffer(data.buffer, function startRunning() { } var vm = new Squeak.Interpreter(image, display, useFrames ? { stackZone: true, jit2: useJit2 } : {}); if (noJit) vm.compiler = null; + if (process.env.LARGEINT === "0") vm.primHandler.largeIntPrims = false; // A/B: desactivar prims LargeInteger if (process.env.JIT2DBG) vm.jit2Debug = true; if (process.env.SEMDBG) { var origSS = vm.primHandler.synchronousSignal; @@ -239,6 +241,31 @@ image.readFromBuffer(data.buffer, function startRunning() { }; } if (process.env.PFNDBG) vm.primFnDebug = true; + if (process.env.HOTSEL) { + // top métodos Smalltalk por activaciones (rcvrClass>>selector) — muestra + // en qué gasta sends la imagen real (¿desperdicio algorítmico o costo base?) + var hot = {}; + var origENMh = vm.executeNewMethod; + vm.executeNewMethod = function(r, m, ac, pi, oc, sel) { + var key = (oc ? oc.className() : (vm.getClass(r).className())) + ">>" + (sel && sel.bytesAsString ? sel.bytesAsString() : "?"); + hot[key] = (hot[key] || 0) + 1; + return origENMh.call(vm, r, m, ac, pi, oc, sel); + }; + process.on("exit", function() { + var arr = Object.keys(hot).map(function(k){ return [k, hot[k]]; }).sort(function(a,b){ return b[1]-a[1]; }); + var tot = arr.reduce(function(s,e){ return s+e[1]; }, 0); + console.error("HOTSEL top 30 de " + arr.length + " selectores (" + tot + " sends):"); + arr.slice(0, 30).forEach(function(e){ console.error(" " + (100*e[1]/tot).toFixed(1) + "% " + e[1] + " " + e[0]); }); + // agregado por clase receptora + var byC = {}; + arr.forEach(function(e){ var c = e[0].split(">>")[0]; byC[c] = (byC[c]||0) + e[1]; }); + var carr = Object.keys(byC).map(function(k){ return [k, byC[k]]; }).sort(function(a,b){ return b[1]-a[1]; }); + console.error("HOTSEL top 15 por CLASE receptora:"); + carr.slice(0, 15).forEach(function(e){ console.error(" " + (100*e[1]/tot).toFixed(1) + "% " + e[1] + " " + e[0]); }); + var largeInt = (byC["LargePositiveInteger"]||0) + (byC["LargeNegativeInteger"]||0); + console.error("→ LargeInteger (receptor): " + (100*largeInt/tot).toFixed(1) + "% de sends"); + }); + } if (process.env.UNWINDDBG) { // cobertura: contar activaciones por selector (watchlist de unwind/terminación) // + process switches + resumes de contextos casados. Responde "¿el workload @@ -576,6 +603,7 @@ image.readFromBuffer(data.buffer, function startRunning() { var frozenYields = 0; while (vm.sendCount < maxSends) { if (display.quitFlag) { stopReason = "quit"; break; } + if (untilMs > 0 && virtualMs >= untilMs) { stopReason = "untilMs"; break; } if (vm.frozen) { // ceder el event loop para que el unfreeze diferido corra if (++frozenYields > 1000000) { stopReason = "frozen-livelock"; break; } From 5e90a504639cd85c3e81998f64f60a2d6eb6579a Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:35:16 -0300 Subject: [PATCH 29/99] =?UTF-8?q?[perf]=20harness:=20--until-stable=20(qui?= =?UTF-8?q?escence=20metric)=20=E2=80=94=20honest=20'cost=20of=20the=20act?= =?UTF-8?q?ion'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness warps the virtual clock when the image waits on a timer, so the Delay/stepping loop (rate-limited to ~60fps in the browser) runs at full speed headless — inflating the timer/scheduling machinery in fixed-send and fixed- virtual-time measurements. --until-stable stops when the Display bits stop changing for ~1M sends and events are exhausted: the real work (render the result) is done, excluding the over-represented idle-spin. This is the honest 'time to render the result' metric. Under it, both the LargeInteger primitives and the entire stack-zone+jit2 give ~0% on the felt Dialogo workload (both reach the final frame 8ff54efc at the same ~10M sends, same wall). The earlier +5-21% were artifacts of send-heavy microbenchmarks or the clock-warp-inflated timer loop. Felt-window profile: V8 LoadIC (megamorphic property access on Squeak-objects-as-JS-objects) ~26%, executeNewMethod ~12%, write barriers/stores ~9%, BitBlt ~2%, GC ~2.5%. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 1b6e3caa..475ec043 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -31,6 +31,7 @@ function argValue(name, deflt) { } var maxSends = parseInt(argValue("--sends", "20000000"), 10); var untilMs = parseInt(argValue("--until-ms", "0"), 10); // parar al alcanzar este tiempo-virtual (mide "tiempo para el mismo trabajo") +var untilStable = args.indexOf("--until-stable") >= 0; // parar cuando el dibujo se estabiliza (= trabajo real terminado; excluye idle-spin) var uiW = parseInt(argValue("--width", "1024"), 10); var uiH = parseInt(argValue("--height", "768"), 10); // Grabación de eventos (--events): la leemos temprano para dimensionar el display @@ -594,6 +595,22 @@ image.readFromBuffer(data.buffer, function startRunning() { } } + // Detección de quiescencia: el dibujo dejó de cambiar por 2 chequeos seguidos + // (~1M sends) y no quedan eventos → el trabajo real terminó. Mide el costo de + // la acción sin el idle-spin que el clock-warp sobre-representa. + var stLastHash = null, stStable = 0, stLastCheck = 0; + function checkStable() { + if (!untilStable || !uiMod) return false; + if (vm.sendCount - stLastCheck < 500000) return false; + stLastCheck = vm.sendCount; + var fp = uiMod.displayFingerprint(vm), h = fp ? fp.hash : null; + var eventsDone = !evSched || evIdx >= evSched.length; + if (h && h === stLastHash && eventsDone) { if (++stStable >= 2) return true; } + else stStable = 0; + stLastHash = h; + return false; + } + var noop = function() {}; var wallStart = process.hrtime.bigint(); clockRunning = true; @@ -614,6 +631,7 @@ image.readFromBuffer(data.buffer, function startRunning() { var result = vm.interpret(5, noop); // thenDo: freeze necesita continueFunc if (result === "frozen") continue; slices++; + if (checkStable()) { stopReason = "stable"; break; } // 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 From 9100964f7cd16560c889405efcb215f408456931 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:17:44 -0300 Subject: [PATCH 30/99] [perf] monomorphize object representation: single shared constructor (~7-8% felt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-class instance constructors (one new Function per Squeak class) gave V8 a distinct hidden class (map) per class, so the VM's hottest property loads (.sqClass, .pointers[i], .bytes on objects of any class) went megamorphic — the single dominant cost of the real workload (LoadIC family ~26% of felt-window ticks). classInstProto now returns ONE shared constructor for all classes, so V8 sees only ~4-5 maps (by storage format), turning those loads mono/low-poly. LoadIC disappears from the profile. Measured with the honest metric (wall to render the result, not a send-heavy microbench): ~7-8% faster on the real Dialogo workload (4363 -> 4022 ms), and byte-identical semantics — same trace hash, same drawing (8ff54efc), ctx==frames, on both the Dialogo V3 image and Cuis. General win: helps any Squeak image, not just Dialogo. This is the opposite result of the earlier 'flat shape' experiment (-7%), which put ALL storage fields in every object and was measured send-heavy; here the per-format fields are kept and only the constructor is shared. Opt out (per-class names in devtools) with Squeak.perClassShape = true. Note: frames+jit2 is still ~0% on this workload even with LoadIC gone — the stack-zone marriage/sync overhead cancels the jit2 gain on Dialogo's heavy process-switching. The win here is the representation change, independent of the stack zone. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 1 + vm.object.js | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 475ec043..18e1197e 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -173,6 +173,7 @@ fs.readdirSync(imageDir).forEach(function(f) { } }); +if (process.env.PERCLASS === "1") Squeak.perClassShape = true; // A/B: volver a constructores por-clase (revertir monomorfización) var data = fs.readFileSync(imagePath); var image = new Squeak.Image(imagePath.replace(/\.image$/, "")); image.readFromBuffer(data.buffer, function startRunning() { diff --git a/vm.object.js b/vm.object.js index cca04567..5bbdbd68 100644 --- a/vm.object.js +++ b/vm.object.js @@ -481,11 +481,27 @@ Object.subclass('Squeak.Object', }, classInstProto: function(className) { if (this.instProto) return this.instProto; + // Monomorfización (default desde 2026-07-12): un ÚNICO constructor + // compartido para todos los objetos → V8 ve pocos maps (~4-5, por formato + // pointers/bytes/words/float) en vez de cientos (uno por clase), volviendo + // el LoadIC de `.sqClass`/`.pointers`/`.bytes` mono/poli en vez de + // megamórfico. Medido con la vara honesta (tiempo hasta renderizar el + // resultado, no microbench): ~7% más rápido en el workload real de Dialogo, + // semántica byte-idéntica (trace + dibujo + ctx≡frames en V3 y Cuis). El + // LoadIC desaparece del perfil (era ~26% del trabajo felt). Distinto del + // intento "flat shape" previo (−7%, shape con TODOS los campos y medido + // send-heavy engañoso): acá los campos por-formato se mantienen, solo se + // comparte el constructor. Opt-out para debugging (nombres por clase en + // devtools): Squeak.perClassShape = true. + if (Squeak.perClassShape !== true) { + if (!Squeak.sharedInstProto) { + Squeak.sharedInstProto = new Function("return function SqueakObject() { this.oop = 0; this.hash = 0; this.dirty = false; this.mark = false; this.nextObject = null; };")(); + Squeak.sharedInstProto.prototype = this.defaultInst().prototype; + } + Object.defineProperty(this, 'instProto', { value: Squeak.sharedInstProto }); + return Squeak.sharedInstProto; + } 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,'_'); From bbe296f1e040a591b5dbc50a759b7b52bab692e1 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:32:01 -0300 Subject: [PATCH 31/99] [perf] correct the monomorphization mechanism note (win is real, attribution was wrong) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ~6.6% win from the shared constructor is real and reproducible (best-of-5 interleaved: 4022 vs 4307 ms, mono faster in all 5). But the earlier claim that it 'eliminates LoadIC (~26%)' was wrong — that reading came from a noisy profile sample. A/B profiling shows the actual mechanism: monomorphization collapses the MEGAMORPHIC IC operations to monomorphic — StoreIC of initInstanceOf (156->27 ticks) and LoadIC_Megamorphic (258->32). The regular LoadIC (loads of .pointers/ .sqClass) remains as the top residual cost (~18%): the loads still happen, now cheap but not free. Fully removing them needs objects in linear memory (WASM), which stays the open lever — this change is a partial step (megamorphic->mono), not the full one. Co-Authored-By: Claude Fable 5 --- vm.object.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/vm.object.js b/vm.object.js index 5bbdbd68..97832237 100644 --- a/vm.object.js +++ b/vm.object.js @@ -483,16 +483,20 @@ Object.subclass('Squeak.Object', if (this.instProto) return this.instProto; // Monomorfización (default desde 2026-07-12): un ÚNICO constructor // compartido para todos los objetos → V8 ve pocos maps (~4-5, por formato - // pointers/bytes/words/float) en vez de cientos (uno por clase), volviendo - // el LoadIC de `.sqClass`/`.pointers`/`.bytes` mono/poli en vez de - // megamórfico. Medido con la vara honesta (tiempo hasta renderizar el - // resultado, no microbench): ~7% más rápido en el workload real de Dialogo, - // semántica byte-idéntica (trace + dibujo + ctx≡frames en V3 y Cuis). El - // LoadIC desaparece del perfil (era ~26% del trabajo felt). Distinto del - // intento "flat shape" previo (−7%, shape con TODOS los campos y medido - // send-heavy engañoso): acá los campos por-formato se mantienen, solo se - // comparte el constructor. Opt-out para debugging (nombres por clase en - // devtools): Squeak.perClassShape = true. + // pointers/bytes/words/float) en vez de cientos (uno por clase). Medido con + // la vara honesta (tiempo hasta renderizar el resultado, no microbench): + // **6.6% más rápido** en el workload real de Dialogo (best-of-5 intercalado: + // 4022 vs 4307 ms, más rápido en las 5 corridas), semántica byte-idéntica + // (trace + dibujo + ctx≡frames en V3 y Cuis). Mecanismo (verificado por + // perfil A/B, NO es "matar el LoadIC" como se dijo primero): las operaciones + // IC MEGAMÓRFICAS caen a monomórficas — StoreIC de initInstanceOf (sqClass=, + // hash=, _format=, pointers=) y LoadIC_Megamorphic. El LoadIC regular + // (loads de `.pointers`/`.sqClass`) QUEDA como costo residual top (~18%): los + // loads siguen ocurriendo, ahora baratos pero no gratis; eliminarlos del + // todo requiere objetos en memoria lineal (WASM). Distinto del intento "flat + // shape" previo (−7%, shape con TODOS los campos, medido send-heavy): acá los + // campos por-formato se mantienen, solo se comparte el constructor. Opt-out + // (nombres por clase en devtools): Squeak.perClassShape = true. if (Squeak.perClassShape !== true) { if (!Squeak.sharedInstProto) { Squeak.sharedInstProto = new Function("return function SqueakObject() { this.oop = 0; this.hash = 0; this.dirty = false; this.mark = false; this.nextObject = null; };")(); From cdc1cb74d5f19a5689e1a0e36d7260089990dd2b Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:35:05 -0300 Subject: [PATCH 32/99] =?UTF-8?q?[perf]=20spike:=20linear-memory=20(WASM)?= =?UTF-8?q?=20vs=20monomorphic=20JS=20objects=20=E2=80=94=20kills=20the=20?= =?UTF-8?q?WASM-linear=20bet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measures field access in the interpreter's hot pattern (context alloc + 9 field writes + inst-var reads per send/return) across four object representations, on the same V8 Node runs: A- megamorphic JS objects (300 shapes, = pre-fix per-class): 31373 ms A monomorphic JS objects (= current default post-fix): 63 ms B Int32Array heap from JS (linear memory, no WASM): 80 ms C linear memory from WASM (raw i32.load/store): 64 ms Conclusion: monomorphic JS objects are AS FAST as raw WASM linear memory (C/A = 1.01x). The megamorphism was the only real object-access cost (~500x on this microbench), and we already eliminated it in pure JS with the shared- constructor fix (+6.6% felt, banked). Moving objects to linear memory — the core of the WASM bet — gives ~0% over what we already have. Linear memory in JS (B) is even 27% slower (bounds checks). The earlier phase-0 2.7x was method compilation on fib (a different lever we already have via jit2, which is 0% felt here), not linear-memory objects. The WASM-linear-memory rewrite would give ~0% felt on Dialogo. This spike saved that months-long bet. We are at the JS ceiling for object representation. Co-Authored-By: Claude Fable 5 --- perf/spikes/linmem-vs-jsobj.js | 166 +++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 perf/spikes/linmem-vs-jsobj.js diff --git a/perf/spikes/linmem-vs-jsobj.js b/perf/spikes/linmem-vs-jsobj.js new file mode 100644 index 00000000..080e09a1 --- /dev/null +++ b/perf/spikes/linmem-vs-jsobj.js @@ -0,0 +1,166 @@ +"use strict"; +// Spike: acceso a campos de objetos en el patrón del intérprete, 3 representaciones: +// A: objetos JS monomórficos (= default actual del VM) +// B: heap Int32Array accedido desde JS (memoria lineal SIN WASM) +// C: memoria lineal accedida desde WASM (loads/stores crudos) +// +// Decide la apuesta WASM: si C no gana claramente a A, el "impuesto de objetos +// JS" no es una palanca real y el rewrite WASM (meses) no vale. Si C >> A, sí. +// +// Loop plano que modela el trabajo de campos de un send+return: escribir 9 slots +// del contexto (method/sender/receiver/pc/sp/args/temps), leer 4 inst-vars del +// receiver + 2 args, computar, acumular. Contexto reciclado (dirección fija) = +// el caso común del free-list del VM. Las 3 reps hacen el MISMO trabajo lógico. + +var ITERS = 40000000; +var REPS = 7; + +// ---- A: objetos JS monomórficos ---- +function CtxA(){ this.method=0;this.sender=0;this.pc=0;this.sp=0;this.receiver=0;this.t0=0;this.t1=0;this.a0=0;this.a1=0; } +function RcvA(){ this.cls=0;this.iv0=0;this.iv1=0;this.iv2=0;this.iv3=0; } +function runA(iters){ + var rcvr=new RcvA(); rcvr.cls=42;rcvr.iv0=1;rcvr.iv1=2;rcvr.iv2=3;rcvr.iv3=4; + var ctx=new CtxA(); var acc=0; + for(var i=0;i>>=7;if(n)x|=0x80;b.push(x);}while(n); } + function i32(n){ // signed LEB128 + var more=1; while(more){ var x=n&0x7f; n>>=7; if((n===0&&!(x&0x40))||(n===-1&&(x&0x40)))more=0; else x|=0x80; b.push(x);} } + // opcodes + var GET=0x20,SET=0x21,CONST=0x41,ADD=0x6a,AND=0x71,GES=0x4e,STORE=0x36,LOAD=0x28, + LOOP=0x03,BLOCK=0x02,BRIF=0x0d,BR=0x0c,END=0x0b,VOID=0x40; + // memarg = align(u32) offset(u32); usamos align=2 (i32), offset=0 (dir absoluta en operando) + function store(){ b.push(STORE); u32(2); u32(0); } + function load(){ b.push(LOAD); u32(2); u32(0); } + // --- header --- + b.push(0,0x61,0x73,0x6d, 1,0,0,0); + // --- type section: () one type (i32)->i32 --- + var ts=[0x01, 0x60,0x01,0x7f,0x01,0x7f]; sect(1,ts); + // --- function section: 1 func, type 0 --- + sect(3,[0x01,0x00]); + // --- memory section: 1 mem, min 1 page --- + sect(5,[0x01,0x00,0x01]); + // --- export section: "run" func0, "mem" mem0 --- + var es=[0x02, 0x03,0x72,0x75,0x6e,0x00,0x00, 0x03,0x6d,0x65,0x6d,0x02,0x00]; sect(7,es); + // --- code section --- + // locals: i(1)=local1, acc=local2, x=local3 (param iters=local0) + var body=[]; + var save=b; b=body; + // local decls: 1 group of 3 i32 + u32(1); u32(3); b.push(0x7f); + // acc=0 + b.push(CONST); i32(0); b.push(SET); u32(2); + // i=0 + b.push(CONST); i32(0); b.push(SET); u32(1); + b.push(BLOCK,VOID); + b.push(LOOP,VOID); + // if i>=iters break: local.get i; local.get iters; ge_s; br_if 1 + b.push(GET);u32(1); b.push(GET);u32(0); b.push(GES); b.push(BRIF);u32(1); + // write ctx fields: mem[128]=7 + function wr(addr,emitVal){ b.push(CONST);i32(addr); emitVal(); store(); } + wr(128,function(){b.push(CONST);i32(7);}); + wr(132,function(){b.push(GET);u32(1);}); // sender=i + wr(136,function(){b.push(CONST);i32(0);}); + wr(140,function(){b.push(CONST);i32(0);}); + wr(144,function(){b.push(CONST);i32(32);}); // receiver=32 + wr(148,function(){b.push(CONST);i32(0);}); + wr(152,function(){b.push(CONST);i32(0);}); + wr(156,function(){b.push(GET);u32(1);}); // a0=i + wr(160,function(){b.push(GET);u32(1);b.push(CONST);i32(1);b.push(ADD);}); // a1=i+1 + // x = mem[36]+mem[40]+mem[44]+mem[48]+mem[156]+mem[160] + b.push(CONST);i32(36);load(); + b.push(CONST);i32(40);load();b.push(ADD); + b.push(CONST);i32(44);load();b.push(ADD); + b.push(CONST);i32(48);load();b.push(ADD); + b.push(CONST);i32(156);load();b.push(ADD); + b.push(CONST);i32(160);load();b.push(ADD); + b.push(SET);u32(3); + // mem[148]=x (t0) + b.push(CONST);i32(148); b.push(GET);u32(3); store(); + // acc += mem[148] & 1 + b.push(GET);u32(2); b.push(CONST);i32(148);load(); b.push(CONST);i32(1);b.push(AND); b.push(ADD); b.push(SET);u32(2); + // i++ + b.push(GET);u32(1);b.push(CONST);i32(1);b.push(ADD);b.push(SET);u32(1); + b.push(BR);u32(0); + b.push(END); // loop + b.push(END); // block + b.push(GET);u32(2); // return acc + b.push(END); // func + var code=b; b=save; + var cs=[0x01]; // 1 body + var bl=code.length; // body length + var lenBytes=[]; (function(n){do{var x=n&0x7f;n>>>=7;if(n)x|=0x80;lenBytes.push(x);}while(n);})(bl); + cs=cs.concat(lenBytes).concat(code); + sect(10,cs); + function sect(id,content){ save2=b; b=[]; u32(content.length); var lb=b; b=save2; b.push(id); for(var k=0;k Date: Mon, 20 Jul 2026 21:12:54 -0300 Subject: [PATCH 33/99] [perf] cache decoded named-primitive module/function names on the method (ctx mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doNamedPrimitive (primitive 117, the generic named-primitive path) called bytesAsString twice per invocation to decode the module and function name from the method's first literal — names that never change for a given method. A browser Performance profile of a real drawing/scripting session showed bytesAsString as the #1 self-time cost at ~10% (ctx mode; the stack-zone/frames path already avoids this via its primFn cache). Cache the decoded strings on the method (_primModName/_primFuncName) so bytesAsString runs once per method, not per call. Correctness preserved: ctx==frames trace and display byte-identical. Headless shows only ~0.6% because the synthetic auto-load+mouse workload barely exercises prim-117; the win lands in the browser's drawing/scripting workload (the 10%), to be confirmed by re-profiling there. Co-Authored-By: Claude Fable 5 --- vm.primitives.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/vm.primitives.js b/vm.primitives.js index 48a3dbf4..c041eb3f 100644 --- a/vm.primitives.js +++ b/vm.primitives.js @@ -463,12 +463,20 @@ Object.subclass('Squeak.Primitives', return this.success; }, doNamedPrimitive: function(argCount, primMethod) { - if (primMethod.pointersSize() < 2) return false; - var firstLiteral = primMethod.pointers[1]; // skip method header - if (firstLiteral.pointersSize() !== 4) return false; + // Los nombres de módulo/función viven en el 1er literal y NO cambian para + // un método dado. Decodificarlos con bytesAsString EN CADA llamada era ~10% + // del trabajo en workloads con muchos primitivos-117 (medido en profile del + // browser, modo ctx). Se cachean en el método (el modo frames ya cachea la + // función resuelta vía primFn; esto es el equivalente mínimo para ctx). + var moduleName = primMethod._primModName, functionName = primMethod._primFuncName; + if (moduleName === undefined) { + if (primMethod.pointersSize() < 2) return false; + var firstLiteral = primMethod.pointers[1]; // skip method header + if (firstLiteral.pointersSize() !== 4) return false; + moduleName = primMethod._primModName = firstLiteral.pointers[0].bytesAsString(); + functionName = primMethod._primFuncName = firstLiteral.pointers[1].bytesAsString(); + } this.primMethod = primMethod; - var moduleName = firstLiteral.pointers[0].bytesAsString(); - var functionName = firstLiteral.pointers[1].bytesAsString(); return this.namedPrimitive(moduleName, functionName, argCount); }, fakePrimitive: function(prim, retVal, argCount) { From 6a2068c8f732ac942637af8f1a93261d6d0e9be3 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:35:01 -0300 Subject: [PATCH 34/99] =?UTF-8?q?[perf]=20browser.js=20=E2=80=94=20drive?= =?UTF-8?q?=20the=20real=20browser=20headless=20(own=20feedback=20loop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launches the actual run/ page in headless Chrome (puppeteer-core + the installed Chrome, no Chromium download), captures console + page errors + full JS stacks, confirms the VM mode, and dispatches real mouse events (which go through recordMouseEvent -> display.runNow -> interpret — the reentrancy the pure-node harness never modelled). Auto-dismisses the alert() that squeak.js raises after a crash (it blocks headless Chrome forever otherwise — that was the hang). Image is served over HTTP (image=/Dialogo.32bits.image from repo root, gitignored) and the project files are loaded on demand via SqueakJS templates + sqindex.json (a served dialogo-fs/ dir, gitignored), so a fresh headless Chrome reproduces the user's environment without 140MB of preloaded IndexedDB. With this, reproduced the stackZone+jit2 mouse crash headless for the first time: undefined.sqClass in primitiveBlockValue via jit2-compiled ContextPart>>resume:through:, cascading into 'invalid PC' in jit1-compiled ContextPart>>terminateTo:. Setup: node perf/harness/browser.js '' --drive [--events f.json] [--secs N]. Co-Authored-By: Claude Fable 5 --- perf/harness/browser.js | 126 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 perf/harness/browser.js diff --git a/perf/harness/browser.js b/perf/harness/browser.js new file mode 100644 index 00000000..d97cf064 --- /dev/null +++ b/perf/harness/browser.js @@ -0,0 +1,126 @@ +"use strict"; +// Driver de browser REAL (Chrome headless vía puppeteer-core) para tener feedback +// propio sin depender del usuario: levanta run/ con la imagen servida por HTTP, +// captura consola + errores, opcionalmente inyecta eventos de mouse REALES (que +// pasan por recordMouseEvent → display.runNow → interpret, la reentrancia que +// reproduce el crash de stackZone+jit2 que el harness headless no alcanzaba). +// +// node perf/harness/browser.js [url] [--drive] [--secs N] [--events file.json] +// default url: http://localhost:8081/run/#stackZone&jit2&image=/Dialogo.32bits.image +// --drive mueve el mouse por el canvas + clicks (dispara el crash) +// --secs N segundos a esperar tras el boot (default 8) +// --events F replay de una grabación {width,height,events:[{at,ev}]} +// --shot F.png guarda screenshot al final + +var fs = require("fs"); +var CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +var args = process.argv.slice(2); +function opt(name, d) { var i = args.indexOf(name); return i >= 0 && args[i+1] && !args[i+1].startsWith("--") ? args[i+1] : d; } +var url = args.find(function(a){ return a.startsWith("http"); }) + || "http://localhost:8081/run/#stackZone&jit2&image=/Dialogo.32bits.image"; +var drive = args.indexOf("--drive") >= 0; +var secs = parseInt(opt("--secs", "8"), 10); +var eventsFile = opt("--events", null); +var shot = opt("--shot", null); + +var HARD_TIMEOUT = parseInt(opt("--timeout", "60"), 10) * 1000; +var hardKill = setTimeout(function(){ console.error("HARD TIMEOUT — matando"); process.exit(3); }, HARD_TIMEOUT); + +(async function() { + var puppeteer = (await import("puppeteer-core")).default; + var browser = await puppeteer.launch({ + executablePath: CHROME, + headless: "new", + args: ["--no-sandbox", "--disable-gpu", "--window-size=1024,820", "--use-gl=swiftshader"], + }); + var page = await browser.newPage(); + await page.setViewport({ width: 1024, height: 820 }); + // capturar stacks completos de los errores (console.error(errorObj) pierde el stack en msg.text()) + await page.evaluateOnNewDocument(function() { + window.__sqErrors = []; + var oe = console.error; + console.error = function() { + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (a && a.stack) window.__sqErrors.push(String(a.stack)); + } + return oe.apply(console, arguments); + }; + window.addEventListener("error", function(e) { if (e.error && e.error.stack) window.__sqErrors.push(String(e.error.stack)); }); + }); + + var logs = [], errors = [], ready = false; + page.on("console", function(msg) { + var t = msg.text(); + logs.push("[" + msg.type() + "] " + t); + if (/squeak: ready/.test(t)) ready = true; + if (msg.type() === "error") errors.push(t); + }); + page.on("pageerror", function(err) { errors.push("PAGEERROR: " + err.message); }); + // los alert() de squeak.js tras un crash bloquean headless — auto-dismiss y registrar + page.on("dialog", function(d) { errors.push("ALERT: " + d.message().split("\n")[0]); d.dismiss().catch(function(){}); }); + page.on("requestfailed", function(req) { errors.push("REQFAIL: " + req.url() + " " + (req.failure()||{}).errorText); }); + + console.log("navegando: " + url); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(function(e){ errors.push("GOTO: " + e.message); }); + + // esperar boot (squeak: ready) hasta 40s + var t0 = Date.now(); + while (!ready && Date.now() - t0 < 40000) await new Promise(function(r){ setTimeout(r, 200); }); + console.log("boot: " + (ready ? "READY en " + ((Date.now()-t0)/1000).toFixed(1) + "s" : "NO llegó a ready (timeout)")); + + // confirmar modo + var mode = await page.evaluate(function() { + var vm = window.SqueakJS && SqueakJS.vm; + return vm ? { useStackZone: !!vm.useStackZone, leafCount: vm.compiler && vm.compiler.leafCount, sends: vm.sendCount } : null; + }).catch(function(){ return null; }); + console.log("modo: " + JSON.stringify(mode)); + + if (eventsFile && fs.existsSync(eventsFile)) { + var rec = JSON.parse(fs.readFileSync(eventsFile, "utf8")); + var evs = Array.isArray(rec) ? rec : rec.events; + console.log("replay de " + evs.length + " eventos grabados…"); + for (var i = 0; i < evs.length; i++) { + var e = evs[i].ev || evs[i]; + if (e[0] === 1) { // mouse + await page.mouse.move(e[2], e[3]); + if (e[4] & 4) await page.mouse.down(); + else if (evs[i-1] && ((evs[i-1].ev||evs[i-1])[4] & 4)) await page.mouse.up(); + } + if (i % 20 === 0) await new Promise(function(r){ setTimeout(r, 10); }); + } + } else if (drive) { + console.log("moviendo el mouse por el canvas + clicks…"); + for (var k = 0; k < 30; k++) { + await page.mouse.move(100 + (k*27) % 800, 100 + (k*19) % 600); + await new Promise(function(r){ setTimeout(r, 30); }); + } + await page.mouse.click(400, 300); + await new Promise(function(r){ setTimeout(r, 100); }); + await page.mouse.move(420, 320); await page.mouse.down(); + for (var k = 0; k < 10; k++) { await page.mouse.move(420 + k*20, 320 + k*10); await new Promise(function(r){ setTimeout(r, 20); }); } + await page.mouse.up(); + } + + await new Promise(function(r){ setTimeout(r, secs * 1000); }); + var sends2 = await page.evaluate(function(){ return window.SqueakJS && SqueakJS.vm ? SqueakJS.vm.sendCount : null; }).catch(function(){ return null; }); + if (shot) { await page.screenshot({ path: shot }).catch(function(){}); console.log("screenshot: " + shot); } + + var stacks = await page.evaluate(function(){ return window.__sqErrors || []; }).catch(function(){ return []; }); + if (stacks.length) { + var uniq = {}; + stacks.forEach(function(s){ var key = s.split("\n").slice(0,6).join("\n"); uniq[key] = (uniq[key]||0)+1; }); + console.log("\n=== STACKS ÚNICOS (" + stacks.length + " errores) ==="); + Object.keys(uniq).forEach(function(k){ console.log("\n×" + uniq[k] + ":\n" + k); }); + } + console.log("\n=== sendCount final: " + sends2 + " ==="); + console.log("=== ERRORES (" + errors.length + ") ==="); + var seen = {}; + errors.forEach(function(e){ var key = e.slice(0, 80); if (!seen[key]) { seen[key] = 0; } seen[key]++; }); + Object.keys(seen).forEach(function(k){ console.log(" ×" + seen[k] + " " + k); }); + if (process.env.FULLLOG) { console.log("\n=== LOG COMPLETO ==="); logs.forEach(function(l){ console.log(" " + l); }); } + + await browser.close(); + process.exit(errors.length ? 1 : 0); +})().catch(function(e){ console.error("FATAL:", e); process.exit(2); }); From 3c4d1e3c460acc7d9edb1da9ad292132a35a38d6 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:32:54 -0300 Subject: [PATCH 35/99] [perf] browser.js: --trace (capture Chrome perf trace) for self-measurement Adds --trace FILE to capture a Chrome Performance trace during the driven workload (same format as DevTools export), so freeze/hotspot analysis can be run headless via perf/harness/parseprof-style parsing without the user re-recording. Also gitignores the local Dialogo image + served dialogo-fs/ used by the loop. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 ++ perf/harness/browser.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 695d7e14..7bed5c07 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/*.min.js* perf/harness/golden.json ws/client/cuis.changes ws/client/CuisDebug-*.log +/Dialogo.32bits.image +/dialogo-fs/ diff --git a/perf/harness/browser.js b/perf/harness/browser.js index d97cf064..3ee03016 100644 --- a/perf/harness/browser.js +++ b/perf/harness/browser.js @@ -77,6 +77,9 @@ var hardKill = setTimeout(function(){ console.error("HARD TIMEOUT — matando"); }).catch(function(){ return null; }); console.log("modo: " + JSON.stringify(mode)); + var traceFile = opt("--trace", null); + if (traceFile) { await page.tracing.start({ path: traceFile, categories: ["devtools.timeline", "v8.cpu_profiler", "disabled-by-default-v8.cpu_profiler"] }); console.log("tracing → " + traceFile); } + if (eventsFile && fs.existsSync(eventsFile)) { var rec = JSON.parse(fs.readFileSync(eventsFile, "utf8")); var evs = Array.isArray(rec) ? rec : rec.events; @@ -104,6 +107,7 @@ var hardKill = setTimeout(function(){ console.error("HARD TIMEOUT — matando"); } await new Promise(function(r){ setTimeout(r, secs * 1000); }); + if (traceFile) { await page.tracing.stop(); console.log("trace guardado: " + traceFile); } var sends2 = await page.evaluate(function(){ return window.SqueakJS && SqueakJS.vm ? SqueakJS.vm.sendCount : null; }).catch(function(){ return null; }); if (shot) { await page.screenshot({ path: shot }).catch(function(){}); console.log("screenshot: " + shot); } From c9f72313752cfebec9feeb34fd4255199ed380de Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:57:33 -0300 Subject: [PATCH 36/99] [perf] opt-in #sliceMs=N: trade a little throughput for far fewer visible freezes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit squeak.js's run() gives the interpreter a fixed 50ms wall-clock budget per slice before yielding to the browser (vm.interpret(50, ...)); the interrupt-check cadence is every ~1000 sends, so the interpreter COULD yield far more often — 50ms was just an arbitrary default, not an architectural limit. This is a scheduling-fairness lever, orthogonal to everything else tried in this project: it doesn't reduce total CPU-seconds, it reduces how long any single browser task blocks the main thread, which is what the spinner literally measures. Measured on a driven mouse-move/click Chrome session (browser.js --drive --trace), 5s window: baseline (50ms slices) had 210 tasks >=20ms and 171 >=50ms (max 215ms). #sliceMs=8: 10 tasks >=20ms, 5 >=50ms (max 134ms) but sendCount dropped 55.2M->32.7M in the window (-41%, likely setTimeout's ~4-6ms scheduling floor eating a bigger fraction of a smaller slice — possibly inflated by headless automation overhead vs a real session). #sliceMs=16 recovers most throughput (39.7M, -28%) while keeping nearly all the smoothness win (4 tasks >=50ms). Opt-in via URL (#sliceMs=16), default unchanged (50) — this trades throughput for perceived responsiveness, a product judgment call, not a pure win. Natural follow-up if pursued further: replace the setTimeout(fn,0) reschedule with a MessageChannel/postMessage-based scheduler (the classic 'setImmediate' shim trick) to avoid the timer floor and get smoothness without the throughput cost. Co-Authored-By: Claude Fable 5 --- perf/harness/browser.js | 2 +- squeak.js | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/perf/harness/browser.js b/perf/harness/browser.js index 3ee03016..f52c73c2 100644 --- a/perf/harness/browser.js +++ b/perf/harness/browser.js @@ -78,7 +78,7 @@ var hardKill = setTimeout(function(){ console.error("HARD TIMEOUT — matando"); console.log("modo: " + JSON.stringify(mode)); var traceFile = opt("--trace", null); - if (traceFile) { await page.tracing.start({ path: traceFile, categories: ["devtools.timeline", "v8.cpu_profiler", "disabled-by-default-v8.cpu_profiler"] }); console.log("tracing → " + traceFile); } + if (traceFile) { await page.tracing.start({ path: traceFile, categories: ["devtools.timeline", "toplevel", "v8.cpu_profiler", "disabled-by-default-v8.cpu_profiler"] }); console.log("tracing → " + traceFile); } if (eventsFile && fs.existsSync(eventsFile)) { var rec = JSON.parse(fs.readFileSync(eventsFile, "utf8")); diff --git a/squeak.js b/squeak.js index 396c45e6..166f2852 100644 --- a/squeak.js +++ b/squeak.js @@ -94,6 +94,14 @@ Object.extend(Squeak, { // UI namespace window.SqueakJS = {}; +// Time-slice budget (ms of wall-clock) the interpreter runs before yielding back +// to the browser event loop, so it can paint/handle input between slices. Default +// 50ms; #sliceMs=N in the URL overrides it (spike: smaller slices trade a little +// throughput for much shorter perceived freezes on long CPU bursts). +(function() { + var m = /[#&]sliceMs=([0-9]+)/.exec(location.hash || location.search); + if (m) SqueakJS.sliceMs = parseInt(m[1], 10); +})(); ////////////////////////////////////////////////////////////////////////////// // display & event setup @@ -1179,7 +1187,7 @@ SqueakJS.runImage = function(buffer, name, display, options) { function run() { try { if (display.quitFlag) SqueakJS.onQuit(vm, display, options); - else vm.interpret(50, function runAgain(ms) { + else vm.interpret(SqueakJS.sliceMs || 50, function runAgain(ms) { if (ms == "sleep") ms = 200; if (spinner) updateSpinner(spinner, ms, vm, display); loop = window.setTimeout(run, ms); From ccaab185e87ebdc8d1adbacfb8a4dd650bc077a0 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:16:43 -0300 Subject: [PATCH 37/99] [perf] harness: CLEANBLK + COUNTBV probes (block marriage source analysis) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLEANBLK counts how many created closures are 'clean' (never touch their outer context). COUNTBV counts block/closure valuations. Findings on Dialogo: the stack zone's ~1.05M context marriages come from block/closure VALUATION (primitiveBlockValue 392k + activateNewClosureMethod 426k), not creation (23k closures, 49.7% clean). So clean-block detection at creation can't recover the marriage cost — it's paid at valuation, when the caller frame is married to give the block a caller/home. Co-Authored-By: Claude Fable 5 --- perf/harness/difftrace.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 18e1197e..66b98f57 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -243,6 +243,42 @@ image.readFromBuffer(data.buffer, function startRunning() { }; } if (process.env.PFNDBG) vm.primFnDebug = true; + if (process.env.COUNTBV) { + var ph = vm.primHandler, cnt = { blockValue:0, blockValueArgs:0, closureAct:0, closureActFull:0 }; + var o1 = ph.primitiveBlockValue.bind(ph); ph.primitiveBlockValue = function(a){ cnt.blockValue++; return o1(a); }; + var o2 = ph.primitiveBlockValueWithArgs.bind(ph); ph.primitiveBlockValueWithArgs = function(a){ cnt.blockValueArgs++; return o2(a); }; + if (ph.activateNewClosureMethod) { var o3 = ph.activateNewClosureMethod.bind(ph); ph.activateNewClosureMethod = function(b,a){ cnt.closureAct++; return o3(b,a); }; } + process.on("exit", function(){ console.error("COUNTBV " + JSON.stringify(cnt) + " byClosure(marriages)=" + (vm.nMarryClosure||0)); }); + } + if (process.env.CLEANBLK) { + // ¿cuántos closures son "clean" (nunca usan su outerContext)? Un clean block + // no necesitaría casar el frame → eliminaría marriages del stack zone. + // Conservador: clean = numCopied 0 Y ningún bytecode que toque estado externo + // (rcvr vars/self/thisContext/^/remote temps/extendidos que podrían). + var dirty = {}; [0x70,0x7C,0x81,0x82,0x84,0x85,0x89,0x8C,0x8D,0x8E].forEach(function(b){dirty[b]=1;}); + var stats = { total:0, clean:0, numCopied0:0, byCopied:{} }; + var origPCC = vm.pushClosureCopy.bind(vm); + vm.pushClosureCopy = function() { + var savedPc = vm.pc, m = vm.method; + var nac = m.bytes[vm.pc], numCopied = nac >> 4; + var bsHi = m.bytes[vm.pc+1], blockSize = bsHi*256 + m.bytes[vm.pc+2]; + var blockStart = vm.pc + 3, isClean = (numCopied === 0); + if (isClean) for (var p = blockStart; p < blockStart + blockSize; p++) { + var b = m.bytes[p]; + if (b <= 0x0F || (b >= 0x60 && b <= 0x67) || dirty[b]) { isClean = false; break; } + } + stats.total++; + if (numCopied === 0) stats.numCopied0++; + stats.byCopied[numCopied] = (stats.byCopied[numCopied]||0)+1; + if (isClean) stats.clean++; + return origPCC(); + }; + process.on("exit", function(){ + console.error("CLEANBLK closures=" + stats.total + " clean=" + stats.clean + + " (" + (100*stats.clean/stats.total).toFixed(1) + "%) numCopied0=" + stats.numCopied0 + + " (" + (100*stats.numCopied0/stats.total).toFixed(1) + "%) byCopied=" + JSON.stringify(stats.byCopied)); + }); + } if (process.env.HOTSEL) { // top métodos Smalltalk por activaciones (rcvrClass>>selector) — muestra // en qué gasta sends la imagen real (¿desperdicio algorítmico o costo base?) From ff87e2b90ac9b80d028da85def4c7ecc6240bf8e Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:36:36 -0300 Subject: [PATCH 38/99] =?UTF-8?q?[perf]=20spike:=20run=20the=20VM=20in=20a?= =?UTF-8?q?=20Web=20Worker=20=E2=80=94=20UI=20thread=20stays=20responsive?= =?UTF-8?q?=20at=20100%=20CPU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruedita/freeze is a UI-thread-blocking problem: the VM runs on the main thread, so 100% CPU work (animation) blocks the browser -> spinner. Moving the VM to a Web Worker fixes it at the architecture level, not by making the VM faster. perf/worker/ is a working proof of concept: - squeak-worker.js: a module Web Worker that loads the VM + Morphic plugins, boots Dialogo, renders the Display Form to a transferred OffscreenCanvas (32bpp ARGB->ABGR), and takes input from postMessage. Shims window/localStorage to self (workers have neither; indexedDB works in workers). - index.html: host that transfers the canvas, forwards mouse events, and shows a requestAnimationFrame counter proving the main thread stays live. Verified in headless Chrome: Dialogo's full UI renders from the worker, and the main thread holds 60fps while the worker runs the VM at 100% CPU (436k sends, 104 renders, rAF fps: 60). SqueakJS's headless VM was already worker-ready (no DOM deps in the core); the new work is the display/input bridge. Co-Authored-By: Claude Fable 5 --- perf/worker/index.html | 59 ++++++++++++++++ perf/worker/squeak-worker.js | 133 +++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 perf/worker/index.html create mode 100644 perf/worker/squeak-worker.js diff --git a/perf/worker/index.html b/perf/worker/index.html new file mode 100644 index 00000000..741a3d71 --- /dev/null +++ b/perf/worker/index.html @@ -0,0 +1,59 @@ + + + + +SqueakJS en Web Worker — spike + + + +
arrancando…
+
main thread: —
+ + + + diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js new file mode 100644 index 00000000..1c35d21c --- /dev/null +++ b/perf/worker/squeak-worker.js @@ -0,0 +1,133 @@ +// Web Worker (module) que corre el VM de SqueakJS FUERA del hilo principal, +// renderizando a un OffscreenCanvas y recibiendo input por postMessage. +// Spike del "puente" Web Worker: el VM usa 100% de un core en background y el +// hilo principal (canvas/input/página) queda responsive → no freeze, no ruedita. +// +// Shims: en un worker existe `self` pero no `window`/`document`/`localStorage`. +// El core del VM solo usa `self`; los plugins de archivo usan `window`/localStorage +// → los apuntamos a `self` (indexedDB SÍ existe en workers). +self.window = self; +self.localStorage = {}; + +import "../../globals.js"; +import "../../vm.js"; +import "../../vm.object.js"; +import "../../vm.object.spur.js"; +import "../../vm.image.js"; +import "../../vm.interpreter.js"; +import "../../vm.interpreter.proxy.js"; +import "../../vm.instruction.stream.js"; +import "../../vm.instruction.stream.sista.js"; +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.input.js"; +import "../../vm.plugins.js"; +import "../../vm.plugins.file.browser.js"; +import "../../vm.files.browser.js"; +// plugins esenciales para Morphic (BitBlt = render; el resto lo usa Dialogo). +// Se omiten los con deps de DOM puro (jpeg2 usa Image/canvas del documento). +import "../../plugins/BitBltPlugin.js"; +import "../../plugins/LargeIntegers.js"; +import "../../plugins/MiscPrimitivePlugin.js"; +import "../../plugins/FloatArrayPlugin.js"; +import "../../plugins/B2DPlugin.js"; +import "../../plugins/Matrix2x3Plugin.js"; +import "../../plugins/ZipPlugin.js"; + +var display = null, ctx = null, vm = null; + +// --- backend de display+input para el worker (OffscreenCanvas + cola de eventos) --- +Object.extend(Squeak.Primitives.prototype, "worker-display", { + primitiveScreenSize: function(argCount) { + return this.popNandPushIfOK(argCount + 1, this.makePointWithXandY(display.width, display.height)); + }, + primitiveScreenDepth: function(argCount) { return this.popNandPushIfOK(argCount + 1, 32); }, + primitiveScreenScaleFactor: function(argCount) { return this.popNandPushIfOK(argCount + 1, 1); }, + primitiveBeDisplay: function(argCount) { + this.vm.specialObjects[Squeak.splOb_TheDisplay] = this.vm.stackValue(0); + this.vm.popN(argCount); return true; + }, + primitiveDeferDisplayUpdates: function(argCount) { this.vm.popN(argCount); return true; }, + primitiveForceDisplayUpdate: function(argCount) { renderDisplay(); this.vm.popN(argCount); return true; }, + primitiveShowDisplayRect: function(argCount) { renderDisplay(); this.vm.popN(argCount); return true; }, + primitiveReverseDisplay: function(argCount) { this.vm.popN(argCount); return true; }, + primitiveSetFullScreen: function(argCount) { this.vm.popN(argCount); return true; }, + primitiveBeCursor: function(argCount) { this.vm.popN(argCount); return true; }, + primitiveTestDisplayDepth: function(argCount) { return this.popNandPushIfOK(argCount + 1, this.vm.trueObj); }, + // input + primitiveInputSemaphore: function(argCount) { + var idx = this.stackInteger(0); if (!this.success) return false; + this.inputEventSemaIndex = idx; + display.signalInputEvent = function() { this.signalSemaphoreWithIndex(this.inputEventSemaIndex); }.bind(this); + return this.popNIfOK(argCount); + }, + primitiveInputWord: function(argCount) { return this.popNandPushIfOK(1, 0); }, + primitiveGetNextEvent: function(argCount) { + var evtBuf = this.stackNonInteger(0).pointers; + var evt = display.eventQueue.shift(); + if (evt) { evtBuf[0] = evt[0]; evtBuf[1] = evt[1] & 0x1FFFFFFF; for (var i = 2; i < evt.length; i++) evtBuf[i] = evt[i]; } + else evtBuf[0] = 0; + return this.popNIfOK(argCount); + }, + primitiveMouseButtons: function(argCount) { return this.popNandPushIfOK(argCount + 1, this.ensureSmallInt(display.buttons | 0)); }, + primitiveMousePoint: function(argCount) { return this.popNandPushIfOK(argCount + 1, this.makePointWithXandY(display.mouseX | 0, display.mouseY | 0)); }, + primitiveKeyboardNext: function(argCount) { return this.popNandPushIfOK(argCount + 1, display.keys.length ? this.ensureSmallInt(display.keys.shift()) : this.vm.nilObj); }, + primitiveKeyboardPeek: function(argCount) { return this.popNandPushIfOK(argCount + 1, display.keys.length ? this.ensureSmallInt(display.keys[0]) : this.vm.nilObj); }, + primitiveBeep: function(argCount) { return true; }, + primitiveClipboardText: function(argCount) { return false; }, +}); + +function renderDisplay() { + var disp = vm.specialObjects[Squeak.splOb_TheDisplay]; + if (!disp || !disp.pointers) return; + var bits = disp.pointers[0], w = disp.pointers[1], h = disp.pointers[2]; + if (!bits || !bits.words || !w || !h) return; + var words = bits.words, n = Math.min(words.length, w * h); + var img = ctx.createImageData(w, h), dst = new Uint32Array(img.data.buffer); + for (var i = 0; i < n; i++) { + var argb = words[i]; + dst[i] = (argb & 0xFF00FF00) | ((argb & 0x00FF0000) >> 16) | ((argb & 0x000000FF) << 16) | 0xFF000000; + } + ctx.putImageData(img, 0, 0); +} + +self.onmessage = function(e) { + var msg = e.data; + if (msg.type === "init") { + ctx = msg.canvas.getContext("2d"); + display = { width: msg.width, height: msg.height, mouseX: msg.width >> 1, mouseY: msg.height >> 1, + buttons: 0, keys: [], eventQueue: [], signalInputEvent: null }; + boot(msg.image); + } else if (msg.type === "event") { + var ev = msg.ev; + if (ev[0] === 1) { display.mouseX = ev[2]; display.mouseY = ev[3]; display.buttons = ev[4]; } + display.eventQueue.push(ev); + if (display.signalInputEvent) display.signalInputEvent(); + } +}; + +function boot(imageUrl) { + Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); + fetch(imageUrl).then(function(r) { return r.arrayBuffer(); }).then(function(data) { + var image = new Squeak.Image("Dialogo"); + image.readFromBuffer(data, function() { + vm = new Squeak.Interpreter(image, display); + self.postMessage({ type: "ready" }); + function run() { + try { vm.interpret(50, function(ms) { setTimeout(run, ms === "sleep" ? 10 : ms); }); } + catch (err) { self.postMessage({ type: "error", msg: String(err && err.stack || err) }); } + } + run(); + // render decoplado del VM: copiar el Display Form al canvas ~30fps, + // sin depender de que la imagen llame forceDisplayUpdate + setInterval(function() { + renderDisplay(); + self.postMessage({ type: "tick", sends: vm.sendCount }); + }, 33); + }); + }).catch(function(err) { self.postMessage({ type: "error", msg: "fetch: " + err }); }); +} From 37c88b527d1ddc265f6635d2d0527651c42882c2 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:48:51 -0300 Subject: [PATCH 39/99] [perf] worker spike: full input (mouse buttons/modifiers + keyboard) Maps DOM events to Squeak events the way vm.input does: mouse buttons (left=Red, middle=Yellow, right=Blue; alt/cmd-click=middle for menus), modifiers (shift/ctrl/alt/cmd), and keyboard (printable + Enter/Backspace/Tab/Esc/arrows), forwarded to the worker via postMessage. Right-click and browser context menu are suppressed so they reach Squeak. Verified interactive in headless Chrome: mouse events reach the worker and the World responds (a hover tooltip appeared), while the main thread stays at 60fps. Co-Authored-By: Claude Fable 5 --- perf/worker/index.html | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/perf/worker/index.html b/perf/worker/index.html index 741a3d71..61658844 100644 --- a/perf/worker/index.html +++ b/perf/worker/index.html @@ -45,15 +45,40 @@ }; worker.onerror = function(e) { statusEl.textContent = "WORKER ONERROR: " + e.message; console.error(e); }; -function sendEvent(x, y, buttons) { +// mapeo de botones/modificadores igual que vm.input del browser +// botones Squeak: Red=4 (izq), Yellow=2 (medio), Blue=1 (der); mods: shift8 ctrl16 alt32 cmd64 +function mods(e) { return (e.shiftKey?8:0)|(e.ctrlKey?16:0)|(e.altKey?32:0)|(e.metaKey?64:0); } +function buttonsFrom(e, downButton) { + if (downButton !== undefined) { + var b = downButton === 0 ? 4 : downButton === 1 ? 2 : downButton === 2 ? 1 : 0; + if (b === 4 && (e.altKey || e.metaKey)) b = 2; // alt/cmd-click = middle (menú) + return b; + } + return (e.buttons & 1 ? 4 : 0) | (e.buttons & 2 ? 1 : 0) | (e.buttons & 4 ? 2 : 0); +} +function mouseEv(e, x, y, buttons) { var r = canvas.getBoundingClientRect(); var cx = Math.round((x - r.left) * canvas.width / r.width); var cy = Math.round((y - r.top) * canvas.height / r.height); - worker.postMessage({ type: "event", ev: [1, Date.now(), cx, cy, buttons, 0] }); + worker.postMessage({ type: "event", ev: [1, Date.now(), cx, cy, buttons, mods(e) ] }); +} +canvas.addEventListener("mousemove", function(e) { mouseEv(e, e.clientX, e.clientY, buttonsFrom(e)); }); +canvas.addEventListener("mousedown", function(e) { e.preventDefault(); mouseEv(e, e.clientX, e.clientY, buttonsFrom(e, e.button)); }); +canvas.addEventListener("mouseup", function(e) { mouseEv(e, e.clientX, e.clientY, 0); }); +canvas.addEventListener("contextmenu", function(e) { e.preventDefault(); }); // botón derecho = Squeak, no menú del browser +// teclado (foco en el canvas) +canvas.tabIndex = 0; +function keyEv(down, e) { + var uni = e.key.length === 1 ? e.key.charCodeAt(0) + : e.key === "Enter" ? 13 : e.key === "Backspace" ? 8 : e.key === "Tab" ? 9 : e.key === "Escape" ? 27 + : e.key === "ArrowLeft" ? 28 : e.key === "ArrowRight" ? 29 : e.key === "ArrowUp" ? 30 : e.key === "ArrowDown" ? 31 : 0; + if (!uni) return; + var mac = uni < 128 ? uni : 0; + // EventTypeKeyboard=2, ts, macCode, EventKeyChar=0, mods, unicode + worker.postMessage({ type: "event", ev: [2, Date.now(), mac, 0, mods(e), uni] }); } -canvas.addEventListener("mousemove", function(e) { sendEvent(e.clientX, e.clientY, e.buttons & 1 ? 4 : 0); }); -canvas.addEventListener("mousedown", function(e) { sendEvent(e.clientX, e.clientY, 4); }); -canvas.addEventListener("mouseup", function(e) { sendEvent(e.clientX, e.clientY, 0); }); +canvas.addEventListener("keydown", function(e) { if (e.key.length === 1 || ["Enter","Backspace","Tab","Escape","ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].indexOf(e.key) >= 0) { e.preventDefault(); keyEv(true, e); } }); +canvas.focus(); From c58f12646e75430974f35ca6727b1c3bdc46450e Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:10:20 -0300 Subject: [PATCH 40/99] [perf] worker spike: load Dialogo's project files (templates) in the worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squeak.fetchTemplateDir populates the worker's virtual FS from the served dialogo-fs/ (dictionaries + projects), fetched lazily over XHR — both XHR and IndexedDB work in workers. The image now auto-loads its projects at boot (sends jump 636k -> 3.1M), no hang, main thread still 60fps. Addresses 'projects from file are not loaded' in the spike. Co-Authored-By: Claude Fable 5 --- perf/worker/squeak-worker.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index 1c35d21c..56097889 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -112,6 +112,9 @@ self.onmessage = function(e) { function boot(imageUrl) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); + // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, + // igual que #templates en el browser. IndexedDB/XHR funcionan en workers. + Squeak.fetchTemplateDir("/", "/dialogo-fs"); fetch(imageUrl).then(function(r) { return r.arrayBuffer(); }).then(function(data) { var image = new Squeak.Image("Dialogo"); image.readFromBuffer(data, function() { From 6df36064553885684f826e8c375063a2bee59a7a Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:14:01 -0300 Subject: [PATCH 41/99] [perf] worker spike: configurable image/size via URL (#image=...&w=..&h=..) Makes perf/worker/index.html a reusable worker-mode launcher instead of a Dialogo-only demo. Canvas is sized before transferControlToOffscreen. Co-Authored-By: Claude Fable 5 --- perf/worker/index.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/perf/worker/index.html b/perf/worker/index.html index 61658844..6b1949c5 100644 --- a/perf/worker/index.html +++ b/perf/worker/index.html @@ -31,9 +31,15 @@ } requestAnimationFrame(tick); +// imagen y tamaño configurables por la URL: #image=/otra.image&w=1024&h=768 +// (fijar el tamaño ANTES de transferir el canvas al worker) +var params = new URLSearchParams((location.hash || "#").slice(1)); +var imageUrl = params.get("image") || "/Dialogo.32bits.image"; +var W = parseInt(params.get("w") || "1024", 10), H = parseInt(params.get("h") || "768", 10); +canvas.width = W; canvas.height = H; var offscreen = canvas.transferControlToOffscreen(); var worker = new Worker("squeak-worker.js", { type: "module" }); -worker.postMessage({ type: "init", canvas: offscreen, width: 1024, height: 768, image: "/Dialogo.32bits.image" }, [offscreen]); +worker.postMessage({ type: "init", canvas: offscreen, width: W, height: H, image: imageUrl }, [offscreen]); var frameCount = 0; worker.onmessage = function(e) { From 57f0e275e158d23c44936dc2702f14cb8fa2f86b Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:15:08 -0300 Subject: [PATCH 42/99] [perf] worker spike: populate keyboard polling interface (display.keys) too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squeak modal dialogs (e.g. naming a new project) often read the keyboard via the polling Sensor interface (display.keys / primitiveKeyboardNext) rather than the event queue. The worker now pushes keyboard events to display.keys as well as the queue — a plausible fix for the 'new project hangs' report (to re-verify). Co-Authored-By: Claude Fable 5 --- perf/worker/squeak-worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index 56097889..ea2729f8 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -105,6 +105,7 @@ self.onmessage = function(e) { } else if (msg.type === "event") { var ev = msg.ev; if (ev[0] === 1) { display.mouseX = ev[2]; display.mouseY = ev[3]; display.buttons = ev[4]; } + else if (ev[0] === 2) display.keys.push((ev[4] << 8) | ev[2]); // teclado: también el polling interface (Sensor, p/ modales) display.eventQueue.push(ev); if (display.signalInputEvent) display.signalInputEvent(); } From 41883170d95d610ca27d918b87679c36f0606b27 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:32:03 -0300 Subject: [PATCH 43/99] [perf] worker spike: build marker + reliable project-dir diagnostic + await templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a visible build marker and a live count of loaded project template dirs to the status, and delays the image boot ~800ms after fetchTemplateDir so the dir structure is registered before the image enumerates projects (defensive against a race). Diagnostic caught a real subtlety: ES-module imports are hoisted, so the worker's self.window/self.localStorage shims run AFTER globals.js — meaning Squeak.Settings is a fresh {} (not self.localStorage). Templates load fine into it (40 project dirs in headless); the counter now reads Squeak.Settings. Lets us pinpoint the user's 'projects don't load' report: build marker confirms version, dir count shows whether templates loaded in their browser. Co-Authored-By: Claude Fable 5 --- perf/worker/index.html | 6 +++--- perf/worker/squeak-worker.js | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/perf/worker/index.html b/perf/worker/index.html index 6b1949c5..a44a0e68 100644 --- a/perf/worker/index.html +++ b/perf/worker/index.html @@ -41,12 +41,12 @@ var worker = new Worker("squeak-worker.js", { type: "module" }); worker.postMessage({ type: "init", canvas: offscreen, width: W, height: H, image: imageUrl }, [offscreen]); -var frameCount = 0; +var frameCount = 0, buildInfo = ""; worker.onmessage = function(e) { var m = e.data; - if (m.type === "ready") statusEl.textContent = "VM READY en el worker — corriendo…"; + if (m.type === "ready") buildInfo = "[" + m.build + "] "; else if (m.type === "frame") { frameCount++; } - else if (m.type === "tick") { frameCount++; statusEl.textContent = "VM en worker | renders: " + frameCount + " | sends: " + m.sends; } + else if (m.type === "tick") { frameCount++; statusEl.textContent = buildInfo + "proyectos: " + m.templateDirs + " dirs · sends: " + m.sends; } else if (m.type === "error") { statusEl.textContent = "ERROR worker: " + m.msg.split("\n")[0]; console.error("worker error:", m.msg); } }; worker.onerror = function(e) { statusEl.textContent = "WORKER ONERROR: " + e.message; console.error(e); }; diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index ea2729f8..b73c8339 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -111,16 +111,21 @@ self.onmessage = function(e) { } }; +var BUILD = "worker-v4 templates+keys+await"; function boot(imageUrl) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, // igual que #templates en el browser. IndexedDB/XHR funcionan en workers. Squeak.fetchTemplateDir("/", "/dialogo-fs"); + // esperar a que los XHR de templates registren la estructura de directorios ANTES + // de que la imagen enumere sus proyectos al arrancar (evita una race consistente) fetch(imageUrl).then(function(r) { return r.arrayBuffer(); }).then(function(data) { + setTimeout(function() { + var tdirs = Object.keys(self.localStorage).filter(function(k){ return k.indexOf("squeak-template:") === 0; }).length; var image = new Squeak.Image("Dialogo"); image.readFromBuffer(data, function() { vm = new Squeak.Interpreter(image, display); - self.postMessage({ type: "ready" }); + self.postMessage({ type: "ready", build: BUILD, templateDirs: tdirs }); function run() { try { vm.interpret(50, function(ms) { setTimeout(run, ms === "sleep" ? 10 : ms); }); } catch (err) { self.postMessage({ type: "error", msg: String(err && err.stack || err) }); } @@ -130,8 +135,10 @@ function boot(imageUrl) { // sin depender de que la imagen llame forceDisplayUpdate setInterval(function() { renderDisplay(); - self.postMessage({ type: "tick", sends: vm.sendCount }); + var tdirs = Object.keys(Squeak.Settings).filter(function(k){ return k.indexOf("squeak-template:") === 0; }).length; + self.postMessage({ type: "tick", sends: vm.sendCount, templateDirs: tdirs }); }, 33); }); + }, 800); }).catch(function(err) { self.postMessage({ type: "error", msg: "fetch: " + err }); }); } From 2b0d205c5fb32e59a9bde24b315f4bc5860c2133 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:34:26 -0300 Subject: [PATCH 44/99] [perf] worker spike: #notemplates flag to test the no-projects state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets us reproduce the projects-off path. Finding: with #notemplates my headless still renders the drawing tablet (not the 'huge center new-project button' the user sees), and clicking the center is absorbed as a ~2M-send burst with no hang (VM stays alive). So the user's 'huge button + no projects' state does not reproduce fresh — points to stale client state (cached old worker or leftover IndexedDB) in their browser. Co-Authored-By: Claude Fable 5 --- perf/worker/index.html | 2 +- perf/worker/squeak-worker.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/perf/worker/index.html b/perf/worker/index.html index a44a0e68..290284ff 100644 --- a/perf/worker/index.html +++ b/perf/worker/index.html @@ -39,7 +39,7 @@ canvas.width = W; canvas.height = H; var offscreen = canvas.transferControlToOffscreen(); var worker = new Worker("squeak-worker.js", { type: "module" }); -worker.postMessage({ type: "init", canvas: offscreen, width: W, height: H, image: imageUrl }, [offscreen]); +worker.postMessage({ type: "init", canvas: offscreen, width: W, height: H, image: imageUrl, notemplates: params.has("notemplates") }, [offscreen]); var frameCount = 0, buildInfo = ""; worker.onmessage = function(e) { diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index b73c8339..14bc9d30 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -101,7 +101,7 @@ self.onmessage = function(e) { ctx = msg.canvas.getContext("2d"); display = { width: msg.width, height: msg.height, mouseX: msg.width >> 1, mouseY: msg.height >> 1, buttons: 0, keys: [], eventQueue: [], signalInputEvent: null }; - boot(msg.image); + boot(msg.image, msg.notemplates); } else if (msg.type === "event") { var ev = msg.ev; if (ev[0] === 1) { display.mouseX = ev[2]; display.mouseY = ev[3]; display.buttons = ev[4]; } @@ -112,11 +112,11 @@ self.onmessage = function(e) { }; var BUILD = "worker-v4 templates+keys+await"; -function boot(imageUrl) { +function boot(imageUrl, notemplates) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, // igual que #templates en el browser. IndexedDB/XHR funcionan en workers. - Squeak.fetchTemplateDir("/", "/dialogo-fs"); + if (!notemplates) Squeak.fetchTemplateDir("/", "/dialogo-fs"); // esperar a que los XHR de templates registren la estructura de directorios ANTES // de que la imagen enumere sus proyectos al arrancar (evita una race consistente) fetch(imageUrl).then(function(r) { return r.arrayBuffer(); }).then(function(data) { From b0a7776d15600fe62cc4079aa7dcb5d342c5343e Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:03:26 -0300 Subject: [PATCH 45/99] [perf] worker spike: add pure-JS JPEGReaderPlugin (jpeg2.browser needs DOM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialogo's project thumbnails may need JPEG; the standard jpeg2 plugin uses document/Image (no DOM in workers), so add the pure-JS JPEGReaderPlugin. No JPEG errors appear with templates (lazy fetch never reaches the project files), so this is precautionary — the user's #notemplates run (their IndexedDB, 16.6M sends vs 3.1M here) loads the projects but still doesn't show them, pointing at a render/layout gap rather than a missing decoder. Co-Authored-By: Claude Fable 5 --- perf/worker/squeak-worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index 14bc9d30..52db9112 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -37,6 +37,7 @@ import "../../plugins/FloatArrayPlugin.js"; import "../../plugins/B2DPlugin.js"; import "../../plugins/Matrix2x3Plugin.js"; import "../../plugins/ZipPlugin.js"; +import "../../plugins/JPEGReaderPlugin.js"; // JPEG puro-JS (jpeg2.browser usa DOM, no sirve en worker) var display = null, ctx = null, vm = null; From 93e410edd1b360a3eb552a8ac4316708cea0a563 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:52:02 -0300 Subject: [PATCH 46/99] [perf] worker spike: worker-safe JPEG via createImageBitmap + OffscreenCanvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the confirmed blocker: Dialogo's project thumbnails need JPEGReadWriter2Plugin, whose decode used new Image()/document.createElement('canvas') (no DOM in workers) -> 'missing primitive primJPEGPluginIsPresent'. Imports vm.plugins.jpeg2.browser.js (defines the jpeg2_* fns; the module is builtin but empty without it) and overrides ONLY the two DOM read functions with createImageBitmap (the browser's native JPEG decoder, available in workers) drawing to an OffscreenCanvas for getImageData. The rest of the plugin (its vm.freeze async pattern, copyPixelsToForm*) is pure JS and reused as-is. Effect: the missing-primitive error is gone and Dialogo proceeds (headless sends 3.1M -> 24M, previously blocked). Decode isn't exercised under templates (lazy fetch never touches the thumbnails); the user runs from IndexedDB (eager) where it is — to re-verify there. Co-Authored-By: Claude Fable 5 --- perf/worker/squeak-worker.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index 52db9112..9ce0bdd1 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -37,7 +37,7 @@ import "../../plugins/FloatArrayPlugin.js"; import "../../plugins/B2DPlugin.js"; import "../../plugins/Matrix2x3Plugin.js"; import "../../plugins/ZipPlugin.js"; -import "../../plugins/JPEGReaderPlugin.js"; // JPEG puro-JS (jpeg2.browser usa DOM, no sirve en worker) +import "../../vm.plugins.jpeg2.browser.js"; // define las funciones jpeg2_* (el módulo JPEGReadWriter2Plugin es builtin pero vacío sin esto) var display = null, ctx = null, vm = null; @@ -82,6 +82,25 @@ Object.extend(Squeak.Primitives.prototype, "worker-display", { primitiveClipboardText: function(argCount) { return false; }, }); +// JPEG worker-safe: jpeg2.browser decodifica con `new Image()` + del DOM, +// que no existe en un worker. Se sobrescriben SOLO las 2 funciones DOM de lectura +// con `createImageBitmap` (decoder JPEG nativo del browser, disponible en workers) +// + `OffscreenCanvas`. El resto del plugin (freeze async, copyPixelsToForm*) es +// puro-JS y se reusa tal cual. (El write/encode queda para después.) +Object.extend(Squeak.Primitives.prototype, "jpeg2-worker-overrides", { + jpeg2_readImageFromBytes: function(bytes, thenDo, errorDo) { + createImageBitmap(new Blob([bytes], { type: "image/jpeg" })) + .then(function(bmp) { thenDo(bmp); }) + .catch(function() { errorDo(); }); + }, + jpeg2_getPixelsFromImage: function(image) { + var canvas = new OffscreenCanvas(image.width, image.height), + context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, image.width, image.height); + }, +}); + function renderDisplay() { var disp = vm.specialObjects[Squeak.splOb_TheDisplay]; if (!disp || !disp.pointers) return; From a905fb8b89a04851c9304c163091f5ec8d2d03b9 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:25:27 -0300 Subject: [PATCH 47/99] [perf] worker: import full vm.display.browser.js (scanChars + real render path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal hand-rolled worker display lacked primitiveScanCharacters, which doPrimitive calls directly (not via the missing-primitive fallback) → hard throw that froze the worker once any text was laid out (crash after drawing/placing a symbol). Fix: import vm.display.browser.js and let its primitives win via our runtime Object.extend overrides (imports are hoisted, so body overrides apply last). Its primitives are almost all worker-safe already — the DOM branches are guarded by display.cursorCanvas / fullscreenRequest / highdpi, which we never set — and they render to the OffscreenCanvas through display.context (added here). So we drop the redundant display-primitive overrides (regaining real deferDisplayUpdates batching) and keep only what genuinely needs DOM/audio (beep/clipboard, screenDepth is headless-only) plus input (worker input arrives via postMessage, not DOM listeners). Verified headless (puppeteer): full Dialogo UI renders — tablet, hands, symbol toolbar, "Español" text, JPEG-decoded art — main thread stays at 60fps, no crashes. --- perf/worker/squeak-worker.js | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index 9ce0bdd1..bc36b293 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -24,6 +24,7 @@ import "../../jit.js"; import "../../vm.stackzone.js"; import "../../jit2.js"; import "../../vm.display.js"; +import "../../vm.display.browser.js"; // primitivos de display (scanCharacters, etc.); las funciones DOM/render las piso abajo import "../../vm.input.js"; import "../../vm.plugins.js"; import "../../vm.plugins.file.browser.js"; @@ -41,24 +42,18 @@ import "../../vm.plugins.jpeg2.browser.js"; // define las funciones jpeg2_* (el var display = null, ctx = null, vm = null; -// --- backend de display+input para el worker (OffscreenCanvas + cola de eventos) --- +// --- backend de display+input para el worker --- +// La MAYORÍA de los primitivos de display de vm.display.browser.js ya son worker-safe: +// las ramas DOM están guardadas por this.display.cursorCanvas / fullscreenRequest / +// highdpi (que no seteamos) y renderizan al OffscreenCanvas vía this.display.context +// (mismo camino showForm/showDisplayBits). Por eso NO los reimplementamos: dejamos que +// corra el batching real de deferDisplayUpdates. Solo pisamos lo que SÍ toca DOM/audio +// (beep/clipboard, screenDepth que solo existe en headless) y el INPUT: en el browser +// llega por listeners del DOM; acá llega por postMessage → cola de eventos. Object.extend(Squeak.Primitives.prototype, "worker-display", { - primitiveScreenSize: function(argCount) { - return this.popNandPushIfOK(argCount + 1, this.makePointWithXandY(display.width, display.height)); - }, primitiveScreenDepth: function(argCount) { return this.popNandPushIfOK(argCount + 1, 32); }, - primitiveScreenScaleFactor: function(argCount) { return this.popNandPushIfOK(argCount + 1, 1); }, - primitiveBeDisplay: function(argCount) { - this.vm.specialObjects[Squeak.splOb_TheDisplay] = this.vm.stackValue(0); - this.vm.popN(argCount); return true; - }, - primitiveDeferDisplayUpdates: function(argCount) { this.vm.popN(argCount); return true; }, - primitiveForceDisplayUpdate: function(argCount) { renderDisplay(); this.vm.popN(argCount); return true; }, - primitiveShowDisplayRect: function(argCount) { renderDisplay(); this.vm.popN(argCount); return true; }, - primitiveReverseDisplay: function(argCount) { this.vm.popN(argCount); return true; }, - primitiveSetFullScreen: function(argCount) { this.vm.popN(argCount); return true; }, - primitiveBeCursor: function(argCount) { this.vm.popN(argCount); return true; }, - primitiveTestDisplayDepth: function(argCount) { return this.popNandPushIfOK(argCount + 1, this.vm.trueObj); }, + primitiveBeep: function(argCount) { this.vm.popN(argCount); return true; }, + primitiveClipboardText: function(argCount) { return false; }, // input primitiveInputSemaphore: function(argCount) { var idx = this.stackInteger(0); if (!this.success) return false; @@ -78,8 +73,6 @@ Object.extend(Squeak.Primitives.prototype, "worker-display", { primitiveMousePoint: function(argCount) { return this.popNandPushIfOK(argCount + 1, this.makePointWithXandY(display.mouseX | 0, display.mouseY | 0)); }, primitiveKeyboardNext: function(argCount) { return this.popNandPushIfOK(argCount + 1, display.keys.length ? this.ensureSmallInt(display.keys.shift()) : this.vm.nilObj); }, primitiveKeyboardPeek: function(argCount) { return this.popNandPushIfOK(argCount + 1, display.keys.length ? this.ensureSmallInt(display.keys[0]) : this.vm.nilObj); }, - primitiveBeep: function(argCount) { return true; }, - primitiveClipboardText: function(argCount) { return false; }, }); // JPEG worker-safe: jpeg2.browser decodifica con `new Image()` + del DOM, @@ -119,7 +112,8 @@ self.onmessage = function(e) { var msg = e.data; if (msg.type === "init") { ctx = msg.canvas.getContext("2d"); - display = { width: msg.width, height: msg.height, mouseX: msg.width >> 1, mouseY: msg.height >> 1, + // context: lo usa la ruta de render de vm.display.browser.js (showForm/showDisplayBits) + display = { width: msg.width, height: msg.height, context: ctx, mouseX: msg.width >> 1, mouseY: msg.height >> 1, buttons: 0, keys: [], eventQueue: [], signalInputEvent: null }; boot(msg.image, msg.notemplates); } else if (msg.type === "event") { @@ -131,7 +125,7 @@ self.onmessage = function(e) { } }; -var BUILD = "worker-v4 templates+keys+await"; +var BUILD = "worker-v5 display.browser+scanChars"; function boot(imageUrl, notemplates) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, From 36023f9966bfcaac5866d8914877b91805ecd036 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:16:23 -0300 Subject: [PATCH 48/99] =?UTF-8?q?[perf]=20worker:=20drop=20periodic=20full?= =?UTF-8?q?-frame=20snapshot=20=E2=86=92=20render=20via=20displayDirty=20(?= =?UTF-8?q?no=20flicker)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 33ms setInterval that copied the whole Display form to the canvas ran async to the VM's draw cycle, so it sometimes captured a mid-draw frame — a region Morphic had cleared but not yet repainted — showing the layer underneath (the orange desk behind the environment as it opens, morphs flickering, drag not fluid). Now that display.context is wired to the OffscreenCanvas, the real render path (displayDirty → displayUpdate → showForm) is active and defer-aware: it only paints a region when Morphic has finished it (deferDisplayUpdates batching), exactly like desktop SqueakJS. Removed the snapshot timer; the remaining interval only posts status (sends/dirs) at 250ms. Verified headless: full UI still renders correctly with no timer. --- perf/worker/squeak-worker.js | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index bc36b293..9cdddfd0 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -94,20 +94,6 @@ Object.extend(Squeak.Primitives.prototype, "jpeg2-worker-overrides", { }, }); -function renderDisplay() { - var disp = vm.specialObjects[Squeak.splOb_TheDisplay]; - if (!disp || !disp.pointers) return; - var bits = disp.pointers[0], w = disp.pointers[1], h = disp.pointers[2]; - if (!bits || !bits.words || !w || !h) return; - var words = bits.words, n = Math.min(words.length, w * h); - var img = ctx.createImageData(w, h), dst = new Uint32Array(img.data.buffer); - for (var i = 0; i < n; i++) { - var argb = words[i]; - dst[i] = (argb & 0xFF00FF00) | ((argb & 0x00FF0000) >> 16) | ((argb & 0x000000FF) << 16) | 0xFF000000; - } - ctx.putImageData(img, 0, 0); -} - self.onmessage = function(e) { var msg = e.data; if (msg.type === "init") { @@ -125,7 +111,7 @@ self.onmessage = function(e) { } }; -var BUILD = "worker-v5 display.browser+scanChars"; +var BUILD = "worker-v6 displayDirty render (no flicker)"; function boot(imageUrl, notemplates) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, @@ -145,13 +131,13 @@ function boot(imageUrl, notemplates) { catch (err) { self.postMessage({ type: "error", msg: String(err && err.stack || err) }); } } run(); - // render decoplado del VM: copiar el Display Form al canvas ~30fps, - // sin depender de que la imagen llame forceDisplayUpdate + // NO renderizamos en un timer aparte: el render lo maneja la ruta real + // displayDirty→showForm (respeta deferDisplayUpdates de Morphic, sin capturar + // frames a mitad de dibujo → sin flicker). Este intervalo es solo status. setInterval(function() { - renderDisplay(); var tdirs = Object.keys(Squeak.Settings).filter(function(k){ return k.indexOf("squeak-template:") === 0; }).length; self.postMessage({ type: "tick", sends: vm.sendCount, templateDirs: tdirs }); - }, 33); + }, 250); }); }, 800); }).catch(function(err) { self.postMessage({ type: "error", msg: "fetch: " + err }); }); From a76a4242bcc438084da4ffda3f69247e630914f5 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:27:10 -0300 Subject: [PATCH 49/99] [perf] implement stream primitives 65/66/67 (primitiveNext/NextPut/AtEnd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqueakJS marked these as missing (return false → Smalltalk fallback). Older images like Dialogo (Cuis 4507) still compile PositionableStream>>next/nextPut:/atEnd with , so deserializing a saved project (SmartRefStream over a ReadStream) ran the whole thing in bytecode — pathologically slow, felt like a hang when opening a project with embedded art. Faithful transcription of the reference VM: only Array/String collections handled, everything else (out-of-range index, odd types, ReadStream nextPut) returns false → fallback, which is always correct. Position is mutated only after every check passes, so a fallback never double-advances. writeLimit is instVar 3 (WriteStream). Gated behind primHandler.streamPrims (STREAMPRIM=0 in difftrace to A/B). Validation: modern images (mini) never invoke them — 0 fires in 3M sends, byte-identical output, zero regression risk. Where they do fire (headless.image), the trace hash is identical to the fallback. The heavy path (interactive Dialogo project-open) fires them but the auto-load harness workload doesn't, so that speedup is verified live, not headless. Co-Authored-By: Claude Opus 4.8 --- perf/harness/difftrace.js | 1 + vm.primitives.js | 65 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/perf/harness/difftrace.js b/perf/harness/difftrace.js index 66b98f57..d0ae8bee 100644 --- a/perf/harness/difftrace.js +++ b/perf/harness/difftrace.js @@ -187,6 +187,7 @@ image.readFromBuffer(data.buffer, function startRunning() { var vm = new Squeak.Interpreter(image, display, useFrames ? { stackZone: true, jit2: useJit2 } : {}); if (noJit) vm.compiler = null; if (process.env.LARGEINT === "0") vm.primHandler.largeIntPrims = false; // A/B: desactivar prims LargeInteger + if (process.env.STREAMPRIM === "0") vm.primHandler.streamPrims = false; // A/B: desactivar prims Stream 65/66/67 if (process.env.JIT2DBG) vm.jit2Debug = true; if (process.env.SEMDBG) { var origSS = vm.primHandler.synchronousSignal; diff --git a/vm.primitives.js b/vm.primitives.js index c041eb3f..593e01aa 100644 --- a/vm.primitives.js +++ b/vm.primitives.js @@ -149,9 +149,9 @@ Object.subclass('Squeak.Primitives', case 62: return this.popNandPushIfOK(argCount+1, this.objectSize(false)); // size case 63: return this.popNandPushIfOK(argCount+1, this.objectAt(false,true,false)); // String.basicAt: case 64: return this.popNandPushIfOK(argCount+1, this.objectAtPut(false,true,false)); // String.basicAt:put: - case 65: this.vm.warnOnce("missing primitive: 65 (primitiveNext)"); return false; - case 66: this.vm.warnOnce("missing primitive: 66 (primitiveNextPut)"); return false; - case 67: this.vm.warnOnce("missing primitive: 67 (primitiveAtEnd)"); return false; + case 65: return this.primitiveStreamNext(argCount); // primitiveNext + case 66: return this.primitiveStreamNextPut(argCount); // primitiveNextPut + case 67: return this.primitiveStreamAtEnd(argCount); // primitiveAtEnd // StorageManagement Primitives (68-79) case 68: return this.popNandPushIfOK(argCount+1, this.objectAt(false,false,true)); // Method.objectAt: case 69: return this.popNandPushIfOK(argCount+1, this.objectAtPut(false,false,true)); // Method.objectAt:put: @@ -1129,6 +1129,65 @@ Object.subclass('Squeak.Primitives', }, }, 'indexing', { + // Stream primitives 65/66/67 (PositionableStream). Como el VM real de Squeak, + // solo manejan colecciones Array o String; ante cualquier otra cosa o borde + // (índice fuera de límite, tipos raros) devuelven false → fallback a Smalltalk, + // que es siempre correcto. Antes de mutar la posición validamos TODO, así un + // fallback nunca la doble-avanza. writeLimit es el instVar 3 (WriteStream). + primitiveStreamNext: function(argCount) { + if (this.streamPrims === false) return false; + var stream = this.stackNonInteger(0); + if (!stream.pointers || stream.pointers.length <= Squeak.Stream_limit) return false; + var array = stream.pointers[Squeak.Stream_array], + index = stream.pointers[Squeak.Stream_position], + limit = stream.pointers[Squeak.Stream_limit]; + if (typeof index !== "number" || typeof limit !== "number") return false; + if (index >= limit || !array || !array.sqClass) return false; + var result; + if (array.sqClass === this.vm.specialObjects[Squeak.splOb_ClassArray]) { + if (!array.pointers || index >= array.pointers.length) return false; + result = array.pointers[index]; + } else if (array.sqClass === this.vm.specialObjects[Squeak.splOb_ClassString]) { + if (!array.bytes || index >= array.bytes.length) return false; + result = this.charFromInt(array.bytes[index] & 0xFF); + } else return false; + if (result === undefined || result === null) return false; + stream.pointers[Squeak.Stream_position] = index + 1; + return this.popNandPushIfOK(argCount + 1, result); + }, + primitiveStreamNextPut: function(argCount) { + if (this.streamPrims === false) return false; + var value = this.vm.stackValue(0), + stream = this.stackNonInteger(1); + if (!stream.pointers || stream.pointers.length <= 3) return false; + var array = stream.pointers[Squeak.Stream_array], + index = stream.pointers[Squeak.Stream_position], + limit = stream.pointers[3]; // writeLimit + if (typeof index !== "number" || typeof limit !== "number") return false; + if (index >= limit || !array || !array.sqClass) return false; + if (array.sqClass === this.vm.specialObjects[Squeak.splOb_ClassArray]) { + if (!array.pointers || index >= array.pointers.length) return false; + array.pointers[index] = value; + array.dirty = true; + } else if (array.sqClass === this.vm.specialObjects[Squeak.splOb_ClassString]) { + if (!array.bytes || index >= array.bytes.length) return false; + if (!value || value.sqClass !== this.vm.specialObjects[Squeak.splOb_ClassCharacter]) return false; + var ascii = this.charToInt(value); + if (typeof ascii !== "number" || ascii < 0 || ascii > 255) return false; + array.bytes[index] = ascii; + } else return false; + stream.pointers[Squeak.Stream_position] = index + 1; + return this.popNandPushIfOK(argCount + 1, value); + }, + primitiveStreamAtEnd: function(argCount) { + if (this.streamPrims === false) return false; + var stream = this.stackNonInteger(0); + if (!stream.pointers || stream.pointers.length <= Squeak.Stream_limit) return false; + var index = stream.pointers[Squeak.Stream_position], + limit = stream.pointers[Squeak.Stream_limit]; + if (typeof index !== "number" || typeof limit !== "number") return false; + return this.popNandPushIfOK(argCount + 1, index >= limit ? this.vm.trueObj : this.vm.falseObj); + }, objectAt: function(cameFromBytecode, convertChars, includeInstVars) { //Returns result of at: or sets success false var array = this.stackNonInteger(1); From 77c64a6368125f410af816fcaad8cfd61c5aa85d Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:29:29 -0300 Subject: [PATCH 50/99] [perf] worker: image-managed cursor (desktop parity) via native CSS cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqueakJS (and the worker) showed the system pointer; the user wanted the Squeak image to own the cursor like the desktop VM. primitiveBeCursor now renders the cursor form to an OffscreenCanvas (reusing showForm, with the 1-bit/mask color handling from vm.display.browser.js) and posts the ImageBitmap + hotspot to the main thread, which sets it as a native CSS cursor (url(...) hotX hotY). The browser then draws it in hardware with perfect tracking — no overlay, no lag. Squeak's form offset is negative (added to the mouse position), so the CSS hotspot is -offset, clamped into the image. Verified headless: canvas.style.cursor becomes the image's 16x16 cursor data URL, UI still renders, no crash. --- perf/worker/index.html | 7 +++++++ perf/worker/squeak-worker.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/perf/worker/index.html b/perf/worker/index.html index 290284ff..8f03f070 100644 --- a/perf/worker/index.html +++ b/perf/worker/index.html @@ -45,6 +45,13 @@ worker.onmessage = function(e) { var m = e.data; if (m.type === "ready") buildInfo = "[" + m.build + "] "; + else if (m.type === "cursor") { + // la imagen definió el cursor → lo aplicamos como cursor CSS nativo (hardware, + // tracking perfecto). Convertimos el ImageBitmap a data URL vía un canvas temporal. + var tc = document.createElement("canvas"); tc.width = m.bitmap.width; tc.height = m.bitmap.height; + tc.getContext("2d").drawImage(m.bitmap, 0, 0); + canvas.style.cursor = "url(" + tc.toDataURL() + ") " + m.hotX + " " + m.hotY + ", auto"; + } else if (m.type === "frame") { frameCount++; } else if (m.type === "tick") { frameCount++; statusEl.textContent = buildInfo + "proyectos: " + m.templateDirs + " dirs · sends: " + m.sends; } else if (m.type === "error") { statusEl.textContent = "ERROR worker: " + m.msg.split("\n")[0]; console.error("worker error:", m.msg); } diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index 9cdddfd0..c63ac5ad 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -54,6 +54,33 @@ Object.extend(Squeak.Primitives.prototype, "worker-display", { primitiveScreenDepth: function(argCount) { return this.popNandPushIfOK(argCount + 1, 32); }, primitiveBeep: function(argCount) { this.vm.popN(argCount); return true; }, primitiveClipboardText: function(argCount) { return false; }, + // Cursor manejado por la imagen (paridad con desktop): construimos el bitmap del + // cursor con showForm sobre un OffscreenCanvas y lo mandamos al main thread, que lo + // pone como cursor CSS nativo (url(...) hotX hotY). Así el browser lo renderiza por + // hardware, sin overlay ni lag. El offset de Squeak es negativo (se suma a la pos del + // mouse) → el hotspot CSS es -offset. + primitiveBeCursor: function(argCount) { + try { + var cursorForm = this.loadForm(this.stackNonInteger(argCount), true), + maskForm = argCount === 1 ? this.loadForm(this.stackNonInteger(0)) : null; + if (this.success && cursorForm) { + var w = cursorForm.width, h = cursorForm.height, + oc = new OffscreenCanvas(w, h), octx = oc.getContext("2d"), + bounds = { left: 0, top: 0, right: w, bottom: h }, form = cursorForm; + if (form.depth === 1) { + if (maskForm) { form = this.cursorMergeMask(form, maskForm); + this.showForm(octx, form, bounds, [0x00000000, 0xFF0000FF, 0xFFFFFFFF, 0xFF000000]); + } else this.showForm(octx, form, bounds, [0x00000000, 0xFF000000]); + } else this.showForm(octx, form, bounds, true); + var bmp = oc.transferToImageBitmap(); + self.postMessage({ type: "cursor", bitmap: bmp, + hotX: Math.max(0, Math.min(w - 1, -(form.offsetX | 0))), + hotY: Math.max(0, Math.min(h - 1, -(form.offsetY | 0))) }, [bmp]); + } + } catch (e) { /* si falla, se queda con el cursor anterior */ } + this.vm.popN(argCount); + return true; + }, // input primitiveInputSemaphore: function(argCount) { var idx = this.stackInteger(0); if (!this.success) return false; @@ -111,7 +138,7 @@ self.onmessage = function(e) { } }; -var BUILD = "worker-v6 displayDirty render (no flicker)"; +var BUILD = "worker-v7 image-managed cursor + stream prims"; function boot(imageUrl, notemplates) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, From da412f0e746a17ac07525f7d0f509c8686754e59 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:44:46 -0300 Subject: [PATCH 51/99] [perf] worker: fix low-space crash (prompt shim), raise headroom, add JPEG numComponents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a project crashed with "ReferenceError: prompt is not defined": on low space the VM does `prompt && prompt(...)`, but prompt/alert/confirm are window-only and a worker has none, so the bare reference throws instead of short-circuiting. Shim them as safe no-ops (prompt/confirm → null so the VM takes the correct branch: signal low space to the image; alert → console). Root cause of the low-space itself: loading a big Dialogo project spikes transient allocation past the 100MB default headroom (peaked at ~151MB, 46M short-lived objects). Two mitigations: raise the worker image headRoom to 512MB (a worker can afford it; the spike fits comfortably and never trips the threshold), and implement jpeg2_primImageNumComponents (the stock plugin lacks it → "missing primitive" pushed the image onto a Smalltalk JPEG path that churns memory). createImageBitmap always decodes to RGB, so we report 3 and the fast primitive path is used. Also added a #nostream URL flag (→ primHandler.streamPrims=false) to A/B whether the new stream primitives are involved in any project-load issue. Verified boot headless: no crashes, zero missing primitives. --- perf/worker/index.html | 2 +- perf/worker/squeak-worker.js | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/perf/worker/index.html b/perf/worker/index.html index 8f03f070..74ef5f7d 100644 --- a/perf/worker/index.html +++ b/perf/worker/index.html @@ -39,7 +39,7 @@ canvas.width = W; canvas.height = H; var offscreen = canvas.transferControlToOffscreen(); var worker = new Worker("squeak-worker.js", { type: "module" }); -worker.postMessage({ type: "init", canvas: offscreen, width: W, height: H, image: imageUrl, notemplates: params.has("notemplates") }, [offscreen]); +worker.postMessage({ type: "init", canvas: offscreen, width: W, height: H, image: imageUrl, notemplates: params.has("notemplates"), nostream: params.has("nostream") }, [offscreen]); var frameCount = 0, buildInfo = ""; worker.onmessage = function(e) { diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index c63ac5ad..02ad7648 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -8,6 +8,14 @@ // → los apuntamos a `self` (indexedDB SÍ existe en workers). self.window = self; self.localStorage = {}; +// En un worker no existen prompt/alert/confirm (son de window). El VM los usa en +// signalLowSpaceIfNecessary (prompt) y para reportar errores (alert). Sin shim, +// `prompt && prompt(...)` tira ReferenceError y crashea el worker en low-space. +// Los hacemos no-ops seguros: prompt/confirm devuelven null → el VM cae a la rama +// correcta (señalar low-space a la imagen); alert va a la consola. +self.prompt = function() { return null; }; +self.confirm = function() { return false; }; +self.alert = function(msg) { console.warn("[squeak alert]", msg); }; import "../../globals.js"; import "../../vm.js"; @@ -119,6 +127,13 @@ Object.extend(Squeak.Primitives.prototype, "jpeg2-worker-overrides", { context.drawImage(image, 0, 0); return context.getImageData(0, 0, image.width, image.height); }, + // El plugin estándar no implementa esto → "missing primitive", y la imagen cae a + // un camino de decode en Smalltalk que aloca millones de temporales (churn → low + // space → crash). createImageBitmap siempre decodifica a RGB, así que informamos + // 3 componentes y la imagen usa la ruta rápida por primitiva. + jpeg2_primImageNumComponents: function(argCount) { + return this.popNandPushIfOK(argCount + 1, 3); + }, }); self.onmessage = function(e) { @@ -128,7 +143,7 @@ self.onmessage = function(e) { // context: lo usa la ruta de render de vm.display.browser.js (showForm/showDisplayBits) display = { width: msg.width, height: msg.height, context: ctx, mouseX: msg.width >> 1, mouseY: msg.height >> 1, buttons: 0, keys: [], eventQueue: [], signalInputEvent: null }; - boot(msg.image, msg.notemplates); + boot(msg.image, msg.notemplates, msg.nostream); } else if (msg.type === "event") { var ev = msg.ev; if (ev[0] === 1) { display.mouseX = ev[2]; display.mouseY = ev[3]; display.buttons = ev[4]; } @@ -138,8 +153,8 @@ self.onmessage = function(e) { } }; -var BUILD = "worker-v7 image-managed cursor + stream prims"; -function boot(imageUrl, notemplates) { +var BUILD = "worker-v8 prompt-shim + headroom + numComponents"; +function boot(imageUrl, notemplates, nostream) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, // igual que #templates en el browser. IndexedDB/XHR funcionan en workers. @@ -150,8 +165,14 @@ function boot(imageUrl, notemplates) { setTimeout(function() { var tdirs = Object.keys(self.localStorage).filter(function(k){ return k.indexOf("squeak-template:") === 0; }).length; var image = new Squeak.Image("Dialogo"); + // Más headroom que el default (100MB): cargar un proyecto grande de Dialogo + // (grafo de morphs + PNGs/JPEGs embebidos) hace un pico transitorio de objetos + // temporales que superaba el límite y gatillaba low-space. Un worker puede usar + // bastante RAM; le damos 512MB de margen para que el pico no toque el umbral. + image.headRoom = 512000000; image.readFromBuffer(data, function() { vm = new Squeak.Interpreter(image, display); + if (nostream) vm.primHandler.streamPrims = false; // A/B: #nostream desactiva prims 65/66/67 self.postMessage({ type: "ready", build: BUILD, templateDirs: tdirs }); function run() { try { vm.interpret(50, function(ms) { setTimeout(run, ms === "sleep" ? 10 : ms); }); } From 5f9cf7b2af1b8366a08d0416ab947af47a370118 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:56:08 -0300 Subject: [PATCH 52/99] [perf] worker: keep only the prompt shim, revert headroom+numComponents (broke opening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v8 also raised image headRoom to 512MB and hardcoded jpeg2_primImageNumComponents=3 as speculative fixes for the memory blowup. Both changed VM behavior in ways that regressed project opening (no project would open, new or saved) and I can't reproduce the open path headless to isolate which. Reverted both. Kept the prompt/alert/confirm shims — the one change that is unambiguously correct and directly fixes the reported crash (ReferenceError: prompt is not defined on low space): now low space signals the image gracefully instead of throwing. The #nostream A/B flag stays. This is effectively v7 (which did open projects for the user) plus the crash fix. The underlying memory churn on big project loads is left for a separate, validated fix. --- perf/worker/squeak-worker.js | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/perf/worker/squeak-worker.js b/perf/worker/squeak-worker.js index 02ad7648..6b8ec1cb 100644 --- a/perf/worker/squeak-worker.js +++ b/perf/worker/squeak-worker.js @@ -127,13 +127,6 @@ Object.extend(Squeak.Primitives.prototype, "jpeg2-worker-overrides", { context.drawImage(image, 0, 0); return context.getImageData(0, 0, image.width, image.height); }, - // El plugin estándar no implementa esto → "missing primitive", y la imagen cae a - // un camino de decode en Smalltalk que aloca millones de temporales (churn → low - // space → crash). createImageBitmap siempre decodifica a RGB, así que informamos - // 3 componentes y la imagen usa la ruta rápida por primitiva. - jpeg2_primImageNumComponents: function(argCount) { - return this.popNandPushIfOK(argCount + 1, 3); - }, }); self.onmessage = function(e) { @@ -153,7 +146,7 @@ self.onmessage = function(e) { } }; -var BUILD = "worker-v8 prompt-shim + headroom + numComponents"; +var BUILD = "worker-v9 prompt-shim only (reverted headroom+numComponents)"; function boot(imageUrl, notemplates, nostream) { Object.extend(Squeak, { vmPath: "/", platformSubtype: "Worker", osVersion: "worker", windowSystem: "worker" }); // cargar los archivos de proyecto de Dialogo (lazy, vía XHR) en el FS del worker, @@ -165,11 +158,6 @@ function boot(imageUrl, notemplates, nostream) { setTimeout(function() { var tdirs = Object.keys(self.localStorage).filter(function(k){ return k.indexOf("squeak-template:") === 0; }).length; var image = new Squeak.Image("Dialogo"); - // Más headroom que el default (100MB): cargar un proyecto grande de Dialogo - // (grafo de morphs + PNGs/JPEGs embebidos) hace un pico transitorio de objetos - // temporales que superaba el límite y gatillaba low-space. Un worker puede usar - // bastante RAM; le damos 512MB de margen para que el pico no toque el umbral. - image.headRoom = 512000000; image.readFromBuffer(data, function() { vm = new Squeak.Interpreter(image, display); if (nostream) vm.primHandler.streamPrims = false; // A/B: #nostream desactiva prims 65/66/67 From 1c75e77dbaf745d7fdc11d12aa67473cc11cd860 Mon Sep 17 00:00:00 2001 From: agustincico <42746022+agustincico@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:16:24 -0300 Subject: [PATCH 53/99] [perf] worker: event recorder + headless replay harness Interactive bugs (the OOM on opening a big project) can't be reproduced headless without the exact input, so add recording: every event sent to the worker is also stored with a timestamp in the harness format ({width,height,events:[{at,ev}]}), with "reiniciar"/"descargar" buttons in the page. A window.__replayEvents hook posts a recording back to the worker with real timing. perf/worker/replay.js drives a recording through the real worker via puppeteer (so JPEG/createImageBitmap and everything else match production), sampling sends over time and surfacing OOM/error/missing-primitive console lines + a final screenshot. Exit code 2 if out-of-memory was seen. This lets us reproduce and validate memory fixes headless before shipping. --- perf/worker/index.html | 35 +++++++++++++++++-- perf/worker/replay.js | 65 ++++++++++++++++++++++++++++++++++++ perf/worker/squeak-worker.js | 2 +- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 perf/worker/replay.js diff --git a/perf/worker/index.html b/perf/worker/index.html index 74ef5f7d..47e41ad0 100644 --- a/perf/worker/index.html +++ b/perf/worker/index.html @@ -13,6 +13,11 @@
arrancando…
main thread: —
+
+ + + — grabá abriendo un proyecto y descargá el JSON para el test +