Skip to content

fix(page): derive the extend bound from the geometry, not a 64 KiB literal - #34

Merged
Ttimmahlax merged 2 commits into
mainfrom
page-capacity-lead
Sep 10, 2026
Merged

fix(page): derive the extend bound from the geometry, not a 64 KiB literal#34
Ttimmahlax merged 2 commits into
mainfrom
page-capacity-lead

Conversation

@Ttimmahlax

Copy link
Copy Markdown
Contributor

Follows the Kairos RTOS step report (docs/plans/finished/fixed-prim-small-step.md, §8.6–8.7). PR #32 corrected the attribution; this fixes the defect that chase led to.

The defect

page_extend bounds its batch at 4 KiB of payload and computed it as:

let span_shift = 4 + (*page).slice_count.trailing_zeros();
let take = ((reserved >> span_shift).max(1)).min(reserved - capacity);

The identity is 4096 / bsize == reserved / (slice_count * 16), and the 16 is SEGMENT_SLICE_SIZE / 4096. So the literal 4 is log2(16) and holds only at the shipped 64 KiB slice.

Under --cfg ra_small_profile the slice is 4 KiB, the true factor is 1, and the bound was 256 bytes of payload instead of 4 KiB — sixteen times too small. For a 512-byte class 8 >> 4 == 0, .max(1) rescues it to one, and every page was extended one block at a time. A page with capacity == 1 has no second block, so the fast path could never hit and malloc_generic ran on 100% of allocations.

Measured

Counters, 100,000 alloc+free pairs, small profile — deterministic, no clock:

size generic/op before after blocks/extend
512 1.0000 0.1250 8
513 1.0000 0.1667 6
1024 1.0000 0.2500 4

Page carve-and-retire churn: 195 per 100,000 → 24 / 32 / 49.

Timing, 32-bit host, ABBA-interleaved, reproduced three times: the 512-vs-513 step inverts from +8.6% (slower) to −36% (faster), and 512 in absolute terms goes 390,200 ns → ~200,000 ns for the same 50,000 pairs, about 1.9× faster.

Blast radius

The default geometry does not move. 65536.trailing_zeros() - 12 == 4, the old literal. The all-features x86-64 assembly diff against main changes exactly one symbol — the debug-record blob — with no executable function touched. This is a ra_small_profile fix.

Not claimed

Not measured on silicon; that rig is the consumer's. The prediction to falsify is §7's heap_4 A/B: 256–512 should stop being the only range rusty_alloc loses. Also still open, and untouched by this: the bin route enters malloc_generic on every op even after the fix.

Gates

20 suites at default / small profile / 256k geometry, gate-selftest 11/11, wasm 20,168 (baseline 20,169), unsafe census clean, clippy 0.

🤖 Generated with Claude Code

tim-almond-house and others added 2 commits September 10, 2026 15:50
…teral

`page_extend` bounds its batch at 4 KiB of payload and computed it as
`span_shift = 4 + slice_count.trailing_zeros()`. The identity behind that is
`4096/bsize == reserved / (slice_count * 16)`, and the `16` is
`SEGMENT_SLICE_SIZE / 4096` -- so the literal `4` is correct only at the
shipped 64 KiB slice.

Under `--cfg ra_small_profile` the slice is 4 KiB, so the bound was 256 bytes
of payload instead of 4 KiB. For a 512-byte class the batch computes to 0,
`.max(1)` clamps it to ONE, and every page carried `capacity == 1` -- no second
block for the fast path to find, so `malloc_generic` ran on 100% of
allocations.

Counted, 100,000 alloc+free pairs at the small profile, generic entries per op:
512 B 1.0000 -> 0.1250, 513 B 1.0000 -> 0.1667, 1 KiB 1.0000 -> 0.2500; page
carve-and-retire churn 195 per 100,000 -> 24/32/49. On a 32-bit host the
512-vs-513 step inverts from +8.6% (slower) to -36% (faster), 512 absolute
390,200 -> ~200,000 ns for the same work, reproduced three times.

The default geometry does not move: 65536.trailing_zeros() - 12 == 4, the old
literal, and the all-features x86-64 asm diff against main changes one symbol,
the debug-record blob, with no executable function touched.

Found by tracing collect_inner's reclaim while chasing the Kairos RTOS's
512-byte step: every reclaimed page printed cap=1 against resv=8, and reserved
being right while capacity was 1 named the extend.

Gates: 20 suites at default / small / 256k, gate-selftest 11/11, wasm 20,168,
census clean, clippy 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Withdraws the "not measured on silicon" caveat. XIAO ESP32-S3, main against the
fix, one board and one session, same 192 KiB region, same 166 ns/op floor and
identical checksums on every row (work parity):

  pingpong 32 B          595 -> 518   13.0% faster
  batch 64 mixed 8-512   833 -> 702   15.7% faster
  churn 64 live 8-512  1,011 -> 856   15.3% faster
  large 2,048 B        1,134 -> 1,121  1.1%

13-16% on every binned workload, the same magnitude as the 16% step the report
opened with. 2,048 B barely moving is the tell that it is the same mechanism:
that size is on the bin route, which still enters malloc_generic on every op and
is untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ttimmahlax
Ttimmahlax merged commit 6c6d801 into main Sep 10, 2026
10 of 12 checks passed
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.

2 participants