Skip to content

Multi-GPU: a --layer-split stage holds only its own layers' weights, and every card's leftover VRAM spills into a helper expert tier (4-way IQ3_S: 9,269 -> 14,172 expert slots, prefill chunk 2,048 -> 4,096) - #390

Closed
gopinath87607 wants to merge 17 commits into
Niko1221:mainfrom
gopinath87607:layer-split-weight-carve

Conversation

@gopinath87607

@gopinath87607 gopinath87607 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Three things a --layer-split run needed, plus the Monitor work that made the first one visible.

The headline is the carve: every stage used to load the whole model's dense weights. Then the
VRAM those weights were wasting is given back — first to each stage's own expert cache, and then,
for whatever is still left over, to a helper tier that any stage's misses can hit.

1. A stage holds only its own layers' weights

Each --layer-split stage loaded the whole model's dense weights: on the 4-GPU IQ3_S rig,
3,485.66 MiB per card — 1,466.78 pack arena + 2,018.88 native dense — about 10.2 GiB across four
cards, holding layers that stage will never run. A stage running 9 of 48 layers still held all 48.

Weights now get the treatment the session already got. session_bytes(g, cells, k, lo, hi) is pure
arithmetic, so the split search prices a candidate layer range before allocating it; weights were
the one per-stage resource that never got that. The carve extends the same rule: a layer-range
predicate on the loader's existing skip/compact path, and the per-stage load moved down to after
st.lb/le are set. One rule produces both the priced size and the loaded layout, so they cannot
disagree.

Measured, 4-way IQ3_S (split 10,19,34 auto):

before after
expert slots 9,269 14,172
expert cache 17,388 MiB 26,892 MiB
CUDA0 slots 3,657 5,120
auto prefill chunk 2,048 4,096

Per-card reclaim: CUDA0 2.56 GiB, CUDA1 2.78, CUDA2 2.33, CUDA3 2.42.

