Skip to content

Commit dd8003b

Browse files
committed
Merge Phase 19: JIT differential testing + fuel check
19.1: force_interpreter flag + 4 differential tests (interp vs JIT) 19.2: JIT fuel decrement at loop back-edges (ARM64 + x86_64) - jitSuppressed() no longer blocks JIT when fuel is set - jit_fuel i64 counter synced before/after JIT execution - Shared fuel exit stubs (1 per function, not per back-edge)
2 parents b25f7c7 + b5f8d8c commit dd8003b

6 files changed

Lines changed: 369 additions & 27 deletions

File tree

.dev/memo.md

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,34 @@ Session handover document. Read at session start.
44

55
## Current State
66

7-
- Stages 0-46 + Phase 1, 3, 5, 8, 11, 15 complete. **v1.5.0** (tagged 48342ab).
7+
- Stages 0-46 + Phase 1, 3, 5, 8, 11, 15 complete. **v1.6.0** (tagged 503e73a).
88
- Spec: 62,263/62,263 Mac+Ubuntu+Windows (100.0%, 0 skip). E2E: 792/792. Real-world: 50/50.
99
- Wasm 3.0: all 9 proposals. WASI: 46/46 (100%). WAT parser complete.
1010
- JIT: Register IR + ARM64/x86_64 (macOS, Linux, Windows x86_64). Size: 1.22MB stripped.
1111
- **Windows x86_64**: First-class support (D129, PR #8). platform.zig abstraction,
1212
VEH guard pages, Win64 ABI, HostHandle WASI, Python test runners, CI 3-OS.
13-
- **C API**: `libzwasm` (.dll/.lib, .dylib/.a, .so/.a) — 25 exported functions (D126).
13+
- **C API**: `libzwasm` — 25 exported functions (D126). c_allocator + ReleaseSafe default.
14+
Issue #11 fixed (Zig GPA PIC bug). 64-test FFI suite via dlopen.
15+
- **Rust FFI example**: PR #12 merged. edition 2024, CI integrated.
1416
- **Conditional compilation**: `-Djit=false`, `-Dcomponent=false`, `-Dwat=false` (D127).
15-
- **main = stable**: v1.5.0. ClojureWasm updated to v1.5.0.
17+
- **main = stable**: v1.6.0+. ClojureWasm updated to v1.5.0.
1618

1719
## Current Task
1820

19-
**PR #8 Windows support review + merge** (branch: `fix/pr8-review-fixes`)
21+
**Phase 19: JIT Reliability — differential testing + fuel check**
2022

21-
- [x] Code review: 26 files, platform.zig / guard.zig / wasi.zig / x86.zig / CI
22-
- [x] Fix run_compat.py rust_file_io regression (guest path alias)
23-
- [x] Fix path_filestat_get SYMLINK_NOFOLLOW restoration
24-
- [x] Fix README Stage 33 duplicate, run_spec.py .exe detection
25-
- [x] Code quality: VEH constant, HostHandle.close(), fd placeholder docs
26-
- [x] Doc updates: embedding.md, security.md, roadmap.md, decisions.md (D129)
27-
- [ ] Merge Gate (Mac + Ubuntu)
28-
- [ ] Benchmark recording
29-
- [ ] Push to PR branch → merge via GitHub
23+
Goal: make JIT a verifiably correct optimization layer over the interpreter.
3024

31-
### Pending: JIT fuel bypass + PR #6 timeout
32-
Checklist: `@./.dev/checklist-jit-fuel-timeout.md`
33-
PR review: `@./private/pr6-timeout-review.md`
25+
- [ ] JIT differential testing infrastructure (interp vs JIT comparison)
26+
- [ ] JIT fuel/deadline check at back-edges (replaces jitSuppressed())
27+
- [ ] W35 ARM64 JIT OOB investigation (unpin CI Rust)
28+
29+
### Design Principles
30+
31+
1. **Interpreter = source of truth**. JIT must produce identical results.
32+
2. **Differential testing** catches JIT-only bugs automatically.
33+
3. **Incremental**: each step independently committable, all gates pass.
34+
4. **No existing behavior removed** — only new verification added.
3435

3536
## Handover Notes
3637

@@ -42,6 +43,11 @@ PR review: `@./private/pr6-timeout-review.md`
4243
- Separate future task. See `@./private/pr6-timeout-review.md` §Fix Options and wasmtime research in `~/Documents/OSS/wasmtime/crates/cranelift/src/func_environ.rs`.
4344
- **Flaky compat tests**: W36 in checklist.md — `go_crypto_sha256`/`go_regex` intermittent DIFF on base code (pre-existing, likely W35-related).
4445

46+
### Issue #11 root cause (2026-03-22)
47+
- Zig 0.15 GPA crashes in Debug-mode shared libraries on Linux x86_64 (PIC codegen).
48+
- Fix: c_allocator (libc malloc) + library builds default to ReleaseSafe.
49+
- Zig build system also has shuffle bug with same-named artifacts → shared-lib/static-lib split.
50+
4551
## References
4652

4753
- `@./.dev/roadmap.md` (future phases), `@./.dev/roadmap-archive.md` (completed stages)
@@ -50,4 +56,3 @@ PR review: `@./private/pr6-timeout-review.md`
5056
- `@./.dev/decisions.md`, `@./.dev/checklist.md`, `@./.dev/spec-support.md`
5157
- `@./.dev/jit-debugging.md`, `@./.dev/bench-strategy.md`
5258
- External: wasmtime (`~/Documents/OSS/wasmtime/`), zware (`~/Documents/OSS/zware/`)
53-

.dev/roadmap.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,35 @@ Largest technical challenge.
160160

161161
**Gate**: Windows x86_64 all tests pass. 3-OS CI complete.
162162

163+
### Phase 19: JIT Reliability (2 days)
164+
165+
Make JIT a verifiably correct optimization layer over the interpreter.
166+
Principle: interpreter is the source of truth; JIT must produce identical results.
167+
All changes incremental — no existing behavior removed, only verification added.
168+
169+
**19.1 Differential Testing Infrastructure**
170+
171+
- Add `force_interpreter` flag to Vm — bypass JIT/RegIR, use stack interpreter
172+
- Differential test harness: run same function via interpreter AND JIT, compare results
173+
- Integrate into spec runner (`--diff` mode), fuzz harness, real-world compat
174+
- Catches JIT-only bugs (W35, W36 class) automatically
175+
176+
**19.2 JIT Fuel Check at Back-Edges**
177+
178+
- Emit fuel decrement + conditional trampoline exit at loop back-edges
179+
- ARM64: `ldr x0, [x20, #fuel_offset]; subs x0, x0, #1; str; b.mi exit`
180+
- x86: `dec qword [vm+fuel_offset]; js exit`
181+
- Remove `jitSuppressed()` fuel condition (keep profile/deadline suppression initially)
182+
- Unblocks PR #6 (timeout support)
183+
184+
**19.3 W35 ARM64 JIT OOB Investigation**
185+
186+
- Diff serde_json.wasm between rustc 1.92.0 and 1.93.1
187+
- Identify wasm opcode pattern that triggers ARM64 JIT miscompilation
188+
- Create minimal reproducer, fix JIT codegen, unpin CI Rust version
189+
190+
**Gate**: Differential test suite pass. Fuel+JIT coexist. CI Rust unpinned.
191+
163192
### Phase 18: Book i18n + Lazy Compilation + CLI Extensions (3 days)
164193

165194
**18.1 Book i18n (1 day)**
@@ -197,6 +226,7 @@ JIT deferred to first call. Trampoline → direct jump patch.
197226
| **v1.4.0** | 5, 8 | C API, conditional compilation, 50+ real-world, WAT parity |
198227
| **v1.5.0** | 11 | Allocator injection, C API config, embedding docs |
199228
| **v1.6.0** | 15 | Windows x86_64, 3-OS CI, platform abstraction |
229+
| **v1.7.0** | 19 | JIT differential testing, fuel check, W35 fix |
200230
| **v2.0.0** | 13 | SIMD JIT (NEON/SSE) |
201231

202232
## Benchmark History

src/jit.zig

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ const a64 = struct {
269269
return 0xD1000000 | (@as(u32, imm12) << 10) | (@as(u32, rn) << 5) | rd;
270270
}
271271

272+
/// SUBS Xd, Xn, #imm12 (64-bit, sets flags)
273+
fn subsImm64(rd: u5, rn: u5, imm12: u12) u32 {
274+
return 0xF1000000 | (@as(u32, imm12) << 10) | (@as(u32, rn) << 5) | rd;
275+
}
276+
272277
/// SUB Wd, Wn, #imm12 (32-bit)
273278
fn subImm32(rd: u5, rn: u5, imm12: u12) u32 {
274279
return 0x51000000 | (@as(u32, imm12) << 10) | (@as(u32, rn) << 5) | rd;
@@ -887,6 +892,8 @@ pub const Compiler = struct {
887892
patches: std.ArrayList(Patch),
888893
/// Error stubs: branch-to-error sites to be patched at end of function.
889894
error_stubs: std.ArrayList(ErrorStub),
895+
/// Branch indices from fuel checks — all patched to a single shared fuel exit.
896+
fuel_check_branches: std.ArrayList(u32),
890897
alloc: Allocator,
891898
reg_count: u16,
892899
local_count: u16,
@@ -916,6 +923,7 @@ pub const Compiler = struct {
916923
param_count: u16,
917924
result_count: u16,
918925
reg_ptr_offset: u32,
926+
jit_fuel_offset: u32,
919927
min_memory_bytes: u32,
920928
/// Bitmask of vregs that need loading in the prologue.
921929
/// Bit N = 1 means vreg N is read before written and must be loaded.
@@ -993,6 +1001,7 @@ pub const Compiler = struct {
9931001
.pc_map = .empty,
9941002
.patches = .empty,
9951003
.error_stubs = .empty,
1004+
.fuel_check_branches = .empty,
9961005
.alloc = alloc,
9971006
.reg_count = 0,
9981007
.local_count = 0,
@@ -1016,6 +1025,7 @@ pub const Compiler = struct {
10161025
.param_count = 0,
10171026
.result_count = 0,
10181027
.reg_ptr_offset = 0,
1028+
.jit_fuel_offset = 0,
10191029
.min_memory_bytes = 0,
10201030
.prologue_load_mask = 0xFFFFF, // default: load all (20 vregs)
10211031
.known_consts = .{null} ** 128,
@@ -1041,6 +1051,7 @@ pub const Compiler = struct {
10411051
self.pc_map.deinit(self.alloc);
10421052
self.patches.deinit(self.alloc);
10431053
self.error_stubs.deinit(self.alloc);
1054+
self.fuel_check_branches.deinit(self.alloc);
10441055
}
10451056

10461057
fn emit(self: *Compiler, inst: u32) void {
@@ -2381,6 +2392,8 @@ pub const Compiler = struct {
23812392
regalloc_mod.OP_BR => {
23822393
self.fpCacheEvictAll();
23832394
const target = instr.operand;
2395+
// Fuel check at back-edges (target <= current pc)
2396+
if (target <= pc.* - 1) self.emitFuelCheck();
23842397
const arm_idx = self.currentIdx();
23852398
self.emit(a64.b(0)); // placeholder
23862399
self.patches.append(self.alloc, .{
@@ -2391,6 +2404,8 @@ pub const Compiler = struct {
23912404
},
23922405
regalloc_mod.OP_BR_IF => {
23932406
self.fpCacheEvictAll();
2407+
// Fuel check at back-edges (before the conditional branch)
2408+
if (instr.operand <= pc.* - 1) self.emitFuelCheck();
23942409
// Branch if rd != 0
23952410
const cond_reg = self.getOrLoad(instr.rd, SCRATCH);
23962411
const arm_idx = self.currentIdx();
@@ -2403,6 +2418,8 @@ pub const Compiler = struct {
24032418
},
24042419
regalloc_mod.OP_BR_IF_NOT => {
24052420
self.fpCacheEvictAll();
2421+
// Fuel check at back-edges
2422+
if (instr.operand <= pc.* - 1) self.emitFuelCheck();
24062423
const cond_reg = self.getOrLoad(instr.rd, SCRATCH);
24072424
const arm_idx = self.currentIdx();
24082425
self.emit(a64.cbz32(cond_reg, 0)); // placeholder
@@ -3307,6 +3324,40 @@ pub const Compiler = struct {
33073324
self.storeVreg(instr.rd, d);
33083325
}
33093326

3327+
/// Emit fuel check at a loop back-edge.
3328+
/// Decrements jit_fuel in the VM struct; if negative, branches to a shared
3329+
/// fuel-exhausted exit (emitted once at end of function by emitErrorStubs).
3330+
fn emitFuelCheck(self: *Compiler) void {
3331+
// Skip when offset is 0 (unit tests without Vm struct)
3332+
if (self.jit_fuel_offset == 0) return;
3333+
// Load vm_ptr → SCRATCH (x8)
3334+
self.emitLoadVmPtr(SCRATCH);
3335+
const fuel_reg: u5 = 0; // x0: safe — not mapped to any vreg
3336+
// Compute address of jit_fuel: ADD SCRATCH, SCRATCH, #offset
3337+
// jit_fuel offset exceeds u16 (Vm struct is >500KB), so use ADD imm.
3338+
const fuel_off = self.jit_fuel_offset;
3339+
// ADD can encode up to 12-bit immediate (4095). If larger, use two-step.
3340+
if (fuel_off <= 4095) {
3341+
self.emit(a64.addImm64(SCRATCH, SCRATCH, @intCast(fuel_off)));
3342+
} else {
3343+
// MOVZ x0, #(fuel_off & 0xFFFF), LSL#0
3344+
self.emit(a64.movz64(fuel_reg, @truncate(fuel_off), 0));
3345+
if (fuel_off > 0xFFFF) {
3346+
self.emit(a64.movk64(fuel_reg, @truncate(fuel_off >> 16), 1));
3347+
}
3348+
self.emit(a64.add64(SCRATCH, SCRATCH, fuel_reg));
3349+
}
3350+
// Now SCRATCH points to &vm.jit_fuel
3351+
self.emit(a64.ldr64(fuel_reg, SCRATCH, 0));
3352+
self.emit(a64.subsImm64(fuel_reg, fuel_reg, 1));
3353+
self.emit(a64.str64(fuel_reg, SCRATCH, 0));
3354+
self.scratch_vreg = null;
3355+
// Branch to shared fuel exit (patched in emitErrorStubs)
3356+
const branch_idx = self.currentIdx();
3357+
self.emit(a64.bCond(.mi, 0)); // placeholder
3358+
self.fuel_check_branches.append(self.alloc, branch_idx) catch {};
3359+
}
3360+
33103361
/// Emit conditional error: if condition is true, branch to shared error stub.
33113362
/// Error stubs are emitted at function end by emitErrorStubs().
33123363
fn emitCondError(self: *Compiler, cond: a64.Cond, error_code: u16) void {
@@ -4652,7 +4703,7 @@ pub const Compiler = struct {
46524703
/// Emit error stubs and shared error epilogue at end of function.
46534704
/// Each error site branches to a stub that sets x0 and jumps to shared exit.
46544705
fn emitErrorStubs(self: *Compiler) void {
4655-
if (self.error_stubs.items.len == 0 and !self.use_guard_pages) return;
4706+
if (self.error_stubs.items.len == 0 and self.fuel_check_branches.items.len == 0 and !self.use_guard_pages) return;
46564707

46574708
// Shared error epilogue: restore callee-saved regs and return (x0 has error code)
46584709
const shared_exit = self.currentIdx();
@@ -4690,6 +4741,22 @@ pub const Compiler = struct {
46904741
}
46914742
}
46924743
}
4744+
4745+
// Emit shared fuel-exhausted exit and patch all fuel check branches.
4746+
// Single stub: MOVZ x0, #9 + B shared_exit. All back-edge checks branch here.
4747+
if (self.fuel_check_branches.items.len > 0) {
4748+
const fuel_stub_idx = self.currentIdx();
4749+
self.emit(a64.movz64(0, 9, 0)); // x0 = 9 (FuelExhausted)
4750+
const exit_offset: i26 = @intCast(@as(i32, @intCast(shared_exit)) - @as(i32, @intCast(self.currentIdx())));
4751+
self.emit(a64.b(exit_offset));
4752+
4753+
// Patch all fuel check B.MI branches to point to this stub
4754+
for (self.fuel_check_branches.items) |branch_idx| {
4755+
const offset: i32 = @as(i32, @intCast(fuel_stub_idx)) - @as(i32, @intCast(branch_idx));
4756+
const imm: u19 = @bitCast(@as(i19, @intCast(offset)));
4757+
self.code.items[branch_idx] = 0x54000000 | (@as(u32, imm) << 5) | @as(u32, @intFromEnum(a64.Cond.mi));
4758+
}
4759+
}
46934760
}
46944761

46954762
// --- Branch patching ---
@@ -5199,6 +5266,7 @@ pub fn compileFunction(
51995266
compiler.use_guard_pages = use_guard_pages;
52005267
compiler.osr_target_pc = osr_target_pc;
52015268
compiler.gc_trampoline_addr = gc_trampoline_addr;
5269+
compiler.jit_fuel_offset = @intCast(@offsetOf(vm_mod.Vm, "jit_fuel"));
52025270

52035271
// Dump JIT code before deinit (pc_map still alive, one-shot)
52045272
if (trace) |tc| {

src/types.zig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,15 @@ pub const WasmModule = struct {
457457
try self.vm.invoke(&self.instance, name, args, results);
458458
}
459459

460+
/// Invoke using only the stack-based interpreter, bypassing RegIR and JIT.
461+
/// Used by differential testing to get a reference result.
462+
pub fn invokeInterpreterOnly(self: *WasmModule, name: []const u8, args: []u64, results: []u64) !void {
463+
self.vm.reset();
464+
self.vm.force_interpreter = true;
465+
defer self.vm.force_interpreter = false;
466+
try self.vm.invoke(&self.instance, name, args, results);
467+
}
468+
460469
/// Read bytes from linear memory at the given offset.
461470
/// The returned slice is owned by the caller and must be freed with `allocator`.
462471
pub fn memoryRead(self: *WasmModule, allocator: Allocator, offset: u32, length: u32) ![]const u8 {

0 commit comments

Comments
 (0)