Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.

Mach nullable-numeric decode experiment

Date: 2026-07-13
Base: 0da78edd
Branch: codex/perf-mach-guard-fusion

This is a separately reviewable Mach/runtime experiment. It changes no semantic mcode, no serialized Mach opcode, and no language rule.

Correctness defect

Every arithmetic expression is currently marked num by mcode even though division by zero and non-representable results are observably null. That allows a result to feed later add/subtract/multiply/divide without another guard. Both backends then decoded tagged null as numeric zero.

The regression computes a dynamic 1 / 0, feeds it to all four operations, and returns one bit for each result that remains null. The correct result is 15; before this experiment both Mach and native returned 0.

Raw and streamlined mcode agree: the downstream operations are emitted without guards because their operand slot is considered numeric. This is not a streamline transformation error. pit-compiler/resolve.cm even documents the null-to-zero downstream coercion as current behavior, but it contradicts the value contract that unrepresentable numeric results are null and permits a failed numeric computation to become an ordinary non-null number.

Rejected repair: checks in every handler

The direct repair checked both operands for null in each generic Mach add/subtract/multiply/divide handler. It fixed the oracle, but spectralnorm’s same-period median regressed from 286.55 to 308.51 ms (+7.7%). Its float-heavy loop pays two extra tag tests on almost every one of 64 million arithmetic dispatches. That implementation was deleted.

Retained Mach repair

Mach mcode already guarantees that these handler operands are number-or-null. A small inline decoder now implements exactly that internal contract:

  • tagged int -> direct int-lane-to-double conversion;
  • tagged null -> NAN, which is absorbing through hardware arithmetic and is mapped back to PIT_NULL by the existing non-finite result check;
  • short float -> direct representation decode.

The four generic handlers use this decoder instead of making eight out-of-line Pit_ToFloat64 calls. The integer/integer fast path is unchanged. This both fixes null propagation and removes call overhead from the normal float path.

Mach performance

Each result is the median of three three-run medians from fresh processes. The baseline and experiment were rebuilt from adjacent trees in the same measurement period. Dispatch counts and serialized Mach sizes are identical.

benchmarkbaseline msexperiment msdeltarole
float_math18.8214.60-22.4%float arithmetic micro
spectralnorm286.55263.16-8.2%float-heavy macro
mandelbrot105.0391.62-12.8%float-heavy macro
fannkuch294.55292.99-0.5% (noise)integer/control macro

Exact results remain float_math 525855, spectralnorm 1623647009, mandelbrot 1970674, and fannkuch 3000008629.

There is no per-actor, per-frame, or per-code memory. The exact same-tree shared-library differential is:

  • dylib file: 1,454,232 -> 1,454,232 bytes (unchanged);
  • __text: 1,029,284 -> 1,029,768 bytes (+484 bytes);
  • cstring, const/data sections, aligned virtual segments: unchanged.

Correctness and tooling receipts

  • Dedicated compiler/Mach oracle: expected and obtained 15.
  • Standalone reproducer: aot_bench/regress/null_numeric_launder.ce.
  • aot_bench/dumpil.ce now accepts DUMP_MODE=raw, making raw/streamlined comparison possible without editing the driver for every investigation.
  • make: passed.
  • compiler tests: 67/67.
  • first cold full suite: 832/833; sole failure was the known realization-time vm_suite timeout.
  • immediate isolated vm_suite: 1086/1086.
  • warmed full suite: 1918/1918.
  • ./pit fuzz 500 --seed 20260713: 3733/3733.

The new C helper allocates nothing and holds no heap PitValue across an allocation, so the GC-rooting checklist does not apply.

Native remains an explicit failing lane

This commit does not claim to repair native. The same standalone oracle still returns 0 in native at this point. That is a pre-existing backend defect and is being handled as a separate experiment so a native code-size or hot-path regression cannot hide inside the Mach win.

A rejected native prototype made the global tagged numeric decoder map null to NaN. It fixed native (0 -> 15) but expanded spectralnorm IL from 388,818 to 407,435 bytes (+4.8%), assembly from 257,196 to 269,552 bytes (+4.8%), and its one-run same-period native time from 173.96 to 175.99 ms (+1.2%). It was deleted.

The lower-cost native design under test is:

  1. preserve NaN/Inf absorbingly inside promoted raw-double (fraw) regions;
  2. decode nullable values only at tagged/fraw spill, reload, and write seams;
  3. for generic tagged arithmetic, combine the two exact-null predicates into one branch to a null result, and omit it when both operands are already raw or statically non-null.

That repair will be kept only if it fixes native 15 without a meaningful regression on non-null float loops.

Recommendation

Keep the Mach change independently. It is a correctness repair and a large, repeatable Mach speedup with no dynamic-memory or bytecode cost. Review native separately; do not weaken or revert this Mach improvement merely because QBE needs a different representation-aware seam.

Source: plans/archive/perf-2026-07/perf-campaign-mach-nullable-numeric.md