The same carve applies to the native GDN/QSA/shared-expert projections
(NativeDense::load's range), which is 2,018.88 of those 3,485.66 MiB.

Three things that fail silently

  • The lower bound. The loader must be given [lb, le), not [0, le). [0, le) leaves two
    thirds of the hole unclaimed while every printed number looks plausible — this got as far as a
    commit before the per-range byte comparison caught it.
  • The routers stay on CUDA0. CS-T's file-tier lookahead memcpy's ffn_gate_inp out of CUDA0's
    table to warm the next layer's pages. A dropped row is still in the metadata but has
    data == nullptr, so the copy fails, ok goes false, and the lookahead turns itself off
    without saying so. All 48 are kept — 120 MiB is cheaper than losing the prefetch.
  • --split-skip-if-fits measures CUDA0 before its weights exist, so the full dense cost is
    added to what it holds back. Without that term the carve makes the check spend exactly the VRAM
    it is about to free.

STRATA_WEIGHT_SLICE=0 is the kill switch, so one binary runs both A/B arms.
STRATA_STAGE_CACHE_SLOTS=S pins stage caches (test-only), because they are auto-sized from free
VRAM and freeing 2.5 GiB per stage would change the slot counts and mask the comparison.

2. Every card's leftover VRAM spills into a helper expert tier

The carve exposed a second problem it does not fix on its own: a stage's cache only ever holds its
own layers'
pairs, so once a stage holds its whole range the cache cannot grow and the cost model
scores its spare VRAM at exactly zero. On the 4-way rig that stranded real memory — CUDA0 at
3,144 MiB and CUDA1 at 1,739 MiB while both 5060s were packed at 7,225 and 7,311.

With --layer-split, every card the run already uses — CUDA0 included — now hands its leftover to
a helper tier holding the globally hottest pairs no stage cache can hold. Stage misses hit it.

  • Automatic, no new opt-in. --no-remote-auto / STRATA_REMOTE_AUTO=0 is the kill switch, so
    it is revertable and A/B-able from the config JSON without a rebuild.
  • Helpers fail soft: a tier that cannot open is logged and skipped. The explicit
    --expert-cache-deviceN path keeps its hard failure.
  • Helpers stay decode/verify-only — they are reached from the pool dispatch, never from
    Prefill, whose GPU MoE path measured 2.2× slower and corrupting.
  • The stage caches' own-layers rule is unchanged. Only the helper spans all layers, so the
    stage-relative d_res table is untouched.
  • Tiers open after the placement is chosen and the caches are filled, so the search is
    untouched — it still picks what it picked.

The tier count goes 3 → 4 (kRemoteMax) to cover four cards, and a helper may now live on device 0
(device < 1 → device < 0), which is why preflight(0) runs in the early window before anything
else creates that context.

Selection is by byte budget, not slot count: a slot count is a poor proxy for a native pack
where blob_bytes(l) varies per layer. The selection loop is extracted into a CUDA-free function
and unit-tested (tests/core/remote_budget_test.cpp, run under CUDA_VISIBLE_DEVICES=-1) for the
budget bound, claimed/primary-cache disjointness, rank order, and bit-for-bit agreement with the
old slots mode.

Not yet measured on the rig. The mechanism, the budget arithmetic and the disjointness are
covered by the unit test; the decode and hit-rate effect needs the 4-way run, which the cards were
busy for. Worth watching: the per-tier log line, and whether CUDA0's and CUDA1's stranded VRAM
shows up as tiers.

3. Score every four-way placement

The search tried every placement up to 3 GPUs and, beyond that, shared layers in proportion to
speed. Four cards got a heuristic instead of a search. It now enumerates all C(L−3, 3) = 15,180
placements and scores each. Memoised per-range fills (1,121 walks) bring it from 740 ms to 14 ms.

The mass model moved from mass[r] = (r+1)^-1.2 to the increment of H(f) = 1 − (1 − f)^b over
the held fraction, b = 3 (STRATA_SPLIT_COVER_B). At f = 0.8138 (20,000 held) it gives 99.35%,
identical to the old fit — so the validated 2-GPU regime is unchanged.

4. The Monitor's per-GPU cards

The split was invisible: one card holding 27 GiB read the same as four. Each GPU now gets its own
card, built from what only the engine knows (layer range, KV and prompt buffers, expert cache)
paired with that card's VRAM from NVML. The engine reports its allocation as seven parallel CSV
fields on the INFO line — single comma-separated tokens because the server splits on whitespace.
NVML's mem_free was being read and thrown away; each card's name is asked for once rather than
once a second. A device that both runs a stage and hosts a helper merges into one card rather than
appearing twice.

The image encoder is a separate process reporting no memory of its own, so its footprint is
measured as the card's free VRAM lost while it started — strata-vision runs a 2048px warm-up
before printing READY, and a delta outside 64–8192 MiB is discarded as noise, keeping the last good
reading. The encoder is pinned to the card the config's vision.cuda_device names (else the
engine's first), which is the card the Monitor must show it on.

5. The PLE table's Q8_0 row

main now takes the PLE table's row size from the tensor's own GGML type (IQ4_NL 90 B, Q5_0
110 B, FP8 E4M3 160 B). This branch adds the fourth format the published Q8_0 checkpoint carries:
Q8_0, 170 B — 5 blocks of 34, one fp16 d per 32 values, no min term. Same geometry, so the
row cache, the batch slab and the 16-rows-make-2560 flatten are untouched; only the stride and the
block decoder differ. ple_row_bytes()/ple_dequant_row() dispatch to the decoders in
artifact/dequant.hpp, so the PLE row and the artifact reader cannot drift apart on the block
layout. A row decoded at the wrong stride opens and decodes plausible garbage, so the row size and
format are logged once at open.

Unlike Q5_0, Q8_0 is not restricted to --ple-io mmap: the Direct reader takes the row size.

6. Three fixes found on the way

An image source that 404s, refuses the connection or names an unknown host raised OSError, which
the dispatcher does not map to a status — the client's connection closed with no reply at all
(curl: (52) Empty reply from server) instead of a 400. Those now raise ValueError like every
other bad image source. free_vram_mib is one helper taking an index rather than two copies of the
same NVML call.

Testing

tests/core/weights_carve_test.cpp covers the range predicate and the priced-vs-loaded agreement.
tests/core/remote_budget_test.cpp covers the helper tier's selection.

There is no multi-GPU integration test in this repo, so the carve was verified by A/B: the engine
driven directly through --serve (no tokenizer, so stdin takes GEN <n> <ids> and stdout emits
T <id> per token), with an explicit split and everything pinned — explicit --prefill,
STRATA_LOOKAHEAD=0, STRATA_STAGE_CACHE_SLOTS — two binaries, one token hash, and the same hash
as before the change. The --split-device 0 bit-exact A/B is only meaningful with
--no-remote-auto; with tiers on, tokens may legitimately differ from the CPU pool's float order.

Merged main

main is merged into this branch (128 commits, 0.1.32–0.1.34) and the conflicts resolved instead of
re-opened: the PLE row-size design is main's with Q8_0 re-added on top, and the upstream changes
in the split region (Verifier::set_commit_async, #340's streamed-ring reserve, stage_room's
search/reserve distinction) are ported onto this branch's structure rather than replacing it.

Verified after the merge: builds clean, 56 of 58 ctest targets pass serially (the two failures are
environmental — ple_parity wants a model shard that is not in the tree, expert_multi_test wants
AVX512-VNNI/VBMI), and serve's 120 Python tests pass.

🤖 Generated with Claude Code

Gopi and others added 7 commits October 1, 2026 20:18
A layer split gives every stage the whole model's dense weights, so a card
running 9 of 48 layers still holds all 48.  On the 4-GPU IQ3_S rig that is
3485 MiB per card holding nothing but cold layers - VRAM the expert cache
should have.

The session carve already solved this for session state: `session_bytes` is
pure arithmetic over a layer range, so the split search prices a candidate
placement before committing VRAM to it.  This gives the weight arena the same
treatment, on the compaction path the loader already had.  With a `skip` set
the loader re-places kept rows from offset 0; the range filter is one more
predicate in that same pass, which is the point of putting it there rather
than in a second function - one rule decides both the arena's size
(`pool_bytes`) and its layout, so a slice cannot be priced by one and written
by another.

`tensor_layer` is shared with NativeDense (which is next) so the two can never
disagree about which layer a tensor belongs to; a disagreement would not be a
crash, it would be a stage quietly running another stage's layer.  Model-global
tensors have no ordinal, so a range never drops the head or the embedding -
they are needed by stages the range did not give them to.  `keep` is for the
one tensor a stage needs although it does not run its layer: the split's
router lookahead reads every layer's router from one stage's table.

`layer_bytes` is the price model, and it is pure.  `hi < 0` disables the carve
and is byte-identical to no filter: the re-placement loop is not entered at
all, so every tensor keeps the offset the pack gave it.

No call site passes a range yet, so nothing changes for a running engine.
weights_carve_test checks the two halves against each other over every range
on a synthetic index with deliberately uneven, unaligned tensors, and requires
the full range to reproduce a real pack's declared pool.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The other 2018 MiB of the 3485 MiB a split stage wastes.  NativeDense uploads
each eligible GGUF matrix into its own allocation and hangs it off the canonical
WeightRef as `native_data`; `eligible()` matches `blk.<n>.<suffix>`, so the set
is per-layer - a stage running 9 of 48 was uploading 39 layers' worth of
projections it can never reach.

`load` and `served_names` take the same `[layer_lo, layer_hi)` as
`WeightTable::load`, and they MUST be given the same range.  `served_names`
populates the canonical loader's `skip` set, so a range applied in one place
and not the other leaves a tensor with neither canonical bytes nor a native
override - two null pointers where a weight belongs, which is not an error
return but a kernel reading address 0.

`served_layer_bytes` is the price model, the counterpart of
`WeightTable::layer_bytes`: per-layer native byte totals from GGUF headers
alone, the same eligibility `load` applies, so the split search can price a
candidate range before committing VRAM to it.

`hi < 0` is the whole model and is the previous behaviour exactly.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Every `--layer-split` stage used to load the WHOLE model's dense weights:
the arena was sized by the whole-model `pool_bytes` and the native
projections by an unrestricted `NativeDense::load`.  A card running 9 of
48 layers held 39 layers' worth of matrices it can never reach - on the
4-GPU IQ3_S rig 3485.66 MiB per card (1466.78 pack arena + 2018.88 native),
10.2 GiB across the four.  That VRAM is expert cache: decode here is
bounded by the LOCAL cache hit rate, and CUDA3's 595 slots are what clamp
the prefill chunk.

The weights now follow the split search, exactly as the sessions already
do.  `st.lb`/`st.le` are set, and only then does each stage size, allocate
and load its own range - the same "priced before it is allocated" rule
`session_bytes` established, because `pool_bytes` and `layer_bytes` are
pure arithmetic over a range.  The stage loop keeps everything that
touches no weights (preflight, streams, the control-vector tables, the
vision position table, the PCIe probe) where it was.

`predict`'s `capr` loop subtracts the range's weights beside its session,
so what the search leaves for the expert cache is what the cache gets.
Stage 0 keeps the weight term at zero for now: CUDA0's own load still
happens before the search and its carve lands separately, so this commit
changes nothing about CUDA0's accounting.

STRATA_WEIGHT_SLICE=0 disables the carve everywhere, so one binary runs
both arms of the A/B.

Carved sizes on IQ3_S at split 10,19,34, against the predicted table:
CUDA1 [10,19) 272.83 + 364.85 = 637.68 MiB (predicted 637.7); CUDA2
[19,34) 448.02 + 656.07 = 1104.09 (1104.1); CUDA3 [34,48) 419.66 +
588.55 = 1008.21 (1008.2).

Co-Authored-By: Claude Code <noreply@anthropic.com>
The weight carve FREES VRAM, so an A/B against STRATA_WEIGHT_SLICE=0
would change the per-stage slot counts as well, and then a token
difference would say nothing about the weights.  Per-stage caches are
sized from free VRAM with no way to pin them (unlike CUDA0's, which
--expert-cache pins), so this is the one hook the A/B needs: stop after
S slots and both arms hold the same pairs.

Test-only, in the same spirit as STRATA_TEST_CACHE_FAIL.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The last 2.56 GiB of the hole, and the most valuable card: CUDA0's local
expert cache is what bounds decode, so its weights are the slice that
pays twice.

CUDA0's load moves down beside the stages' - `pool_bytes`, the arena,
`wt.load` and `native_dense.load` all now happen once `split_at[0]` is
known, and it runs [0, split_at[0]).  The PLE's six `blk.1.*` weights
are wired there rather than in the PLE block, because that is where `wt`
is first valid; every one of them is inside CUDA0's range whatever the
split is, so nothing about the PLE changes.

THREE THINGS THIS HAD TO GET RIGHT, each one silent if it did not:

  * THE ROUTERS STAY.  CS-T's file-tier lookahead memcpy's every layer's
    `ffn_gate_inp` out of CUDA0's table to warm the next layer's pages.
    A dropped row keeps its metadata but has `data == nullptr`, so the
    copy fails, `ok` goes false and the lookahead turns itself off
    without saying so.  All 48 are kept - 120 MiB against a prefetch.

  * `--split-skip-if-fits` MEASURES CUDA0 BEFORE ITS WEIGHTS EXIST, so
    the whole model's dense is added to what it holds back.  Without
    that term the carve makes the split look skippable by exactly the
    VRAM it is about to spend.

  * THE PRICE MODEL NOW COVERS THE CARVE-OFF ARM.  `stage_room` runs
    before the weights load in BOTH arms, so `weight_bytes` returns the
    whole model when `STRATA_WEIGHT_SLICE=0` rather than zero - it was
    zero, which would have let the search place the split against VRAM
    that is not there.  `--layer-split auto` with the kill switch set is
    the only path that reached it.

`--split-device 0` needs no separate guard: `ver_same` replays a later
stage against THIS table, but that mode already clears `multi_gpu`, so
`hi0 < 0` and CUDA0 keeps the whole model.

A/B at split 10,19,34 with the stage caches pinned: four runs, one hash -
and the same hash as before this commit.  CUDA0 3485.66 -> 868.30 MiB
(458.89 arena incl. the routers + 409.41 native, 62 of 300 matrices).

Co-Authored-By: Claude Code <noreply@anthropic.com>
IQ4_NL rows are 90 bytes (5 blocks of 18); Q8_0 rows are 170 (5 blocks
of 34, `d` once per 32 values, no min term).  The published IQ3_S/IQ2_S
checkpoints carry the PLE table as IQ4_NL and the Q8_0 checkpoint
carries it as Q8_0, so the row cache and the batch slab - both
page-granular - have to take the stride from the tensor's own type
rather than from a constant.

`PleReader::open` takes the row size (default 90: every existing caller
compiles and reads exactly as before), `PleTable::open` reads it off the
tensor and logs it, and `ple_dequant_row` dispatches to the artifact
reader's own IQ4_NL/Q8_0 block decoders, so the PLE row and
`artifact/dequant.hpp` cannot drift apart on the block layout.

A wrong stride still opens and decodes plausible garbage, so the row
size is printed once at open, and the selftest now runs the whole reader
- page arithmetic, straddling rows, dedup, bulk tickets, two tickets in
flight, the injected-delay arm - twice, once per row size, plus a direct
check of the Q8_0 block layout against known values.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…sion footprint

The split is invisible in the UI: the metric cards show pooled totals, so
a four-GPU run reads the same as one card holding 27 GiB.  Each GPU now
gets its own card, built from what the engine alone knows - its layer
range, its KV and prompt buffers, its expert cache - paired with that
card's VRAM from NVML.

The engine reports its own allocation as seven parallel CSV fields on the
INFO line (gpu_dev, gpu_layers, gpu_kv_mib, gpu_buf_mib, gpu_experts,
gpu_expert_mib, gpu_draft_mib).  They are single comma-separated tokens
because the server's INFO parser splits on whitespace.  A remote-expert
GPU runs no stage, so its layer span is `-`; the MTP head is reported on
the one card that holds it and nowhere else, since no totals line
accounts for it.  No VRAM figure comes from the engine: it knows only its
own allocations, and NVML already reports used/free/total.

For that last part NVML's `mem_free` was being read and thrown away, so
telemetry now keeps it, and each card's name is asked for once rather
than once a second.

The image encoder is a separate process that reports no memory of its own
- not to stdout, not to the log - so its footprint is measured as what
that card's free VRAM lost while it started.  `strata-vision` runs a
2048px warm-up before printing READY, so every buffer it will ever need
is allocated by the time the reading is taken; a delta outside 64..8192
MiB is noise rather than this process and is discarded, keeping the last
good number.

Also folds in three unrelated fixes found on the way: an image source
that 404s, refuses the connection or names an unknown host raised
OSError, which the dispatcher does not map to a status, so the client's
connection closed with no reply at all instead of a 400 - those now
raise ValueError like every other bad image source; `free_vram_mib` is
one helper taking an index rather than two copies of the same NVML call;
and the vision encoder is pinned to the config's first GPU, which is the
card the Monitor has to show it on.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@gopinath87607

Copy link
Copy Markdown
Contributor Author

this pr is still on working will share all the test output if anythink i need to change it let me know it.

…he cannot use

A four-GPU run picked its split from a formula that reads only `layer_ms`.
`capr` - the free VRAM that decides how many experts a stage can actually
hold - never entered it, so the search could hand the most layers to the
card with the least room and never notice.  On this 3060/3060/5060/5060 box
CUDA1 ended up with 1,841 MiB no cache could use while CUDA2 ran 15 layers
it could hold a third of, and ~20 GiB sat stranded across the two 3060s.
All C(L-3,3) placements are now scored - 15,180 at 48 layers.

What makes that affordable: a stage's fill depends only on its own layer
range and its own free VRAM (each ranked pair goes to the one stage that
owns its layer, and each stage stops at its first pair that does not fit),
so a placement's held mass is a sum over four independently memoised range
walks - 1,121 walks instead of 15,180, measured at 14 ms against 740 ms for
the same answer.

Scoring the placements is not enough on its own, because the cost model's
mass curve was wrong here.  `mass[r]` was (r+1)^-1.2, fitted to the
5080+3090 sweep; it is still right on that rig (99.35% predicted at 20,000
held, 99.4% measured) and badly wrong on this one - it claims the top 5,805
pairs hold 94.9% of the routed mass where the rig measures 69.2% over four
boots and 14.9M decode lookups.  No exponent reconciles the two, because
the curve is a property of the model's routing, and the profile file
carries only a ranking, with no counts, so it cannot be read off the data.
What both rigs do agree on is that the hit rate follows the held COUNT
almost alone: 14,541 held measured 92.2%, 14,384 held measured 89.8%.  The
mass is therefore the increment of H(f) = 1 - (1 - f)^b over the held
fraction, whose prefixes sum to exactly H(N/total).  b = 3 is chosen to
leave the validated regime where it was: at f = 0.8138 it gives 99.35%,
the same as the old fit, so the 2-GPU placements are untouched, while at
this rig's f = 0.59 it gives 93.2% against 92.2% measured.
STRATA_SPLIT_COVER_B overrides b for a model whose routing is sharper.

The second half is the VRAM no placement can reach.  A stage's cache holds
only its own layers' pairs (`GpuStage::profile` is filled from that stage's
range alone, so `slot_of` never returns a foreign layer's slot), which means
a card given few layers has room nothing can fill.  With `--layer-split`
every card the run already uses - CUDA0 included - now spills its leftover
VRAM into a helper tier holding the globally hottest pairs no stage cache
holds, and every stage's misses can hit it.  The tiers are sized from
`cudaMemGetInfo` after the weights, sessions, drafter and caches are all
allocated, so they take what is really left: the session reserve, this
card's verify window, the drafter's bind buffers, the prompt buffers when
they are not borrowed, and `open`'s own 512 MiB come off first, and under
256 MiB no tier opens.  They are disjoint by construction - one shared
`claimed` bitmap plus CUDA0's cache as `primary` - so their order only
decides whose PCIe round trip serves the hot pairs, and they are opened
fastest-SM-clock first.  The whole thing is automatic and has a kill
switch: `--no-remote-auto`, or `STRATA_REMOTE_AUTO=0`.

That required letting a helper tier live on a card that already runs a
stage.  `--expert-cache-deviceN` and the automatic tiers are never mixed:
naming a device is a statement about where helper VRAM should go.  A
device-0 tier needs its context flags set before any context exists, since
`cudaInitDevice` applies once per primary context and then fails with
cudaErrorSetOnActiveProcess, so `preflight(0)` moved into the early window
next to the existing CUDA1 one - before the PCIe probe and the weight
arena - and device 0 takes `cudaDeviceMapHost` without `ScheduleSpin`,
which belongs on a card sitting on the pool's critical path, not on the
card the whole run is built on.  If the mapped host allocation is refused
(WDDM's pinned budget is largely spent by then) the staging falls back to
plain portable pinned memory and copies, rather than losing the tier; how
the activations actually cross is MEASURED from `cudaHostGetDevicePointer`
and printed, never assumed.  The tier count widens 3 -> 4 (`kRemoteMax`),
and a spill tier that cannot open is dropped with a warning where an
explicit one still fails the run.  The Monitor merges a tier on a card that
also runs a stage into that card's own cell instead of printing the device
twice.

`select_remote_pairs` is extracted from `open` so the sizing rule can be
tested without a GPU, and takes a byte budget alongside the slot count.  A
budget SKIPS a pair that does not fit rather than stopping, because blob
sizes differ per layer and one hot oversized blob must not hide the colder
pairs behind it that still fit.  tests/core/remote_budget_test.cpp checks
400 random walks against a reference implementation written the obvious
way, plus the named properties, and skips the unequal-blob case where no
native layout has loaded.

`tools/make_profile.py` gains a note: `take()` skips a pair it has already
ranked, and the shipped base ranks all 24,576, so with the default `--base`
no trace can move a pair - the run prints "0 from the traces" and the
output is the base again.  The ordering in use is the shipped one, not this
model's, which is why the cost model needed the measured curve above
rather than another fit.

Verified on the 4-way rig: the split moved from K=2,3,27 to K=14,29,39,
held pairs from 5,805 to 15,190, and the caches now fill on every card
(CUDA1 4,634 of its 7,680 pairs, CUDA2 2,877 of 5,120, CUDA3 2,008 of
4,608) instead of CUDA0 and CUDA1 being capped at their own 1,024 and 512
with ~20 GiB idle.  The spill tiers found nothing to do and correctly
declined (456 MiB free against a ~1.3 GiB reserve) - with a placement that
no longer strands VRAM there is nothing left to lend.  The first request
measured a 90.6% decode hit rate and 50.2 tok/s against 38.1 tok/s for the
imbalanced split with no tiers and 41.3 tok/s with them, on the same
prompt shape; a run that short is not the last word on throughput.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@gopinath87607

Copy link
Copy Markdown
Contributor Author

this pr is finished if anyone interested feel free to check it decode jumb 40 to 54 tps while prompt stay as it is

…rve)

128 commits of 0.1.32-0.1.34 (AMD/HIP, MCP, setup, #340's split ring reserve,
#369's per-layer admission, the pool affinity flag) onto the weight-carve work.

Conflicts and how they were settled:

* include/strata/kernels/ngram.hpp, src/kernels/ngram.cpp, src/ngram/ple_reader.{hpp,cpp}:
  upstream's row-size design is the base (the stride is the tensor's own GGML type,
  and PleReader::open takes it).  Q8_0 is re-added on top as a fourth format -
  type 8, 170 B rows (5 blocks of 34, one fp16 `d` per 32 values) - because the
  Q8_0 checkpoint carries the PLE table as Q8_0 and upstream's whitelist refuses it.
  ple_row_bytes()/ple_dequant_row() are the dispatch; the Direct reader takes the
  variable row, so unlike Q5_0 there is no mmap-only guard.

* include/strata/core/native_dense.hpp, src/core/native_dense.cpp: both sides kept -
  our served_layer_bytes() (the split search's price model) beside upstream's
  keep_unquantized_ple_key().

* serve/server.py: Service.free_vram_mib() and the Vision construction both delegate
  to the one shared serve.telemetry.free_vram_mib(index, amd) - AMD-aware, and the
  same helper the encoder's own footprint reading uses.  The Vision call takes
  upstream's relative-path vcfg + vision_env() and keeps our device=, derived the
  way vision_env picks the card.  The now-unused `import ctypes` is dropped.

* src/program/generate.cpp: our structure (the carve, the ns == 4 placement
  enumeration, the coverage curve, the spill tiers) is kept; upstream's deltas in the
  same region are ported onto it - the SSD keep-alive lines, Verifier::set_commit_async,
  #340's split_ring_mib + Prefill::set_ring_override(96), and stage_room()'s `search`
  parameter with place_with_reserve.

* src/ngram/ple_reader_test.cpp: auto-merged, then row_bytes threaded through the
  keep-alive blocks that came from upstream (check_rows/open calls).

Built clean in build-v0131.  56 of 58 ctest targets pass serially; the two failures
are environmental and pre-existing (ple_parity wants the Q2_0 shard 2, absent;
expert_multi_test wants AVX512-VNNI/VBMI, which this CPU lacks).  serve's 120 python
tests pass (2 skipped).  Under -j8 the CUDA parity tests fail on VRAM because the
4-way server holds the cards - serial run is the honest one.

Not pushed: the rig builds and tests this first.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@gopinath87607 gopinath87607 changed the title Multi-GPU: a --layer-split stage holds only its own layers' weights (4-way IQ3_S: 9,269 -> 14,172 expert slots, prefill chunk 2,048 -> 4,096); the Monitor's per-GPU cards; the PLE table's row size from its GGML type; and the image-source and vision-footprint fixes Multi-GPU: a --layer-split stage holds only its own layers' weights, and every card's leftover VRAM spills into a helper expert tier (4-way IQ3_S: 9,269 -> 14,172 expert slots, prefill chunk 2,048 -> 4,096) Oct 2, 2026
Gopi and others added 6 commits October 2, 2026 10:13
… empty result

The Anthropic dialect flattened a tool's result through _text_of(), which keeps the text
parts only, so an image a tool returned - Claude Code's Read on a picture, a screenshot
tool, a browser tool - was dropped with no error, no log line and no refusal.  The model
received an empty tool result and answered anyway: a confident description of a picture it
had never been given.  This is the shape Claude Code uses for every image its Read tool
returns, which is why images could work in one client and come back wrong in another.

Measured on the 4-way IQ3_S rig, one image, three request shapes, before the fix:

  Anthropic, image inside tool_result   380 prompt tokens   "B - blue shape on a white
                                                             background"   WRONG
  Anthropic, plain user image block     890 prompt tokens   correct
  OpenAI, image in the "tool" message   956 prompt tokens   correct

The image contributes ~576 tokens, so 380 is the proof that it never arrived.

_parts_of() keeps it, and the pack's chat template already renders one image item per image
for a message of any role (chat_template.jinja:8), so the rows land inside <tool_response>
where the picture belongs.  A message holding an image beside a tool_result was dropped the
same way and now keeps it too.  A text-only result is unchanged: _parts_of() returns a
plain string when there is no image.

After: 959 prompt tokens and "The letter Q inside a green circle on a purple background."

tests: serve/test_server.py's ToolResultImages - the image survives and images_of() finds
it, a text-only result is unchanged, an image beside a tool result survives, and the
rendered prompt gets the encoder's rows.  serve's 157 tests pass (7 skipped).

Co-Authored-By: Claude Code <noreply@anthropic.com>
The auto chunk scan prices a chunk with `bytes_needed` and lends it the slots
those bytes want, so an over-count there is an under-sized loan - and this one
under-sized every loan by a step.  The count walked `carve`'s sequence from
memory and got two of its buffers wrong: it took `xn` unconditionally, though
`carve` takes it only under STRATA_GR_UNFUSED, and it never took `grs`.

Net T*(D-HC)*4 bytes too many: 42 MB at a 1024-token chunk, 252 MB at 6144 (48
Q8_0 slots).  The direction is safe - over-estimating, and `take` still
bounds-checks - but the prompt path was told it had less room than it did.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…s own slot count

The ring was 384 slots, a number measured on Q2_0 where a slot is one expert blob
of 1,382,400 B - 506 MiB.  A slot is a whole blob, so on a pack with bigger blobs
the same 384 slots are a different amount of memory: 1,893 MiB on Q8_0, more than
half of an 8 GB card's expert cache, and what kept that card at a 1024-token chunk
when its own buffers would have fitted 6144.  Measured on the 4-way rig, one env
var and nothing else: chunk 1024 -> 6144, prefill 87 -> 402 tok/s.

So RING_BYTES is the constant and slots are derived from the pack's max blob -
384 on Q2_0 (pinned) or 96 (not), exactly as before, and the pack it was tuned on
does not move.  This is also what lets the scan treat "the ring is full" as a
plain byte comparison.

Also exports the three accessors the scan needs: bytes_needed_no_ring (the chunk's
own buffers, without the ring), ring_max_slots (the cap in slots) and
ring_slots_for (what the ring resolves to for a chunk, after the override, the env
var and the pinned-share rule - what `init` lays out and what the INFO line
reports).

Co-Authored-By: Claude Code <noreply@anthropic.com>
…the better buy

`--prefill auto` walked a fixed list of chunk sizes - 32768, 16384, 8192, 6144,
... - and took the first that fitted, with the ring a fixed 384 slots taken off
the top before the chunk was ever considered.  Both halves of that were wrong on
a rig like this one.

The list is coarse exactly where it matters.  `bytes_needed` is a sum of
(T x positive constant) terms plus a max of such sums, so it rises monotonically
with T, and so does every test the scan applies - which makes the largest chunk
that fits a bisection on the 256-token grid the prompt path already works on.
Seven probes against the list's ten, at one `bytes_needed` each.  It matters
because this rig affords ~8,700 tokens and was handed 8192; 8704 is not on the
list.

The order was wrong because chunk and ring spend the same borrowed VRAM, and a
ring slot is worth far more than a chunk token.  Measured here (2x RTX 3060 +
2x RTX 5060, CUDA3 lending at its 90% cap, 120K prompt): 8960 tokens with the
17-slot ring that leaves reads at 963 tok/s, 8192/130 at 1,008, 7168 with the
ring full at its 199-slot byte budget at 1,000, 5632/199 at 915.  A ring slot is
worth ~0.53 tok/s and a chunk token ~0.05, so the 69 slots between a full ring
and 8192's 130 are worth more than the 512 chunk tokens they cost - and once the
ring IS full, a smaller chunk buys nothing.  So: the largest chunk that still
leaves the ring full, and only a rig where no chunk can afford one falls back to
the old rule with a kRingMin floor.

The room is counted in BYTES, not slots.  A ring slot is max_blob, but a cache
slot holds its own layer's blob - 2.15 MiB against a 2.54 MiB max_blob here.
In slots the ring looked 18% cheaper than it is, a chunk whose ring fitted only
after that discount was refused outright, and the scan stopped a step short.
`bytes_from_slots` is the inverse of the existing `slots_from_bytes`, and
`cache_slots_for` factors the latter out for the serve path's per-participant
version.  Both inverses clamp at k <= 0: the lend budget is
`min(slots - 128, pct * slots / 100)` and goes negative on a cache under 128
slots, where `slots - k` would index slot_offsets() past its end.

Verified on the rig: the scan picks 7680/199 against the old rule's 8960/17,
1018.8 and 1021.1 tok/s on two cold 120K prompts against 963.1 - +5.9%.
Q8_0 picks 5632/101, its 506 MiB budget over a 5,222,400 B blob.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…e with it

The auto scan now settles on a chunk and a ring for the run, and the engine
reports both on its INFO line (`prefill_chunk`, `prefill_ring`).  The Monitor's
GPU panel gains a "Prompt chunk" row reading them, so the pair is visible from
the web app instead of only in the boot log.

`prefill_ring` is the RESOLVED ring, not the room the scan offered: ring_slots()
clamps to [16, RING_MAX], a chunk under stream_all_min() gets STAGE, and
STRATA_PREFILL_RING overrides both.  What is shown is what `init` lays out.

Co-Authored-By: Claude Code <noreply@anthropic.com>
One textual conflict, in src/program/generate.cpp, and only one: upstream's
#448 ("name the small split card that caps the prompt chunk") refactored the
serve path's chunk picker into fits_one/fits(c, cap, only)/pick(only) plus a
warning that reports what CUDA0 alone would have chosen - the exact block this
branch had replaced with the ring-first scan.  Both sides rewrote the same
lines.

Resolved by keeping upstream's structure and putting this branch's rule inside
it: `pick(only)` now runs the ring-first two-pass scan for the auto path and
keeps upstream's halving loop for an explicit --prefill verbatim.  `ring_room`
gained the same `only` parameter as `fits`, so `pick(&pf_parts[0])` still means
"CUDA0 by itself" and the #448 diagnostic is unchanged.  `ring` is resolved
from the chosen chunk before that diagnostic runs, because the diagnostic's own
probes leave set_ring_override dirty and `if (chunk > 0)` puts the chosen ring
back.

Verified on the 4-way IQ3_S rig: the merged binary prints
  prompt chunk auto: 7680 tokens, a 199-slot ring
- the same pair the branch chose before the merge, and the measured peak - and
the #448 warning correctly names CUDA2 and CUDA3 as the cards that cap it:

  WARNING: prompt chunk 7680 tokens, not 16640: CUDA2 (NVIDIA GeForce RTX 5060)
  has 2812 expert-cache slots ... (#448)

Both features are therefore live together.  /metrics reports
prefill_chunk=7680 prefill_ring=199, so the Monitor row still reads the pair.
serve.test_server: 105 tests, OK (upstream added three).

Co-Authored-By: Claude Code <noreply@anthropic.com>
@gopinath87607

gopinath87607 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@Niko1221 if you have time kindly check this pr prefill jumb to 1k token, decode also jumbed ,slots and chunk number auto fixed ,anthorpic image api fix ,monitor ui change, and reduced vram mem.

Gopi and others added 2 commits October 2, 2026 20:28
The JSON behind the 4-GPU Q8_0 server: --pack/--native/--ple-gguf, --prefill
auto:32768, --expert-cache auto, --layer-split auto over gpu [0,1,2,3],
--max-context 262144, --kv int8 --kv-resident 32768, --spec 4, and
fit_max_tokens, which clamps an oversized max_tokens to the room the prompt
leaves rather than returning a 400 (serve/server.py:1210-1219).

/strata-*.json is gitignored at the repo root, so this copy lives under
configs/.  The paths in it are this machine's (/home/gopi/Strata,
/home/gopi/Strata-data, /mnt/nvme) and are not portable as they stand.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The same shape as configs/strata-q8_0-4way.json, differing only in the pack,
the native/PLE shard names and the model name: --prefill auto:32768,
--expert-cache auto, --layer-split auto over gpu [0,1,2,3], --max-context
262144, --kv int8 --kv-resident 32768, --spec 4, and fit_max_tokens.

This is the config the 4-way rig's prefill numbers were measured on: a
7680-token chunk with a 199-slot ring, ~1020 tok/s on a 120K prompt.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@gopinath87607

Copy link
Copy Markdown
Contributor Author

pushed the config file too

@Niko1221

Niko1221 commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Thanks! 0.1.38 adds a second-GPU helper tier (#531, --peer-device), so we'd rather not take a second one. Could you split this? (1) The per-stage weight carve as its own PR on 0.1.38, with the split identical to before at a fixed cache and the slot counts. (2) The leftover-VRAM helper tier on top of #531's PeerExperts instead of a new tier. (3) The Monitor and image-API changes as separate PRs.

@gopinath87607

Copy link
Copy Markdown
Contributor Author

Thanks! 0.1.38 adds a second-GPU helper tier (#531, --peer-device), so we'd rather not take a second one. Could you split this? (1) The per-stage weight carve as its own PR on 0.1.38, with the split identical to before at a fixed cache and the slot counts. (2) The leftover-VRAM helper tier on top of #531's PeerExperts instead of a new tier. (3) The Monitor and image-API changes as separate PRs.

just woke up doing it now.

@gopinath87607 gopinath87607 mentioned this pull request Oct 3, 2026
@gopinath87607

Copy link
Copy Markdown
Contributor Author

Thanks! 0.1.38 adds a second-GPU helper tier (#531, --peer-device), so we'd rather not take a second one. Could you split this? (1) The per-stage weight carve as its own PR on 0.1.38, with the split identical to before at a fixed cache and the slot counts. (2) The leftover-VRAM helper tier on top of #531's PeerExperts instead of a new tier. (3) The Monitor and image-API changes as separate PRs.

Thanks — that's a fair call on the second tier, and I'll split as you describe.

(1) and (3) are already up as their own PRs — #580, #581 and #582 below. On (2) I'd rather check
with you before building it, because I don't think PeerExperts as it stands covers the case this
branch was written for, and I'd be building something you'd have to throw away.

What the tier here actually is: with --layer-split, every card the run already uses has a
local expert cache, and a stage's cache only ever holds its own layers' pairs — so once a card
holds every pair its range can reach, the VRAM left beside it is unusable by that cache and the
cost model scores the rest as zero. On the 4-way rig, with the weight carve and --expert-cache auto, CUDA2 takes 8.75 GiB and holds all 4,608 of the pairs its layers can ever use, and 1.5
GiB is still free beside it. The tier is a helper on those same cards holding the globally hottest
pairs no stage cache can hold, reached from the pool dispatch on a stage miss.

Before the carve that leftover was far larger — 3,144 MiB on CUDA0 and 1,739 MiB on CUDA1, while
the two 5060s were packed with dense weights — so I want to be straight with you that the carve
has already taken most of what this tier was reaching for
. What is left is ~1.5 GiB on a card
whose own range is exhausted, and a split that hands CUDA1 7,680 pairs it can only hold 2,552 of.
The second is the split search's problem, not a helper's.

--peer-device doesn't line up with that in three ways:

  • It's one card, and it refuses a layer split. generate.cpp:1718 is a hard return 2 when
    --peer-device meets --layer-split, with the reason in the comment above it — the peer is the
    second card's only user, and a split would put a second engine part on it. But on a 4-way split
    all four cards run stages; the spare VRAM is on the stage cards. One peer device cannot hold
    four cards' leftovers, and it isn't on any of them.
  • It changes the prompt path session-wide. set_peer_portable(o.peer_device >= 1) is global, and
    prefill.cpp:123 makes fused_ring() return false whenever it's set — so --peer-device forces
    the MMQ prompt path (and its buffer sizes) for the whole session, whether or not the peer ever
    takes a row. On this rig the fused ring is the prompt path's fast route.
  • Its sizing is exactly the mechanism I'd want to reuse. peer_experts.cpp:136-138 is
    budget = free_b - reserve with --peer-reserve-mib — leftover VRAM minus a reserve. That is
    what the branch's selection does too, so the two are not in

So the question is which of these you'd prefer:

(a) Extend PeerExperts to a **co-located tier per stageayer-splitrefusal so a peer can live on a card that also runs a stage, ng and selection would then bePeerExperts`' own code, which is wha

(b) Treat them as disjoint: the peer is a second-GPU tierll tier is a
co-located tier for a split run, and mine waits until #216's c sizing
real on both. Your docs already call peer auto-sizing "a follow-up", so this may be the honest one.

Given the numbers above I'd now lean (b): with the carve merged, what the tier buys is ~1.5 GiB
of experts on one card per run, which does not obviously pay for relaxing a guard you put there on
purpose. (a) is still a small change — the guard and the tierlf already
unit-tested against a byte budget — so if you'd rather have t216 lands,
say so and I'll build it that way. Your call; I'd rather ask

Either way, here is the split, all five against v0.1.38:

PR
(1) the per-stage weight carve #580
(3) the Monitor's per-GPU cards [#581](https://github.com
(3) the image API — an image inside a tool_result, and a #582
the prefill chunk/ring byte budget [#583](https://github.
the four-way placement search [#584](https://github.com/N

#584 is one commit on top of #580 — it changes the split that gets chosen, so it could not ride with
the carve without breaking its "identical to before" claim. #as sitting
on the branch. The PLE Q8_0 row is dropped: it duplicates wrt`.

@gopinath87607

Copy link
Copy Markdown
Contributor Author

Split as you asked. #390 is superseded by these, all rebased onto v0.1.38 (99f3dbd):

what PR branch
(1) the per-stage weight carve #580 weight-carve-0138
(2) the leftover-VRAM helper tier not built — see the comment above —
(3) the Monitor's per-GPU cards #581 monitor-gpu-cards
(3) the image API — an image inside a tool_result, and a failing source answering 400 #582 serve-tool-result-images
the four-way placement search #584 split-placement-search, on the carve's commits
the prefill chunk/ring byte budget #583 prefill-chunk-ring

Two of those you didn't name, so the reasoning: the placement search was welded into the same commit
as the helper tier, but it changes the split that gets chosen, which would have broken the
carve's "identical to before" claim — so it is its own PR, stacked on #580's commits and opened
against main because a PR's base has to be a branch of this repository. Its compare therefore
shows #580's five commits as well; only the last one, "score every four-way placement instead of
guessing it", is its own, and I'll rebase it onto main once #580 is in, which makes it a single
commit. The prefill chunk/ring work is a separate concern that happened to be sitting on the branch.
And the PLE Q8_0 row is dropped: it is a duplicate of what is already on q8_0-support.

On (1), the two things you asked for. The split is identical to before at a fixed cache — same
placement, same slot counts, same generated token ids, from one binary with the old path behind
STRATA_WEIGHT_SLICE=0. The freed VRAM is the whole point, and on the rig's own 4-way IQ3_S config
with everything left on auto: 8,790 → 14,061 expert slots, 17,372 → 27,727 MiB of expert cache,
and the auto prompt chunk 2,048 → 6,144. Both arms chose the same split, so the VRAM is the only
thing that moved. Per-card before/after tables and the exact A/B flags are in the PR body.

The placement search is measured on top of that, and I've put the post-carve number in its body
rather than the pre-carve one: with the carve merged, what it adds is +419 slots (+3.0%), not
the ~20 GiB its commit first quoted — that figure was measured before the carve, which removes most
of the stranding it was counting. It is still the right split; it is a smaller win than that number
suggested, and both the body and the commit message now say so.

Nothing here rewrites the branch: this PR keeps its commits and its configs, so the links already in
this thread keep working.

Two changes from what was in the file: PR numbers are now in the table (the branch column stayed, so he can still check out each branch), and the placement-search paragraph no longer claims it "targets the carve's branch" — it targets main, stacked on #580's commits, for the fork-base reason.

@gopinath87607

Copy link
Copy Markdown
Contributor Author

pushed prefile to 2k+ please check this pr #761

@gopinath87607

Copy link
Copy Markdown
Contributor Author

Closing this. Every piece of it has either reached main in another form or is carried by a PR of its own, so
there is nothing left here to merge.

Where each piece went

what it was where it is now
the per-stage weight carve closed as #580 - v0.1.39 ships its own (#559 --trim-stage-weights, #639 STRATA_STAGE_TRIM=1)
the carve on the config the installer ships (auto) open as #880 - moves the stages' weight load after the split search, so auto gets the carve too: 469.7 -> 1,929.7 tok/s prefill on the 4-way rig, split unmoved
the leftover-VRAM helper tier not built - the question above about PeerExperts was never answered, and a single --peer-device cannot hold four stage cards' leftovers
the Monitor's per-GPU cards open as #581 - rebased onto v0.1.39
the image API (an image in a tool_result, 400 on a bad source) open as #582 - rebased onto v0.1.39
the four-way placement search open as #887 - the search alone, on v0.1.39 and with no carve in it. #584, the version stacked on the carve, is closed
the prefill chunk/ring byte budget closed as #583 - the ring went in as your own commits

The two that are worth your time. #880 is the one that pays on a normal config: the trim was refused under
auto, and auto is what the install ships, so almost nobody was getting it. #887 is the one with a fresh
measurement: on the 4-way IQ3_S rig the split moves K=10,19,34 -> K=14,31,41, prefill 425 -> 597 tok/s (+40%)
and decode 44 -> 51 tok/s (+16%), two boots per arm. It carries one caveat I would rather you read before merging:
the mass curve changed in the same commit, so that number is the pair, not the search alone.

Worth saying plainly: not one of the PRs split out of this was merged. What reached v0.1.39 came in as your own commits.
For the carve that is the right outcome - #580 would have been a second arena-sizing path beside #559 - and I would
rather say it than leave it implied.

fork/layer-split-weight-carve is left exactly as it is (tip aa504eb), so the two config links in this thread keep
working.

Thanks for the review and for the split - the pieces are better apart than they were together.
#887
#880
#582
#581

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