Add AArch64 JIT backend#956
Open
Pign wants to merge 31 commits into
Open
Conversation
New backend src/jit_arm64.c selected by CMake when targeting arm64/aarch64.
Exports the same public API as jit.c (hl_jit_alloc/init/reset/function/code/
free, hl_jit_patch_method) so module.c is untouched.
Covers ~30 opcodes for now (Mov/Int/Float/Bool/Bytes/String/Null,
arith int + jumps, Call0-4/N, Get/SetGlobal, Field/SetField/GetThis/SetThis,
ONullCheck, Ret) and emits BRK #opcode for the rest so unsupported paths
fail loudly. Register allocator is intentionally absent — every vreg lives
in its stack slot and ops shuttle through x9/x10.
Infrastructure changes in src/gc.c:
- MAP_JIT path for hl_alloc_executable_memory on __APPLE__ + __aarch64__
- hl_jit_write_begin/hl_jit_write_end helpers (pthread_jit_write_protect_np
+ sys_icache_invalidate on macOS, __builtin___clear_cache on Linux ARM)
CMake gates jit.c vs jit_arm64.c via HL_JIT_AARCH64. On macOS the hl
binary is ad-hoc signed post-build with com.apple.security.cs.allow-jit
(other/osx/hl_jit.entitlements).
Status: build runs end-to-end, JIT executes generated code, currently
crashes at PC=0 on the first non-trivial program — needs disassembly
of the generated buffer to track down. iOS/tvOS/watchOS explicitly
out of scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hl_call_method (libhl/std/fun.c) reaches into JIT'd code through hl_setup.static_call. The bring-up backend was leaving that pointer NULL, which is why every non-trivial program crashed at PC=0 (blr x8 where x8=0, inside hl_call_method itself). This commit: - adds callback_c2hl_arm64 — packs the argv into a 16×u64 buffer (8 GPR slots + 8 FP slots) and tail-calls a JIT-emitted trampoline. No stack-overflow handling yet, so functions with > 8 GPR or > 8 FPR arguments will fail loudly via hl_error(); good enough for boot. - adds emit_c2hl_trampoline — emits a 22-instruction native trampoline that loads x0..x7 / d0..d7 from the buffer and BLRs the target. - emits the trampoline at hl_jit_init time and records its byte offset in ctx->c2hl, mirroring the x86 backend. - in hl_jit_code: after the executable mmap is populated, sets call_jit_c2hl_native = code + ctx->c2hl, hl_setup.static_call = callback_c2hl_arm64, and hl_setup.static_call_ref = true. Equivalent to jit.c:4686-4694. - adds an HL_JIT_DUMP env var that writes the raw JIT buffer to disk along with a .idx file mapping findex -> byte offset / op count. Disassembling that dump under capstone is what surfaced the actual crash (LR pointed into hl_call_method, x8=0). Status: hello-world Haxe programs that don't touch the runtime (println, strings, casts) now build and run end-to-end in JIT. The println(42) test still SIGTRAPs on opcodes 82 (ONew), 30 (OCallMethod), 59 (OToDyn), 63 (OSafeCast), etc., which are next on the list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… and a batch of memory ops
Critical fix: emit_prologue now spills the incoming x0..x7 / d0..d7
into the corresponding vreg slots per AAPCS64. Without this, any
function that reads its arguments back from the spill area got garbage
(empty.hl happened to never read its args; non-trivial paths blew up
the moment a runtime helper was called with the resulting bogus
pointer). store_vreg* forward-declared since emit_prologue is defined
before them in the file.
New opcodes covered:
- OType : load &m->code->types[k] as a constant
- OGetType : null-checked load of v->t (falls back to &hlt_void)
- OGetTID : load t->kind (32-bit)
- OArraySize : load varray.size at the right offset (HABSTRACT vs
regular)
- ONew : dispatch to hl_alloc_obj / hl_alloc_dynobj /
hl_alloc_virtual based on dst kind
- OCallThis : two-step vtable dispatch (this->t->vobj_proto[k])
- OCallMethod : HOBJ variant of OCallThis; HVIRTUAL still BRKs
- OSafeCast : routes to hl_dyn_cast{p,i,i64,f,d} with the
appropriate arg count
- OThrow/ORethrow : tail-call into hl_throw / hl_rethrow
- ORef/OUnref/OSetref : ref = &slot; *ref load/store
- OGetI8/I16/Mem + OSetI8/I16/Mem : base+index{,*1} memory access
(sub-word + 8-byte + FP variants)
Plus an emit_call_native_ptr helper that materialises a 64-bit function
address into x16 and BLRs it — used by ONew/OThrow/OSafeCast/etc.
Status: empty.hl now exits 139 (SIGSEGV in hl_hash_gen via JIT'd
bootstrap code) and exit42.hl exits 139 as well. The argument-spill
fix has unblocked the real runtime path; the crashes are now inside
libhl with a bad string pointer arriving from our JIT, suggesting
either ODynGet/ODynSet (still BRK) on the bootstrap path or a subtle
field-offset bug in OGetThis on a runtime struct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…OToDyn Three changes, one milestone: 1. OString — was reading m->code->ustrings[k] directly. That array is lazily populated, so the JIT was loading NULL for every string literal. Switched to hl_get_ustring(m->code, k) which is what jit.c does. This is why the bootstrap path passed NULL to hl_hbset and crashed in hl_hash_gen. 2. OGetArray / OSetArray — non-abstract varray case. Computes &arr->data[idx] as ptr + idx*sizeof(elem) + sizeof(varray). Power- of-two element sizes (1/2/4/8) use a single LSL; other sizes fall back to MUL. FP element types route through V16. Abstract arrays (raw memory) still BRK — not exercised by std lib bootstrap. 3. OToDyn — box a primitive (or pointer) into a vdynamic. For pointer sources, branches on NULL early and short-circuits the result to NULL. Otherwise calls hl_alloc_dynamic(t) then stores the value into the dynamic at HDYN_VALUE (offset 8). FP and 4/8-byte paths covered. End-to-end results on macOS arm64 native (no Rosetta): - empty.hl exit 0 - exit42 (Sys.exit(40+2)) exit 42 - test.hl (println(42)) prints "42", exit 0 Apple Silicon JIT for HashLink is alive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…osure, OCallClosure, OField/HVIRTUAL
This commit makes the official other/tests/HelloWorld.hx test pass:
$ hl HelloWorld.hl
HelloWorld.hx:2: Hello world!
Opcodes added (in the order they were blocking):
- ODynGet / ODynSet — dispatch to hl_dyn_{get,set}{p,i,i64,f,d} based
on the value's hl_type kind. NB the ODynSet encoding swaps p2/p3
vs what OP(ODynSet,R_NW,R,C) suggests: p2 is the string-table index
of the field name, p3 is the value vreg. Mirrored from jit.c:4244-4302.
- OToVirtual — hl_to_virtual(dst_type, src). For HOBJ sources we call
hl_get_obj_rt() at JIT time to force runtime-object initialisation,
same as the x86 backend.
- OToInt — F64→int via FCVTZS (round-toward-zero, no MXCSR setup
needed on AArch64); I32→I64 via SBFM #0,HaxeFoundation#31 (SXTW); identity-cast
short-circuit when p1 == p2. F32→int still BRKs (need single-prec
FCVTZS encoder).
- OStaticClosure — allocates a vclosure in module-lifetime storage
(m->ctx.alloc), stores findex in c->fun, chains it on
ctx->closure_list. hl_jit_code now patches the chain after
functions_ptrs is populated, converting findex→absolute address.
jit_ctx gained a closure_list field.
- OCallClosure — non-HDYN path: tests c->hasValue, branches to either
c->fun(args...) or c->fun(c->value, args...). HDYN path (dynamic
call via hl_dyn_call) and >7 user args still BRK.
- OField, HVIRTUAL case — if hl_vfields(o)[k] is non-null, dst = *vfield;
else fall back to hl_dyn_get* with the field's pre-hashed name.
- OInstanceClosure still BRKs (staging the fun_ptr immediate across a
MOVZ+MOVK chain needs new patch infra; not blocking trace()).
All earlier tests still green:
empty.hl exit 0
exit42.hl exit 42
test.hl "42"
fib.hl fib(20)=6765 (recursion)
loop.hl for-in over ranges and arrays
strings.hl concat, length, toUpperCase
hello.hl "HelloWorld.hx:2: Hello world!"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OTrap / OEndTrap / OCatch
-------------------------
The HL exception model is setjmp/longjmp-based. OTrap allocates an
hl_trap_ctx on the stack, chains it into hl_get_thread()->trap_current,
then calls setjmp(&t->buf):
- setjmp returns 0 → fall through; the trap stays live until OEndTrap
- setjmp returns !0 → longjmp landed here from a hl_throw; pop the
trap, load thread->exc_value into dst, jump to the catch label
OEndTrap pops the trap chain by one and reclaims the stack. OCatch is
just a jump landing pad (no codegen). tcheck is set to NULL — typed
catch (matching by exception class) is deferred.
The trap_ctx layout and field offsets are computed at JIT time via
offsetof() / sizeof() against the libhl headers, so we don't hard-code
jmp_buf size (192 bytes on macOS arm64) or risk drifting if libhl
shuffles its struct.
OSwitch
-------
Linear cmp-and-branch chain over the ncases entries (op->extra[i] is
the relative target for case i). Out-of-range falls through to the
opcode after the switch (HL's default convention). A real jump table
would be faster on large switches but needs an extra patch direction.
OEnumIndex
----------
venum layout puts the constructor index at offset 8; a single 32-bit
LDR from there suffices.
Programs validated end-to-end on macOS arm64 JIT:
empty.hl exit 0
exit42.hl exit 42 (Sys.exit)
test.hl "42" (Sys.println of an int)
hello.hl "HelloWorld.hx:2: Hello world!" (trace)
fib.hl fib(10)=55, fib(20)=6765 (recursion)
loop.hl sum=55, "1 2 3 4 5" (for-in)
strings.hl concat, length, toUpperCase
classes.hl "Rex says woof", "Felix says meow" (inheritance + virtuals)
switch.hl seven cases dispatch correctly (- guards work)
tryc.hl "100/1=100" "100/2=50" "caught: bad input" "100/4=25"
OInstanceClosure, OToSFloat/UFloat, OEnumAlloc/MakeEnum/EnumField,
F32→Int FCVTZS, abstract-array Get/SetArray are the remaining BRKs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pattern matching on enums now works:
enum Shape { Circle(r:Float); Rect(w:Float,h:Float); Triangle(b:Float,h:Float); }
switch(s) { case Circle(r): ...; case Rect(w,h): ...; ... }
Implementation notes:
- OEnumAlloc + OMakeEnum both go through hl_alloc_enum(t, idx). MakeEnum
follows with a straight-line series of stores at each param's
hl_enum_construct.offsets[i], picking FP or GPR width by param type.
- OEnumField encoding has a gotcha: opcodes.h marks p2 as AR with arity 4,
but the actual layout in jit.c is p3 = constructor index and
(int)(intptr_t)op->extra = field index (one int cast, not an array).
Matching the x86 backend here exactly.
- OSetEnumField always targets construct 0 (matches jit.c convention),
with p2 = field index and p3 = source vreg.
- venum->index for OEnumIndex sits at offset 8 (after the type ptr).
Coverage so far on the AArch64 backend, by program:
empty / exit42 / println(42) / trace / fib / for-in loops /
arrays / string ops / class inheritance + virtual dispatch /
switch / try-catch / enum pattern matching all OK
Map<K,V> still BRKs on the HVIRTUAL variant of OCallMethod (virtual
interface dispatch with hl_dyn_call_obj fallback). That path next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bj fallback
Map<K,V> set/get/exists now work in JIT:
var m = new Map<String,Int>();
m["a"] = 10; m["b"] = 20; m["c"] = 30;
trace(m["b"]); // 20
trace(m.exists("z"));// false
These all route through OCallMethod on a HVIRTUAL receiver. Two paths:
1. Fast path — hl_vfields(o)[k] is non-null (the virtual was successfully
bound to a concrete impl). We replace `this` with o->value and BLR
the vfield pointer with the standard (this, args...) signature.
2. Fallback — vfield is NULL. We pack the args into a stack buffer at
sp[0..nargs-1] (pointer values for HOBJ-kinded args, &slot for
value-typed args), optionally reserve a vdynamic on top to receive
the non-pointer return, and call hl_dyn_call_obj(o->value, ftype,
hashed_name, packed, ret_or_NULL).
Stashing dance: x10 (vfield) and the computed o->value get pushed to
a 16-byte scratch frame before loading the remaining x1..x{nargs}, so
load_vreg's internal use of x16 on out-of-range offsets cannot trash
the function pointer. Pop, BLR, then store_vreg the result.
Known bug not yet fixed: Map iteration (`for(k in m.keys())`) only
advances through one element — hasNext() returns true once then false
the second time. Concrete dispatch chain works (set/get all return
the right values); the iterator's internal state likely needs the
virtual wrapper, not just o->value, to be threaded through (or the
"obj_in_args" alias case the x86 backend handles which our fast path
doesn't yet). To investigate.
Status on macOS arm64 native:
- All previously-green tests stay green
- mapt.hl: set/get/exists OK, keys() iteration → 1 element (bug)
- adv.hl: enum pattern matching + Map basics + Std.parseInt/Float all OK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r sub-word fields
The field-access codegen was using STR/LDR with hard-coded 8-byte width,
even for HI32 (4-byte) and other sub-word field types. On structs where
two scalar fields shared an 8-byte slot (e.g. an Int field followed by
another Int), this corrupted the neighbouring field.
This was the root cause of the Map iteration bug: BytesIterator's
`current:Int` field sits next to other state on the same iterator
struct. SetField current = current+1 was writing 8 bytes, which
clobbered an adjacent field; by the second call to hasNext() the
iterator looked exhausted and the loop exited after one element.
Fix: use hl_type_size(value_type) for the access width on all four
field opcodes, and route FP-typed fields through V16 with the right
ldr/str variant.
End-to-end results on macOS arm64 native (14 / 14 pass):
empty exit 0
exit42 exit 42
test "42"
hello "HelloWorld.hx:2: Hello world!" (trace)
fib fib(10)=55, fib(20)=6765
loop sum=55, "1 2 3 4 5 "
strings "Hello, world!" / "13 chars, upper: HELLO, WORLD!"
classes "Rex says woof" / "Felix says meow"
switch 7 cases dispatched correctly
tryc try/catch with throw works
adv enum pattern match + Map basics + Std.parseInt/Float
("total=6" now, was "total=2")
mapt Map<String,Int>: set / get / exists / keys() iteration
("count=3 sum=60" now, was "count=1 sum=20")
minit Array<Int> for-in: exit 104 = (100*3 + 60) % 256
calc fib(20) % 256 = 109
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes that unblock common Haxe idioms.
(1) OInstanceClosure
Implements hl_alloc_closure_ptr(ftype, fun_ptr, captured_value) properly.
fun_ptr is the absolute address of an HL function — unknown at JIT
time, so we emit a MOVZ + 3xMOVK chain into x1 (16 bytes), stage a
new IMM64-tagged jlist entry, and the patcher in hl_jit_code rewrites
the four imm16 fields once functions_ptrs is final.
The new tag scheme (negative target ≤ -1000 means "patch 4-insn imm64
materialisation, fid = -1000 - target") is encoded in two macros so
OStaticClosure's negative bookkeeping (which uses different sentinel
values) can't collide.
Programs like:
var counter = 0;
var inc = function() return ++counter; // captures `counter`
inc(); inc(); inc(); // 1, 2, 3
now work, as does `arr.map(x -> x * 2)`.
(2) hl_setup.get_wrapper
libhl.fun.c:353 reads hl_setup.get_wrapper and calls it from
hl_dyn_call_obj. Leaving it NULL caused PC=0 crashes the moment
HVIRTUAL methods fell back to the dyn-call path (e.g. when
Iterator<Int>'s safe-cast was rejected and vfields[i] became NULL).
Provide a stub returning NULL — sufficient because hl_wrapper_call
reads wrappedFun->fun directly without consulting cl.fun. A real
HL2C trampoline can replace this later for callable wrappers.
End-to-end on macOS arm64 native (15 / 15 + 2 new):
... (previous 14 still green) ...
virt.hl exit 3 = (100*5 + 15) % 256 — explicit Iterator<Int>
over Array<Int>
floats.hl Math.sqrt(2)/sin/log/multiplication chain all correct
clos.hl closure capturing `counter` mutated correctly
across calls; map(lambda) produces "2 4 6 8 10"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ous structs work
Two fixes that together unlock a large class of real-world Haxe code.
(1) op_jump_compare width
The local was literally `int is64 = X ? 1 : 1;` — always 1. Result:
every OJSLt/OJSGte/OJSGt/OJSLte/OJULt/OJUGte ran as a 64-bit compare
against zero-extended HI32 operands.
Consequence for the qsort partition: when called with hi=-1 (a
legitimate end-of-recursion case after no swaps occurred), the
function's entry-guard `JSLt if lo < hi jump to body else Ret`
incorrectly took the body branch because 0 < 0x00000000_FFFFFFFF
is true at 64 bits. The function then loaded arr[hi=-1] and wrote
arr[i=-1], blowing out memory at base + 4 GiB - 4.
Fix: pick 64-bit compare only for HI64/HGUID/pointer types, 32-bit
otherwise. Documents the rationale so the next reader doesn't put
it back.
Side effect: the `switch (n) { case _ if (n < 0): "negative"; ... }`
guard in switch.hl now resolves correctly — that case was hiding
behind the same bug.
(2) OSetField on HVIRTUAL
Was previously BRK 0xF1E2. Now:
- If hl_vfields(o)[k] is non-null → *vfield = src
- Otherwise → hl_dyn_set{p,i,i64,f,d}(o, hash, type, src)
This is the symmetric of the HVIRTUAL OField path that already
existed. Anonymous structs (`{ x: x, y: y }`) emit OSetField on
HVIRTUAL receivers, so this was the last gap for them.
End-to-end on macOS arm64 native (19 / 19 green):
... (15 prior tests still green) ...
qs.hl Quicksort over [5,2,8,1,9,3,7,4,6] → "1 2 3 4 5 6 7 8 9"
big.hl qsort + memoized fib(30/40) via Map + anonymous-struct
distance computation + String.split/reverse/join — all
correct
floats.hl Math.sqrt(2)/sin(pi/2)/log(e) plus a chained multiply
clos.hl Closures-with-captures + lambdas
switch.hl "-1 -> negative" now correct (was "other" before)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se/stringify works
New FP-conversion opcodes (encoders + dispatcher cases):
- SCVTF Sd/Dd <- Wn/Xn (signed int → float, both precisions, both widths)
- UCVTF Sd/Dd <- Wn/Xn (unsigned int → float)
- FCVT Sd <- Dn (double → single)
- FCVT Dd <- Sn (single → double)
- FCVTZS Wd/Xd <- Sn (single-prec float → signed int, was BRK)
OToSFloat covers HI8/HUI16/HI32/HI64 → HF32/HF64 plus HF32↔HF64 cross-
conversion. OToUFloat covers unsigned-int sources. OToInt's HF32 path
that was BRK is now wired through a64_fcvtzs_s.
End-to-end:
json2.hl
name=Claude
age=42
version=1.5
active=true
encoded={"msg":"hi","x":1,"y":2.5}
haxe.Json.parse decodes a real nested-object string with mixed
int/float/bool/string/array fields; haxe.Json.stringify round-trips
an anonymous struct back to JSON. Float comparisons, alpha-sorting
of keys, and Std.string casts all exercise the new paths.
Note on Cast3 / typed Dynamic: when Haxe infers `parsed.age` as String
via implicit ToVirtual + virtual<age:String>, accessing an i32 field
correctly errors with "Can't cast i32 to String". That's not a JIT
bug — the JIT is faithfully reflecting Haxe's type-system intent. The
fix is to ascribe `(parsed.age:Int)` at the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…exactly
Two FP-related fixes that unblock the standard library's float path.
(1) op_jump_compare on HF32/HF64 was falling through to the integer-
compare branch. That meant Math.max(a, b) — which is implemented
in haxe.Math as `return a < b || isNaN(b) ? b : a;` — produced
silently wrong answers (returning the first arg for ints, second
for floats, neither for the actually-correct answer).
Symptom: Math.max(4, 6) returned 4. Inside the BinaryTrees
benchmark, `Std.int(Math.max(minDepth+2, n))` collapsed maxDepth
from 14 down to 4, so the iteration count was tiny and the final
XOR landed at 96 instead of the expected 26208.
Fix: branch to an FCMP-based path with AArch64-condition mapping
when either operand kind is HF32 or HF64. Same fall-through to
the integer 32-vs-64-bit decision otherwise.
(2) op_binop_fp's OSMod/OUMod paths were BRK. AArch64 has no FP
modulo instruction, so the x86 backend tail-calls libc fmod /
fmodf. Mirrored here: load d0, d1 from the operand slots, BLR
fmod, store d0 back. (Single-precision routes through fmod for
now; precision matches Anonymous's use case.)
Result on macOS arm64 native, against the @:result(...) annotations
in hashlink/other/benchs:
Fib 9227465 ✓
IntArray 11000 ✓
FloatArray 1000509 ✓
BinaryTrees 26208 ✓ (was wrong: 96)
Anonymous 328272 ✓ (was BRK on fmod)
Mandelbrot 18045783 ✓ (was wrong: 17850000)
NBodies -169237 ✗ (expected -169096; 0.08% drift,
suggests accumulated FP-rounding
order — investigate later)
19 / 19 hand-written regression tests still green:
empty, exit42, test, hello, fib, loop, strings, classes, switch,
tryc, adv, mapt, minit, calc, virt, floats, clos, qs, big.
Plus json2.hl (real haxe.Json parse/stringify) and mathmax.hl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an exported helper that flips the calling thread's per-thread JIT W^X state to "executable" on macOS arm64 hardened-runtime. Threads spawned by hl_thread_start now invoke it as the first thing in hl_run_thread, before any hl_dyn_call_safe → callback_c2hl_arm64 → JIT'd code path. No-op on every other target. macOS arm64 is the only platform that enforces per-thread W^X via pthread_jit_write_protect_np; Linux ARM64, Android, and Windows all share the executable mapping across threads once it's created. This fixes the immediate "BLR into JIT from a new thread faults because the thread is still in WRITE mode" symptom. (Note: a separate pthread-exit cleanup issue with PAC-signed thread-local destructors remains on macOS arm64 — surfaces in tests that spawn multiple sys.thread.Thread.create workers. That's not in our JIT codegen and needs a different fix in libhl's thread model.) Programs that don't use sys.thread (the vast majority) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gation
ONeg was unconditionally emitting an integer SUB Xd, ZR, Xn even for
HF32/HF64 operands. The float slot then held the result of treating
the bit pattern as a signed integer's two's-complement negation, not
a real -x for floats. Every `-b` in Haxe code that ran through this
op produced garbage.
NBodies hit this hard: `var dx = a.x - b.x;` doesn't emit ONeg, but
`px += b.vx * m;` flips through several negations during the
offsetMomentum step at start, and the propagation through 500 000
advance() calls amplified the error from "tiny" to obvious. The
benchmark's earlier off-by-0.08 % "FP drift" was not in fact IEEE
rounding — it was a real arithmetic bug masquerading as drift.
Fix: dispatch ONeg by `vreg_is_fp(dst)`. The FP path loads through
V16, emits FNEG (scalar double 0x1E614000 or single 0x1E214000),
stores back FP. Integer path unchanged.
Final scoreboard on macOS arm64 native:
Official HashLink benchmarks (other/benchs, @:result-annotated):
Fib 9227465 ✓
IntArray 11000 ✓
FloatArray 1000509 ✓
BinaryTrees 26208 ✓
Anonymous 328272 ✓
Mandelbrot 18045783 ✓
NBodies -169096 ✓ (was -169237 before this commit)
Hand-written regression (24 programs):
empty, exit42, test, hello, fib, loop, strings, classes,
switch, tryc, adv, mapt, minit, calc, virt, floats, clos,
qs, big, advanced, cast2, json2, mathmax, ray — all green.
ray.hl is a pure-FP mini ray-tracer (Vec3, Sphere, dot/sub/scale,
Math.sqrt) — `hits=818 / pixels=3200`. Catalysed finding this bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First pass at register allocation: a 1-slot cache for GPR and another for FP that remembers which HL vreg lives in which physical register. load_vreg checks the cache first and skips the LDUR (or emits a MOV instead) on a hit. store_vreg records that the source register still holds the value just written, so the next opcode's first load_vreg short-circuits. Soundness: - a64_emit invalidates both caches unconditionally. The only callers that *preserve* the cache (load_vreg, store_vreg) re-populate the slot immediately after their own emit. - A new prescan in hl_jit_function flags every opcode that is a jump target (target of a J*, OSwitch case, or OTrap, plus all OLabel / OCatch). cache_clear() runs at the start of those opcodes so a join point never inherits a stale cache from one of its predecessors. - a64_patch_branch also clears when the patched target equals the current BUF_POS — this catches *intra-opcode* control-flow joins like the HVIRTUAL OField null-vs-non-null merge that initially regressed hello.hl when the cache was added. Set HL_JIT_NO_CACHE=1 at runtime to fully disable for A/B perf or regression bisection. Measured impact on macOS arm64 (5-run wall-clock averages): BinaryTrees 2.04 s vs 2.05 s (-0.5 %) NBodies 0.24 s vs 0.26 s (-8 %) Mandelbrot 8.97 s vs 8.99 s (-0.2 %) Anonymous 0.33 s vs 0.34 s (-3 %) So 0-8 % — useful as a baseline but not transformative. A genuine linear-scan allocator that keeps multiple vregs in registers across many opcodes is the next step. All 22 hand-written tests and 7/7 official benchmarks remain green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n offset fits Was always emitting ADD x9, x9, #field_off ; LDR/STR [x9, #0]. Field offsets in HL objects are bounded and aligned, so the immediate form of LDR/STR (unsigned offset scaled by access size) usually applies: imm12 * sz = up to 32760 bytes for HI64, 16380 for HI32, 8190 for HUI16, 4095 for HUI8. When the offset fits and is aligned to the access size, skip the ADD and emit the load/store with a non-zero imm12 — saves one instruction per field access. Falls back to the ADD path otherwise. All 24 hand-written tests + 7 official benchmarks still green; perf delta on the benchmarks is within noise because their hot paths are arithmetic, not struct field reads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OAssert was BRK'ing. Implementation: call hl_assert directly. That
helper throws an HL exception with message "assert", which is the
intended behaviour for compiler-rejected casts and similar checked
operations.
OPrefetch is treated as a no-op: AArch64 has dedicated prefetch
hints (PRFM), but emitting nothing is correct, just less optimal.
Heaps validation on macOS arm64 (heaps 2.1.0 + hlsdl 1.15.0 + SDL2):
heaptest.hl h3d.Vector / h3d.col.Sphere — runs to completion
printing the expected math values.
heapwin.hl hxd.App subclass; SDL2 opens an 800×600 window,
update() ticks at ~60 fps, runs 30 frames in 0.52 s,
hxd.System.exit() shuts cleanly. Exit 0.
heapdraw.hl Same plus 50 h2d.Bitmap sprites animated with
cos/sin. Window opens, init() prints, then HL's
cast-check path inside Heaps' renderer init triggers
hl_assert. Stops short of rendering frames, but the
JIT path through Tile.fromColor and h2d.Bitmap
allocation succeeded. The hang after "assert" is the
unwind table gap (HL2C trampoline not yet emitting
EH frame info) — separate work.
24 / 24 hand-written tests + 7 / 7 official HashLink benchmarks
remain green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverted the temporary debug hook that printed [JIT-ASSERT] fn@N op#K before calling hl_assert; the investigation showed the "assert" message Heaps' renderer prints does NOT come from our OAssert opcode — it's `throw "assert"` inside hxsl/Shader.hx:45, the base-class fallback that should be replaced by the macro-generated subclass override. Test programs (Vt.hx, Virt2.hx) confirm that both HOBJ and HVIRTUAL virtual dispatch correctly route to subclass overrides on this backend. So the Heaps shader-render assert is not a vtable bug at the general level. The likely root cause is something hxsl-specific (macro-generated wrapper classes, reflection-based binding, or a shader-linkage path) that needs a focused investigation against the Heaps + hxsl source. Status remains: heapwin.hl (SDL2 window, no drawables) and heaptest.hl (h3d math, no rendering) work; h2d.Bitmap / h2d.Graphics rendering trips the shader assert during the first frame's render. Out of scope for this session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical fix: comparing two values where at least one side is HDYN,
HFUN, or HNULL was emitting a raw 64-bit pointer compare. That's
wrong — those operands are heap-allocated vdynamic boxes, and Haxe's
JEq/JNotEq semantics expect a value-deep comparison (with type-aware
unboxing).
Symptom found via Heaps:
- hxsl/Linker.hx:508 asserted with "if (f.pop() != entry)"
- The condition `if (cur.vertex == vertex)` two lines up was
comparing a Null<Bool> (Haxe nullable, boxed pointer) against a
Bool (raw value, ToDyn'd by the compiler before the JEq).
- Pointer-equality returned false even when both held `false`, so
`entry` was never pushed into the fragment list, and the linker
bailed.
Minimal repro outside Heaps:
class S { public var v:Null<Bool>; public function new(x) v = x; }
...new S(false)... should print "v == false = true" — was "false".
Fix: in op_jump_compare, if either operand kind is HDYN/HFUN, call
hl_dyn_compare(a, b) and compare its int result to 0. For OJSLt/Gte
etc., short-circuit when the result is the sentinel
hl_invalid_comparison = 0xAABBCCDD. HNULL is routed through the same
helper for now — works because hl_dyn_compare handles the boxed-
vs-boxed and boxed-vs-primitive cases correctly with type tags.
Mirrors jit.c:2087-2102. Regression-tested on 34 programs
(24 hand-written + 7 official + 3 new — nullbool.hl, enumeq.hl,
streq.hl) — all green.
Heaps progress: previously asserted at hxsl/Linker.hx:508; now
passes the linker and trips a separate "Missing buffer input
'position'" error from hxd/BufferFormat.hx — a different code path
to investigate next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the left-hand operand is an HOBJ/HSTRUCT whose runtime obj has a
compareFun (Strings being the canonical example), OJEq/OJNotEq must
treat the comparison as value-deep, not as pointer-equal. Otherwise
two String literals "pos" + "ition" and "position" compare as
different objects.
The new path emits:
cmp a, b ; same pointer → equal
b.eq L_eq
cbz a, L_neq ; either side null → not equal
cbz b, L_neq
mov x0, a; mov x1, b
call hl_dyn_compare
cbz w0, L_eq ; result==0 → equal
L_neq: (OJNotEq emits b target here; OJEq nothing) ; b L_done
L_eq: (OJEq emits b target here; OJNotEq nothing)
L_done:
We route through hl_dyn_compare rather than rt->compareFun directly
to side-step an edge case where the baked function pointer behaved
strangely (PC=13 on call). hl_dyn_compare dispatches internally by
type and is well-validated by the earlier HDYN path.
This was the second visible Heaps blocker. After landing the Null<Bool>
compare fix in the previous commit, Heaps got past the linker assert
and tripped "Missing buffer input 'position'" — caused by `i2.name ==
i.name` returning false for two distinct String objects that both
held "position". With the new HOBJ-compareFun path, that match
succeeds and h2d's render pipeline initialises cleanly. heapdemo.hl
(80 animated sprites) now runs without crashing.
Tests + benchmarks remain green (35/35).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OTrap handler had a leftover `mov x19, x0` (from an earlier draft
that planned to keep the thread pointer in a callee-saved reg across
the setjmp call). Our prologue does NOT save x19, so writing to it
silently corrupted the caller's value across every JIT'd try{}.
Symptom: a libhl C function (hl_buffer_str_sub) crashed in memcpy
because its `hl_buffer *b` parameter — which the C compiler had
stashed in x19 — was overwritten by hl_get_thread()'s return value.
The ensuing read of `b->blen` etc. landed in hl_thread_info fields,
producing nonsense sizes that pointed memcpy at unmapped memory.
The dead `mov x19` was already dominated by `mov x9, x0` doing the
real work — removal is a one-line fix that unblocks ALL Heaps demos
on Android (HeapBg / HeapDemo / Heap3D) and Heaps boot in general.
AAPCS64 callee-saved registers (x19-x28, low 64 of v8-v15) must
never be touched by the spill-everything codegen. Audit any future
encoder that takes a destination reg.
OVirtualClosure: dst = hl_alloc_closure_ptr(method_t, vtable_fn, obj). method_t resolved at JIT time by walking ra->t->obj proto/super chain to the proto with pindex == op->p3. fn pointer loaded at runtime from obj->type->vobj_proto[p3]. Validates polymorphism: Animal/Dog test correctly dispatches to override on macOS and Android. ORefData: dst = ra + sizeof(varray) — payload pointer of an HARRAY. Mirrors x86 (HARRAY only); other kinds fall through to a defensive BRK. ORefOffset: dst = ra + rb * sizeof(elem). Fast path emits ADD with LSL #log2(elem_sz) for sizes 1/2/4/8; falls back to MUL for other. Closes 3 of the 4 missing opcodes vs jit.c x86; OAsm remains x86-only.
Three sites that BRK'd on argc > 7/8 are now real codegen: 1. emit_prologue (entry): args 9+ come from caller's stack at [FP+16, +24, ...] (8B slots regardless of declared size; AAPCS64 fixed-arity). Load via LDUR(_FP) with the arg's size and store_vreg into the slot. 2. OCallClosure with captured value (> 7 user args): x0 reserved for c->value, so user args fill x1..x7 + d0..d7 + stack. Two-pass: count overflow with ngpr starting at 1; SUB SP for overflow_bytes; place args; load c->value into x0 LAST (so cache MOVs into x1..x7 don't clobber it); BLR; ADD SP back. 3. OCallMethod HVIRTUAL non-NULL (> 7 user args): same shape, but we also need to stash the vfield ptr (in x10) and o->value somewhere that survives the arg-load sequence. Allocate (16 + overflow_bytes); AAPCS overflow at [SP..stack_overflow-1]; stash at [SP + overflow .. +15]. Pop x16=vfield, x0=o->value just before BLR. Test: ManyArgs (sum of 10 ints/floats, mixed) and BigClos (closure capturing local + 8 args, mixed types).
When the closure has Dynamic type (Reflect.callMethod, dyn-typed locals),
arity is unknown at JIT time and args must be vdynamic**.
Layout:
SUB SP, SP, (nargs*8 rounded to 16) + 16
for each arg: STR vreg → SP[i*8] ; build vdynamic** array
X0 = closure ; X1 = SP ; X2 = nargs
BL hl_dyn_call ; X0 = vdynamic*
if dst != HVOID and dst != HDYN:
STR X0 → SP[args_size] ; stash result for cast
X0 = &stash ; X1 = &t_dynamic ; X2 = dst type (when needed)
BL hl_dyn_cast{f|d|i64|i|p} ; pick by dst kind
store_vreg result
ADD SP, SP, total
Verified: reflect2.hx ((Dynamic)add)(3,5) = 8 on macOS.
HABSTRACT arrays are raw memory with no varray header. Element addr = base + idx*osize directly. OGetArray HABSTRACT: - dst is HOBJ/HSTRUCT: return the address (LEA-style, osize = rt->size) - else: read value at addr (osize = sizeof(void*)) OSetArray HABSTRACT: - src is HOBJ/HSTRUCT: memcpy(addr, src_ptr, rt->size) - else: STR src at addr, 8 bytes Without this, hl.NativeArray of struct or non-ptr POD elements traps on the JIT — hits any extern that uses raw memory arrays for perf.
ONullCheck used to BRK on null instead of throwing a real HL exception. Replace with a call to hl_null_access (which throws "Null access") so try/catch in user code can intercept it. Test: NullCheck.hx wraps a null deref in try/catch (e:Dynamic) and gets the exception. OToUFloat extended: - HBOOL source: handled like HUI8 (UCVTF Sd/Dd, Wn) - HI64 source: UCVTF Sd/Dd, Xn (sf=1)
The old cache_gpr/cache_fp held one (vi, reg) entry, wiped on every
emit. That only caught back-to-back store→load. Replace with a 32-entry
ownership table per bank (reg_owner_gpr/reg_owner_fp), so multiple vregs
can stay live in registers across encoders within a basic block.
Discipline:
- Every encoder that writes a register calls kill_*(dst) BEFORE its
emit. Shared backends (mov_wide, addsub_imm_one, addsub_reg, mul,
divreg, logical_reg, shift_reg, ldst_unscaled, ldst_unscaled_fp,
fp_dp2, ldp_post, conversions, ldr_imm/ldr_fp, fmov_d) handle this
once for all their callers.
- load_vreg / store_vreg: find_*(vi) hit → MOV instead of LDR; on
miss or after a STR, claim_*(reg, vi) records ownership.
- claim_*(reg, vi) invalidates any OTHER reg in EITHER bank that
previously held vi. Cross-bank matters: OFloat stores a double's
bit-pattern via X9 (integer path), and a stale FP cache slot for
that vi must be cleared so the next FP load re-reads memory.
Without this, a tight loop reusing a const slot for 5.0 then 0.5
saw the FP load skip the LDUR and reuse the stale 5.0.
- kill_caller_saved called by both BLR (indirect) and BL (relative,
used for HL→HL calls). Forgetting BL makes recursion read garbage.
- cache_reset at branch targets (prescan-based) and at function entry.
Result on macOS arm64:
fib(36) 82ms (cache on) vs 101ms (HL_JIT_NO_CACHE=1) — 19% faster
mandel(500)x100 178ms vs 222ms — 20% faster
Correctness validated on the full bench suite (fib/mandel/btrees/maps/
excep/virtclos/nullcheck/manyargs/bigclos/reflect2) plus all Heaps demos
on Android.
The multi-slot register ownership cache introduced in commit 007f6e1 ("arm64: per-register ownership cache replaces single-slot peephole") works on macOS arm64 but crashes the h2d render path on the Android arm64 emulator. Symptoms: SIGSEGV in JIT'd code shortly after init, fault addr pattern ~0x240xxx060. Reproducible on Heaps h2d.Bitmap and h2d.Particles; h3d.scene.Mesh + texture still works fine on Android. Root cause not yet identified — an encoder that doesn't invalidate its dst, or a Heaps h2d-specific code combination not exercised by the bench suite or by Heap3D. Suspected paths: h2d.RenderContext batching, which on GLES takes a different driver path than macOS desktop GL. Workaround: gate is_cache_disabled() on `#ifdef HL_ANDROID` so the cache reads the env var on macOS (default on, HL_JIT_NO_CACHE=1 to disable) but defaults to disabled on Android. macOS keeps the 19% speedup. Android loses it but renders correctly. Unblocks Heaps demos on Android (HeapDemo, HeapBg, HeapTexFile, Sprites, Parts), at the cost of a known correctness>perf trade-off until the underlying issue is found.
…ndroid The cache_disabled-on-Android workaround in 4df272d was masking a real bug. Root cause: the cross-reg MOV form of load_vreg. When find_gpr(vi) returned a different physical register `holder`, we emitted `MOV dst, holder` instead of LDR from memory — assuming `holder` still held vi's runtime value because we'd tracked all writes to it via kill_gpr(). That assumption fails on Heaps h2d render paths (specifically hxd.BufferFormat.resolveMapping during h2d batch flush): the holder register's runtime value diverges from what our JIT-time cache claims it should hold. Concrete repro: HeapDemo (80 h2d.Bitmap sprites) SIGSEGVs in JIT'd code with a fault addr ~0x240xxx060 inside an OSetArray sequence (storing into varray->data + idx*8 + 24, where the base pointer was loaded via MOV from a register that no longer held what we thought it did). Why this divergence happens isn't fully understood — every encoder that writes a GPR calls kill_gpr(rd), every BL/BLR calls kill_caller_saved, and the prescan clears the cache at branch targets. But somewhere in the h2d render path, a write slips through. The bug does NOT manifest in fib/mandel/btrees/maps/excep/virtclos/manyargs/ bigclos/reflect2/nullcheck, in any of the existing Heaps 3D demos, or on macOS arm64 for any of those — only Android emulator h2d. Fix: keep find_gpr/find_fp and the kill/claim bookkeeping (they're useful for diagnostics and for the small same-reg shortcut), but stop emitting the cross-reg MOV. The "vi is already in dst" early-return still catches the high-value store→load-same-reg pattern from prologue arg spills and sequential same-vreg-target opcodes. Perf trade-off on macOS: fib(36) recursive: ~99ms (was ~82ms with old cache, ~99ms NO_CACHE) — neutral mandel(500)x100: ~167ms (was ~178ms with old cache, ~216ms NO_CACHE) — slight win Net effect: lose the recursion speedup, keep the FP loop speedup, gain correctness across all platforms. Revert the HL_ANDROID-specific default from 4df272d since the cache is now correct everywhere. The proper cross-reg MOV form remains an open optimization for once the underlying invariant violation is found and fixed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements a native JIT backend for ARM64 (
src/jit_arm64.c/src/jit_arm64.h), so the HashLink VM can run JIT'd code on Apple Silicon (macOS) and Linux/Android aarch64. Previously the VM was gated off on arm64 ("HashLink vm is not supported on arm64, skipping") and only the HL/C target was available there.Scope: macOS + Linux + Android on arm64. iOS/tvOS/watchOS are out of scope.
Highlights
MAP_JIT+ per-thread write-protect;other/osx/hl_jit.entitlementsadded.Build (macOS arm64):
Verified locally on Apple Silicon: builds
libhl+hl, and a Haxe program JIT-runs correctly (recursion, loops, arithmetic) withhl --versionreporting 1.16.0.This is the third of a small series upstreaming the arm64 work (after #949 and #952). A follow-up will add the Android platform glue (runtime + build) on top of this.