Skip to content

Add AArch64 JIT backend#956

Open
Pign wants to merge 31 commits into
HaxeFoundation:masterfrom
Pign:up/arm64-jit
Open

Add AArch64 JIT backend#956
Pign wants to merge 31 commits into
HaxeFoundation:masterfrom
Pign:up/arm64-jit

Conversation

@Pign

@Pign Pign commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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

  • Opcode coverage brought up incrementally (memory ops, calls/closures, virtuals, enums, try/catch, switch, FP/SIMD conversions, comparisons). The commit series is kept granular on purpose — each commit documents one opcode or fix for review.
  • macOS arm64 W^X via MAP_JIT + per-thread write-protect; other/osx/hl_jit.entitlements added.
  • Passes the official HashLink benchmark suite and a battery of hand-written tests; runs Heaps math/transform workloads.

Build (macOS arm64):

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --target libhl hl

Verified locally on Apple Silicon: builds libhl + hl, and a Haxe program JIT-runs correctly (recursion, loops, arithmetic) with hl --version reporting 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.

Pign and others added 30 commits July 14, 2026 11:26
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant