Backend refactor cross-architecture findings
Date: 2026-07-19
Revision tested: 8d7927db (refactor(opt): make scratch and clobber ownership explicit)
Run
make test-cross TARGET=all DEPTH=full KIT_VM=1 RUN=1
The run covered all 19 configured target selectors and completed in about 3h32m:
- 233 lane checks passed, 26 failed, 0 skipped.
- All compile/link smoke cases passed.
- All basic smoke executions passed.
- Full corpora ran where wired; Android and 64-bit freestanding targets have smoke-only coverage.
Findings
| Scope | Finding |
|---|---|
| Hosted x64, O0/O1 | Aggregate/sret returns are corrupt across Linux, FreeBSD, Windows, and macOS. x64_copy_bytes() resolves a stack source through R11 while R11 holds the sret destination, redirecting stores into the stack frame. |
| x64 and RV64, O1 | Overflow-result tests miscompile. |
| Arm32, O1 | Two aggregate-return cases abort emission with no instruction-local native temporary. |
| Windows x64 | Packed indirect musttail is rejected at O0 and returns 173 at O1. |
| Linux RV64 | Initialized C TLS returns zero under both glibc and musl at O0/O1. |
| FreeBSD RV64 dynamic | 334 cases per FreeBSD matrix fail before execution because libc.so.7 uses System V ELF OSABI and carries FreeBSD identity in NT_FREEBSD_ABI_TAG; Kit classifies it as Linux. The other 10 failures are the x64 regressions above. |
All actual AArch64 cases passed. RV32 toy/parser coverage, all glibc/musl libc suites, and non-x64 front/back amalgam executions also passed.
Linux x64 QEMU-user processes reported SIGSEGV and then hung; the affected test containers were stopped, so the aggregate log records status 137. Native FreeBSD and macOS runs reproduced the segmentation fault.
Artifact
Full log: build/test-logs/test-cross-all-full.log
The tested binaries were built from clean revision 8d7927db. Workspace edits made after that build were not included in this run.
RV64 follow-up
The RV64 failures above had three separate causes:
- The optimized emitter forwarded folded integer constants to every native
intrinsic as
NATIVE_LOC_IMM, but the RISC-V intrinsic hook consumes value operands as registers. The NativeTarget boundary now materializes value immediates by default and has an explicit per-target capability for the few backends that accept them; shared control immediates remain target-neutral. - The hosted RISC-V TLS lowering already followed the psABI:
tppoints at the start of the TLS image. The executable test harness copied.tdata16 bytes pasttp, so initialized TLS read the zeroed guard area. The harness now setstpto the actual image address. - FreeBSD's RISC-V
libc.so.7identifies itself with a FreeBSD ELF note while leavingEI_OSABIas System V. ELF detection now recognizes a boundedNT_FREEBSD_ABI_TAGnote in either aPT_NOTEsegment orSHT_NOTEsection, with a synthetic unit test covering positive and negative identities.
Targeted validation after the fixes:
- RV64 C/parser overflow and initialized-TLS cases, plus the Toy intrinsic overflow case, pass at O0 and O1.
test-rv64-tls-linkandtest-rv64-inlinepass.- RV64 assembler encode/decode coverage passes 43 cases with the one existing missing-golden skip; the decoder unit passes 101 checks.
- A dynamically linked FreeBSD RISC-V toy executable links and runs in the VM at O0 and O1.
- The shared native-boundary unit tests pass, and the overflow case remains green at O0/O1 on x86-64 and AArch64.
This was a targeted RV64 follow-up, not a repeat of the full 19-target matrix.
RV32 follow-up
RV32 uses the same RISC-V NativeTarget implementation as RV64, with xlen=32,
so the follow-up emphasized the refactor-sensitive shared paths: temporary and
clobber ownership, intrinsic operand locations, call marshalling, small
aggregate arguments, TLS, and both RV32 ABIs used by the test harness.
The original full cross log was already clean for RV32: the Toy corpus passed 427 cases with two intentional AArch64-only skips, and the parser corpus passed 970 cases with 64 intentional skips. Current-tree validation added:
- Targeted parser cases for signed-overflow intrinsics, initialized TLS, instruction-temporary boundaries, machine-clobbered returns, and small aggregate argument spills pass at O0 and O1.
- The Toy bit/overflow intrinsic case passes at O0 and O1.
test-smoke-rv32passes all 20 checks. This covers hard-floatilp32fand soft-floatilp32at O0/O1, automatic runtime linking, concurrent runtime cache lanes, ELF flags, mixed-ABI rejection, and the negative control.- The RV32 assembler corpus passes all 16 cases and the decoder unit passes all 96 checks. The JIT image/lookup test builds and validates its bytes; native execution is intentionally skipped on the non-RV32 host.
- The current full Toy run again passes 427 cases with the same two intentional
skips. The full parser run recorded 969 passes and one QEMU timeout, plus the
same 64 intentional skips. The timed-out O0
6_5_45_ptr_eq_nullartifact disassembles correctly, its saved ELF returns 3 when run directly, and an isolated rerun passes at both O0 and O1. This is a transient QEMU/harness timeout rather than a reproducible backend failure; the heavy corpus was not rerun merely to replace that result. test-cross TARGET=freestanding-rv32 DEPTH=smoke KIT_VM=1passes all three compile, link, and execution lane checks.
No RV32-specific or shared production-code change was required. The shared immediate-location correction made during the RV64 follow-up remains the appropriate architectural fix and is covered by the RV32 overflow probes.
ARM32 follow-up
The clean 8d7927db revision reproduced both ARM32 failures from the original
matrix exactly: 130_record_sret_return in the Toy corpus and
6_8_6_4_05_struct_return_mixed_fp_int in the C corpus passed at O0, while O1
aborted after the first three ABI result registers made a fourth temporary
unavailable. The shared native emitter must load a frame/stack return part
directly into its final ABI register; routing it through a temporary creates a
false fifth-register requirement. The current emitter does that direct load.
The shared regression now fills an entire four-register result bank, matching
the ARM32 failure rather than testing only two parts.
The audit also found two independent contract bugs that the original corpus could not expose reliably because Kit was both caller and callee:
- The AAPCS32 classifier treated arguments and results symmetrically at a
16-byte cutoff. AAPCS32
instead returns composites larger than one word through caller-provided
storage, while every statically sized composite argument remains a by-value
memory image split across r0-r3 and the stack. Classification now represents
that asymmetry directly.
ABIArgInfo.npartsis consequently 32-bit: a large fixed-size argument cannot silently truncate at 65,535 word lanes. - Known-frame prologues did not include callee-saved registers named by ARM32
O1 inline-asm clobbers. Parsing the raw target clobbers, expanding the shared
ABI clobber sets, and merging the result with allocator-used callee saves now
lives in
cg/native_asm.c; AA64, x64, RISC-V, and ARM32 provide only their register-name parser and target preservation predicate. An ARM32 O1 test verifies that a named r4 clobber produces a matching save/restore, and r7, sp, and pc are rejected as structural clobbers.
The shared direct-asm binder also used an I64 location as the address carrier for every memory operand. It now selects I32 or I64 from the target pointer width, keeping this target-neutral path valid on ARM32 and RV32.
Validation on the corrected tree:
- The two historical cases pass at O0 and O1. Focused AAPCS cases covering 3-byte tails, 4/8/16/32-byte records, 8-byte alignment, and a 65,537-word argument pass; the ABI classifier unit is 495/0.
- Two-way Clang interoperability passes at O0 and O1: a Clang caller consumes Kit's 8-byte composite return and 32-byte by-value argument, and a Kit caller consumes the same interfaces implemented by Clang.
- Shared native-boundary units pass: register effects 102/0, direct target 55, location MIR 57/0, full-bank frame-result emission 335/0, and frame-value backends 316/0. All four native inline-asm units pass. ARM32 ISA decoding is 583/0 and target-feature coverage is 32/0.
test-cross TARGET=freestanding-arm32 DEPTH=full KIT_VM=1 RUN=1passed all eight compile/link/smoke/amalgam/Toy lane checks; Toy was 427 pass, 0 fail, 2 intentional skips. The parser batch recorded 966 pass, 2 QEMU timeouts, and 66 intentional skips. Both timed-out O0 artifacts (6_5_9_01_suppressed_null_ptr_cmp,6_6_01_enum_const) and their O1 forms passed immediately in isolated serial runs (4/0, 0.28-0.92 seconds), so the only non-green full-lane verdict is a transient harness timeout rather than a reproducible backend failure. The heavy corpus was not rerun merely to erase that result.
Artifacts:
build/test-logs/test-cross-freestanding-arm32-full.logbuild/test-logs/arm32-timeout-isolated.log
X64 follow-up
The hosted x64 failures exposed a broader ownership problem than the original
R11 aggregate-copy corruption. Exact-width 3/5/6/7-byte loads and stores chose
address and data temporaries through backend-global part_scratch state while
the generic x64 memory emitter independently borrowed the same private bank.
Those operations now stabilize the complete effective address in an explicitly
selected private register for their local phase and use the other private
register for chunk data. The backend no longer carries part_scratch.
Call setup had the same hidden-state shape. Stack bias, parallel-copy scratch,
tail-call state, and indirect-callee parking now live in a local
X64CallMarshalCtx; the old backend-global call_callee_parked flag is gone.
The mutating interface is named marshal_call/marshal_ret and fills a
NativeCallPhase, making its ownership and lifetime distinct from the pure
call_stack_bytes frame-sizing query. Dead call-plan clobber/return mask fields
were removed.
The cross-architecture audit then consolidated the shared scratch/clobber model:
NativePhysRegInfo.flagsis the single static authority for allocability, reservation, argument/return roles, and caller/callee preservation. Role masks are derived centrally. The only live-ABI override is x64 caller/callee preservation, where SysV/Apple and Win64 differ.- A shared
native_reg_info_validate()checks physical rows, ordered policy lists, role contradictions, temp-bank separation, asm-temp reservation, emit-temp non-allocability, and emitter cache containment at both the O0 direct-target and O1 machinization boundaries. Machinization separately adds the policy-owned emitter bank to each function's reserved set. - O0 inline-asm register selection and ABI-clobber expansion live in
cg/native_asm.c; targets declare only their ordered operand pool and retain target-specific constraint parsing/emission. Known-frame asm/callee-save merging is shared there as well. - Parallel argument-copy scratch is class-tagged explicitly, so physical register zero cannot double as “unconfigured”; cycle breaking rejects a missing, invalid, or live-aliased scratch register.
- Machine-operation effects are exhaustive through
NATIVE_MOP_COUNT. Focused x64 coverage includes division/remainder, variable shifts, bitfields, atomics, variadics, intrinsics, syscalls, and TLS. Exact role-mask tests cover SysV x64, dynamic Win64 preservation, AA64, RV64, and ARM32; they found and corrected missing argument-role flags on x64 XMM6/XMM7.
Two cross-harness ownership bugs were also corrected while validating x64:
FreeBSD/Windows full selectors now pass only the requested VM architecture,
and parse startup objects are cached per architecture rather than sharing a
mutable parse_start.o across concurrent workers. The x64 execution lane now
classifies the IEEE-binary128 semantic fixtures as inapplicable (while retaining
the layout/macro probe), matching the actual SysV/Apple x87 and Win64
double-aliased long double ABIs.
Final validation, in the requested platform order:
- macOS x64: Mach-O build plus direct and queued Rosetta execution, 3/3.
- Linux x64, glibc + musl, full depth: 42/42 cross lanes. Each target passed 427 Toy cases and 1,004 parser executions; glibc passed 9 libc cases and musl passed 18. Only the documented AA64-only and inapplicable x64 binary128 cases skipped.
- FreeBSD x64, full depth: 13/13 cross lanes and 668/668 applicable Toy cases.
- Windows x64, full depth: 13/13 cross lanes and 334/334 applicable Toy cases, including FP callee saves, typed-asm ABI clobbers, indirect packed-stack musttail, aggregate returns, variadics, and O0/O1 paths.
- Shared focused units: 865/865 checks plus all x64/AA64/RV64/ARM32 inline-asm units. Tier-1 optimizer and architecture suites both pass.