Skip to content

perf(layer-stream): pack the pinned arenas largest-first when that page-locks less (#1702) - #1713

Merged
MakazhanAlpamys merged 6 commits into
mainfrom
perf/pinned-arena-packing-1702
Oct 7, 2026
Merged

MakazhanAlpamys merged 6 commits into
mainfrom
perf/pinned-arena-packing-1702

Conversation

@MakazhanAlpamys

@MakazhanAlpamys MakazhanAlpamys commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Closes #1702.

This is a memory change. The speed series of #1702 has been run: by the rule written before any
run, no difference in step speed is measurable at its resolution of +/-15%. Nothing here claims
a speed-up or a slow-down.

What changes

plan_pinned_arenas (src/soup_cli/utils/layer_stream_runtime.py) walked the tensors in
allocation order and only looked at the arena it opened last. A large tensor that arrives late
stranded the space in front of it.

It now plans two layouts:

  • the in-order walk, unchanged (moved into _plan_in_order, same lines);
  • stable first-fit decreasing (_plan_largest_first): largest tensor first, each into the first
    arena with room, placements written back at the ORIGINAL tensor indices.

The second is returned only when it page-locks strictly fewer bytes. On a tie the in-order
plan comes back, placement for placement, so a store that gains nothing is laid out exactly as
before. Same capacity rule, same 2 GiB ceiling, same alignment, same power-of-two trim. No config
field and no CLI flag.

Memory: measured

Qwen3-8B, NF4 double-quant, RAM tier, on main plus this change (RTX 5070 Laptop, torch
2.14.0+cu130), real runs:

in-order plan packed plan
arenas 4 x 2 GiB 3 x 2 GiB
page-locked bytes 8,589,934,592 6,442,450,944 (-25%)
store bytes (1,154 tensors) 6,073,035,760 6,073,035,760
peak process RSS (3-step run) 10.85 GB 8.71 GB
peak VRAM allocated / reserved 2.807 / 3.047 GB 2.807 / 3.047 GB

Disk tier: 4,294,967,296 bytes of staging in both arms. The layouts tie there and the in-order
plan is kept.

Arithmetic only, on the two Qwen2.5-14B geometries the #901 tests already hold: NF4 decoder
7,381,975,040 -> 6,845,104,128 (-7.3%), bf16 decoder 28,991,029,248 -> 26,843,545,600 (-7.4%).

Correctness: strict control passed

Three optimizer steps under torch.use_deterministic_algorithms(True) and
CUBLAS_WORKSPACE_CONFIG=:4096:8, one fresh process per arm and mode. After every step: SHA-256
of the full logits, the loss, all 144 LoRA gradients and all 144 updated adapter tensors.

mode in-order packed digests equal
RAM tier, page-locked bytes 8,589,934,592 6,442,450,944 870 of 870
disk tier (direct I/O) 4,294,967,296 4,294,967,296 870 of 870

That is a statement about three deterministic steps: the two layouts hand the same bytes to the
same computation. It is not a claim about default execution or a long run.

Speed: no difference measurable at +/-15.1%

The plan and the decision rule were written in #1702 before any run and are copied into
benchmarks/gate-1702-arena-packing.md. benchmarks/harness/issue1702_arena_pack_gate.py runs
it: in-order vs packed, A B B A per seed, 40 fresh-process runs, the RAM tier as the subject and
the disk tier as the negative control. The in-order arm is made inside the process by pointing
_plan_largest_first at _plan_in_order; there is no switch in the config.

One series, 2026-10-07 16:40Z-17:16Z, at 4e692d31, nothing else on the box, AC power in every
stamp. 40 of 40 runs completed, no failed run.

seed RAM tier d control d
3 -2.71% +3.03%
17 +4.75% -0.39%
29 -1.41% +9.69%
43 +3.58% -0.34%
59 +5.61% -15.13%
mean (sample SD) +1.96% (3.77%) -0.63% (9.09%)

The control's mean is inside +/-3%, so the series stands. The band is 15.13%, the largest |d| in
the control. The RAM tier's +1.96% is inside it: no difference measurable at +/-15.13%, and
this PR claims memory only.

mode, arm (10 runs each) tokens/s, mean (SD) median step store build peak RSS
RAM, in-order 515.8 (30.7) 0.972 s 11.5 s 10.84 GB
RAM, packed 525.5 (31.5) 0.963 s 9.0 s 8.69 GB
disk, in-order 273.1 (24.2) 1.866 s 1.7 s 6.13 GB
disk, packed 270.6 (22.2) 1.882 s 1.5 s 6.12 GB

The band is wide because the control ran in two regimes: 15 of its 20 runs have a median step of
1.86-2.01 s and 5 have 1.62-1.64 s, and two seed blocks were split between them. The plan is the
same in both arms on the disk tier, so that is the box, not the change. The RAM tier shows no such
split, but the rule takes the band from the control and the rule was written first. So the series
cannot exclude a change of less than 15% in either direction, and it shows none.

The driver was rehearsed end to end on Qwen2.5-1.5B beforehand (40 of 40 runs completed; not a
measurement of anything). The rehearsal found the laptop on battery, where a step takes 0.83 s
against 0.31 s on AC, so the driver refuses to time on battery and a series with any run stamped
on battery is INCONCLUSIVE. That rule was added to the record before the series ran.

Tests

tests/test_issue1702_pinned_arena_packing.py (17 CPU tests, 1 on CUDA):

  • the Qwen3-8B geometry plans three 2 GiB arenas, and four with the in-order walk;
  • the Qwen3-8B and 70B disk-staging geometries keep their in-order placements;
  • a geometry where the sorted layout has the same arena count but trims to more bytes keeps the
    in-order plan;
  • placements stay indexed by allocation order (bytes written through the plan come back through
    it); equal sizes keep their order; zero-byte and over-ceiling tensors keep their contracts;
  • 400 seeded random geometries: the returned plan is always a valid layout, never costs more
    than the in-order one, and equals it on a tie (339 packed, 61 kept);
  • the disk tier carves a packed plan at the right indices (pageable memory stands in);
  • on CUDA: a store that selects the packed plan is bit-identical to its shards, charges the host
    allocator exactly the arena sizes, and round-trips through non-blocking host-to-device copies.

Written first: against the planner without the choice, 11 tests fail on the old numbers (10 in
this file, 1 in the #901 file).

Mutations, each killed by a named test:

mutation killed by
tie goes to the packed plan (< -> <=) test_equal_cost_disk_staging_retains_original_placements
always the packed plan test_the_same_arena_count_is_not_a_saving_when_the_trim_rounds_up
never the packed plan test_late_vocabulary_weights_reuse_the_decoder_arenas_slack
no write-back to the original indices test_placements_stay_indexed_by_allocation_order
packed walk does not align test_late_vocabulary_weights_reuse_the_decoder_arenas_slack
smallest first test_late_vocabulary_weights_reuse_the_decoder_arenas_slack
over-ceiling tensor gets no wider arena test_zero_byte_and_oversized_tensors_keep_their_contracts
next-fit instead of first-fit test_late_vocabulary_weights_reuse_the_decoder_arenas_slack
ties among equal sizes reversed test_equal_sizes_keep_their_allocation_order

tests/test_issue1702_arena_pack_gate.py (44 CPU tests) holds the gate's schedule, every branch
of its decision rule and what the driver makes of each ending of a run (finished, hung, died,
stale result, battery power); 24 mutations of the harness are each killed.

One existing assertion changes: test_an_outlier_raises_the_arena_for_the_store_but_never_past_the_ceiling
in tests/test_issue901_pinned_arenas.py. Its last arena is now 128 MiB instead of 512 MiB,
because the 300 MiB outlier is placed first.

Local runs: 932 passed, 9 skipped on the 21 test files that touch the planner, the two sources
or the probe harness (CPU); 7 passed with -m gpu on the three arena files. ruff check clean.

One interaction to know about: #1531 / #1621

On the disk tier a pinned arena whose base comes back off a sector boundary is shifted inside
the arena, and replaced by the next power of two when it has no slack. A packed arena is filled
more tightly, so if one is filled exactly to its power of two AND its base is misaligned, it
costs the next size up, the same as an in-order arena that happens to be full. The regions stay
aligned and correctly indexed
(test_a_full_packed_arena_off_a_sector_boundary_takes_the_next_size_up). Staging regions are
whole sectors, so an arena has either no slack or at least one sector of it, and "no slack"
needs the region sizes to sum to an exact power of two. In the measured disk geometries the
plan is the in-order one anyway.

Not covered

A physical 4 GB card, Llama checkpoints, and any model other than Qwen3-8B on real hardware.

…ge-locks less (#1702)

plan_pinned_arenas walked the tensors in allocation order and only looked at
the arena it opened last, so a large tensor that arrives late stranded the
space in front of it. A Qwen3-8B NF4 store is 3.58 GB of decoder tensors and
then two 1.24 GB vocabulary matrices: each matrix opened a 2 GiB arena of its
own, and 8 GiB was page-locked for a 6.07 GB store.

The planner now also plans stable first-fit decreasing and returns it only
when it page-locks strictly fewer bytes. A tie keeps the in-order plan,
placement for placement, so a store that gains nothing is laid out as before.
Placements stay indexed by the caller's tensor order. No config field.

Qwen3-8B NF4, RAM tier: 8,589,934,592 -> 6,442,450,944 page-locked bytes.
The disk tier's staging ties and is unchanged. The two Qwen2.5-14B geometries
of #901 lose about 7% each (arithmetic).

Speed is not measured by this commit: the plan and its decision rule are in
#1702.
…any run (#1702)

benchmarks/harness/issue1702_arena_pack_gate.py runs the plan of #1702: the
in-order plan against the packed one, A B B A per seed, every run a fresh
process, the RAM tier as the subject and the disk tier as the negative
control. The in-order arm is made inside the process by pointing the planner's
largest-first walk at its in-order walk. The schedule and every branch of the
decision rule are held by tests/test_issue1702_arena_pack_gate.py.

benchmarks/gate-1702-arena-packing.md records the rule, the protocol and the
strict control, which is run: three deterministic optimizer steps on Qwen3-8B
are byte-identical between the two plans on the RAM tier (8 GiB vs 6 GiB
page-locked) and on the disk tier, 870 of 870 digests each.

The speed series is not run yet.
…tale result, and does not time on battery (#1702)

A rehearsal of the 40-run driver on Qwen2.5-1.5B found the laptop on battery: a step took 0.83 s against 0.31 s on AC. The driver now refuses to start a series on battery power and stops one when the box goes on battery, and a series with any run stamped on battery is INCONCLUSIVE. A run with no result after 15 minutes is stopped and counted as failed, and a result file left by an earlier run of the same name is removed before the run starts. All of it is written into the record before any run of the series.
…asurable at +/-15.1% (#1702)

One series of 40 fresh-process Qwen3-8B runs, in-order against packed, A B B A per seed, in a declared window. 40 of 40 completed. The RAM tier's mean difference is +1.96% (per seed -2.71, +4.75, -1.41, +3.58, +5.61). The disk control's mean is -0.63%, so the series stands; its largest difference, 15.13%, is the band. By the rule written in #1702 before any run: no difference measurable at +/-15.13%, and the change is claimed as memory only. The band is wide because the control ran in two regimes; the record says so.
@MakazhanAlpamys MakazhanAlpamys added the ci:full Run the full CI matrix on this PR (a maintainer adds it at approval) label Oct 7, 2026
@MakazhanAlpamys
MakazhanAlpamys merged commit 60ab2fd into main Oct 7, 2026
27 of 34 checks passed
@MakazhanAlpamys
MakazhanAlpamys deleted the perf/pinned-arena-packing-1702 branch October 7, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Run the full CI matrix on this PR (a maintainer adds it at approval)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Layer streaming: the RAM tier page-locks 8 GiB for a 6.07 GB Qwen3-8B store; placing large tensors first needs 6 GiB

1 participant