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

Native raw-number experiment

Status

Accepted in the contained performance campaign. This experiment retains numeric values as QBE doubles across proven native-only numeric regions instead of repeatedly encoding and decoding PitValue. It is enabled only for the nan64 representation. The f32 and fixed builds keep their existing tagged lowering because their per-operation rounding/lane rules are different.

The optimization depends on one independent correctness repair: qbe_new_float64 must call the canonical public Pit_NewFloat64, not the internal force-float __Pit_NewFloat64. The old boundary failed to re-tag exact integer results and was the reason d_field/record_field could turn x % 8 into a non-integer array key. This fix is commit 44b87db7; its focused oracle is commit 8a74ea48.

What changed

  • Promoted num locals use a QBE d register on nan64.
  • Straight-line arithmetic scratch results can remain point-specific QBE doubles when every use before the next definition is numeric-safe. A compact control-flow liveness walk proves death at labels/branches; uncertainty keeps the tagged path.
  • A quiet NaN is an internal poison for Pit null. Arithmetic propagates it, and materialization maps it back to null. This prevents the old null-to-zero-to-number laundering.
  • Every retained arithmetic result applies nan64’s actual semantic boundary: normal values in the short-float exponent range survive, subnormals become zero, and NaN/Inf/out-of-range values become poison/null.
  • Generic arithmetic validates every operand that is still dynamic. The QBE emitter transports exact, block-local numeric facts from the canonical is_num; jump_false mcode guard and from numeric literals, moves, lengths, and safe unary results. It then deletes only duplicate tag tests. Unary numeric operations, min/max, comparisons, division, modulo, remainder, and integer division preserve null/disruption behavior at raw/tagged seams.
  • Calls, invokes, captured slots, and functions with disruption handlers remain conservative. The existing frame always contains tagged PitValues; raw registers spill canonically before any frame-observing seam. The forced-GC oracle keeps a raw number live across one GC and 2,076 KiB of allocation.
  • No runtime object, record, frame, heap, or GC layout changed. There is no persistent metadata and no added per-actor memory.

This is deliberately implemented in the QBE emitter rather than as a new mcode operation. Mach semantics and its instruction stream are unchanged.

Correctness gates

The following native oracles were added:

  • raw_numeric_boundaries: division by zero, exponent under/overflow, sticky poison through later arithmetic, every unary numeric operation, min/max, floor/ceiling/round/trunc, modulo/remainder/idiv, truthiness, arrays, records, and computed tolerance operands.
  • raw_numeric_chain: point-local raw chaining and materialization.
  • raw_numeric_poison: invalid operands cannot become valid numbers later.
  • raw_numeric_literal_boundaries: out-of-range literals remain null and IEEE subnormal literals observe nan64’s canonical zero rule through loop locals.
  • raw_numeric_call_seams: ordinary and captured-function call boundaries.
  • raw_numeric_gc_seam: raw value live across allocation and copying GC.
  • Existing stone_write: disruption behavior remains Mach/native identical.

Some invalid-arithmetic checks use a native-only golden. Baseline Mach has a separate dispatch bug: several numeric opcodes call Pit_ToFloat64(null), get zero, and launder an invalid result. Cross-lane agreement would therefore bless the same wrong answer. The harness visibly prints NATIVE rather than hiding the mismatch.

Validation performed:

  • nan64 full suite, warmed: 1,918 passed, 0 failed.
  • vm_suite after explicit realization: 1,086 passed, 0 failed.
  • deterministic differential fuzz: 1,000 programs / 7,510 checks, 0 failed, seed 2601786.
  • f32 representation fixture: golden passed.
  • fixed representation fixture: golden passed.
  • all focused native seam oracles passed; d_field returned 290006 in both lanes. record_field returned 974489 for 24 consecutive native invocations before the diagnostic actor’s host turn ended.

Performance and size

All timings are medians from isolated fresh-process benchmark rows on Apple silicon. The untouched comparison worktree was exact campaign baseline 2601786e.

benchmarkbaseline nativeexperiment nativechangebaseline IL / asmexperiment IL / asm
mandelbrot27.04 ms9.64 ms-64.3%, 2.80x faster52,133 / 33,968 B62,713 / 40,052 B
float_math7.19 ms1.81 ms-74.8%, 3.97x faster29,894 / 26,743 B31,167 / 27,284 B
loop_nested29.41 ms12.20 ms-58.5%, 2.41x faster30,079 / 26,239 B24,673 / 22,749 B

loop_nested is principally the independent LICM divisor-range repair in 8791fcf1; it is listed here because it is the combined campaign state.

The raw-number tradeoff is static code size, not heap size:

  • mandelbrot: +10,580 B IL (+20.3%), +6,084 B assembly (+17.9%).
  • float_math: +1,273 B IL (+4.3%), +541 B assembly (+2.0%).
  • benchmark heap traffic is unchanged (1 KiB reported, zero GC in all three).
  • compiler-only temporary state adds maps/arrays for point liveness. Compiler time and temporary memory were not optimized; neither survives in executables.

For desktop/AOT throughput the mandelbrot exchange is worth taking: 6 KiB buys 17.4 ms per invocation. For ROM-constrained console profiles this should become an explicit size/speed policy lever after more macro coverage. The default here chooses speed because the campaign’s purpose is to discover the fastest sound lowering first.

Macro regression and repair

