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
numlocals use a QBEdregister 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_falsemcode 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_suiteafter 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_fieldreturned 290006 in both lanes.record_fieldreturned 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.
| benchmark | baseline native | experiment native | change | baseline IL / asm | experiment IL / asm |
|---|---|---|---|---|---|
| mandelbrot | 27.04 ms | 9.64 ms | -64.3%, 2.80x faster | 52,133 / 33,968 B | 62,713 / 40,052 B |
| float_math | 7.19 ms | 1.81 ms | -74.8%, 3.97x faster | 29,894 / 26,743 B | 31,167 / 27,284 B |
| loop_nested | 29.41 ms | 12.20 ms | -58.5%, 2.41x faster | 30,079 / 26,239 B | 24,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:
| benchmark | campaign baseline IL / asm | repaired IL / asm | change |
|---|---|---|---|
spectralnorm | 390,232 / 256,300 B | 390,265 / 256,856 B | +0.01% / +0.22% |
fannkuch | 176,438 / 120,825 B | 164,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:
| benchmark | raw on | raw off | change | IL / assembly change |
|---|---|---|---|---|
| mandelbrot | 9.108 ms | 73.838 ms | -87.7% | -16.7% / -29.0% |
| float_math | 1.853 ms | 11.904 ms | -84.4% | -6.3% / -4.8% |
| spectralnorm | 125.630 ms | 125.938 ms | neutral (-0.24%) | -1.8% / -2.7% |
| fannkuch | 239.19 ms | 245.50 ms | noise; lowering does not engage | identical |
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.0was 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_suiterepeatedly 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 fuzzis stale discoverability; it exits and tells the user to runpit fuzz. The latter passed.- The representation script needs
PIT_INPROCESS=1reliably 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/fannkuchrows 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_fieldvs-harness run ends its actor after native compilation, while the smallerminrundriver 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
- Fix the Mach invalid-number laundering so all raw numeric goldens can return to ordinary Mach/native agreement.
- Measure compiler peak memory after the now-accepted removal of the 128-instruction integer-range budget; executable speed remains the gate.
- 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