The first sound raw-number checkpoint was not acceptable on the other two numeric shootouts. Exact parent-worktree measurements found spectralnorm 173.22 -> 192.83 ms (+11.3%) and fannkuch 200.74 -> 209.57 ms (+4.4%). The cause was not the raw double registers themselves: the correctness work had added a fresh pair of dynamic number-tag tests to every generic arithmetic overflow path even when semantic mcode had just proved the operands numeric.

The repair is a point-local proof transport in the QBE emitter. It recognizes only an immediately adjacent is_num guard, value; jump_false guard, error pair, keeps the fact only within the current basic block, invalidates it on a definition, and drops all facts at labels/handlers. Generic arithmetic still emits one or two tag tests for operands that remain unproven. No mcode opcode, runtime metadata, or persistent executable memory was added.

Composed with full integer-range analysis, the resulting code size is now:

benchmarkcampaign baseline IL / asmrepaired IL / asmchange
spectralnorm390,232 / 256,300 B390,265 / 256,856 B+0.01% / +0.22%
fannkuch176,438 / 120,825 B164,913 / 114,301 B-6.5% / -5.4%

Thirty-run native drivers were bounded by the actor turn limit after eight spectralnorm and five fannkuch invocations, but every completed invocation returned the expected result. Runtime medians for this repaired composition must be taken in the parent harness before final acceptance; static evidence shows the original code-size regression has been removed rather than hidden.

The repaired checkpoint restored fannkuch (200.36 ms versus the 200.74 ms baseline), but spectralnorm remained 182.27 ms versus 173.22 ms despite assembly slightly smaller than baseline. Per-function inspection found the exact difference: spectralnorm’s hot inlined row functions cannot promote a stable num local because they contain calls, but point-local scratch retention still created isolated raw doubles. Each island paid raw canonicalization and then immediately crossed back to a tagged value; there was no long-lived raw register over which to amortize that boundary.

The follow-up eligibility rule therefore requires at least one promoted fraw local in the function before a scratch result can remain raw. Mandelbrot and float_math both have such regions and are byte-for-byte unchanged; fannkuch is also unchanged. Spectralnorm’s local native driver remains correct, with the anchored-only arm at 394,852 / 259,579 bytes IL/assembly. Parent-harness timing is the acceptance gate for this last rule. The criterion is structural and representation-based, not benchmark-, source-name-, or size-based.

Final composed acceptance gate

Closed-world inference for ordinary hoisted function declarations removed the unrelated dynamic-parameter code shape that had made spectralnorm a hostile host for raw scratch values. A controlled temporary raw-on/raw-off switch was then measured and deleted:

benchmarkraw onraw offchangeIL / assembly change
mandelbrot9.108 ms73.838 ms-87.7%-16.7% / -29.0%
float_math1.853 ms11.904 ms-84.4%-6.3% / -4.8%
spectralnorm125.630 ms125.938 msneutral (-0.24%)-1.8% / -2.7%
fannkuch239.19 ms245.50 msnoise; lowering does not engageidentical

Every result matched its golden. An earlier same-session spectral pair moved in the other direction under host drift, so no raw runtime credit is claimed for that row; the artifact is smaller and, critically, the old +11.3% regression is gone. This clears the macro gate for keeping raw regions.

The final decoder refinement combines validity and tagged-value decoding into one representation-specific branch tree. Nonnumbers become the existing NaN poison directly instead of first taking an is_num tree and then a second int/float decode tree. Nan64, f32, and fixed implement the same sticky-invalid contract. On the current composed root it improved spectralnorm 128.74 -> 124.65 ms while shrinking IL 174,589 -> 170,554 bytes and assembly 114,160 -> 112,376 bytes. It adds no frame, heap, metadata, or per-actor state.

Rejected arms

  • Leaving IEEE NaN/Inf live without applying Pit’s numeric boundary was fast but observably wrong after later arithmetic.
  • Mapping invalid values to 0.0 was also fast and unsound.
  • A branchless bit-mask canonicalizer roughly doubled mandelbrot time in its measured arm (about 16.9 to 35.4 ms); it was deleted.
  • Eagerly materializing every result remained correct but forfeited most of the win. Canonicalization is now paid only at semantic operation boundaries while the value remains raw.

Benchmark and tooling findings

  • A cold full suite charges realization/compilation against each test deadline. vm_suite repeatedly timed out while still compiling; running the program once to realize it, then rerunning the test, produced 1,086/1,086 and the warmed full suite produced 1,918/1,918. Setup/readiness must be separated from execution deadlines.
  • pit test fuzz is stale discoverability; it exits and tells the user to run pit fuzz. The latter passed.
  • The representation script needs PIT_INPROCESS=1 reliably in this worktree; the first post-reconfiguration invocation can otherwise wait on daemon/CLI lifecycle rather than test the representation.
  • This worktree’s full spectralnorm/fannkuch rows disrupt in Mach before the first timed invocation. The parent worktree can run them and supplied the exact rejected-checkpoint timings above. Native-only drivers work here, so this is benchmark orchestration friction rather than a compiler result.
  • A record_field vs-harness run ends its actor after native compilation, while the smaller minrun driver completes 24 correct native invocations. The benchmark driver needs to surface why the actor was stopped instead of ending with only an actor-halted line.

Next experiments

  1. Fix the Mach invalid-number laundering so all raw numeric goldens can return to ordinary Mach/native agreement.
  2. Measure compiler peak memory after the now-accepted removal of the 128-instruction integer-range budget; executable speed remains the gate.
  3. Add an explicit AOT size profile that disables or narrows raw-number regions for ROM-constrained targets, while retaining the speed profile measured here.

Source: plans/archive/perf-2026-07/perf-raw-numeric.md