Repository navigation
--expert-cache-per-layer fails to start on native packs (verify_slot, slot 0) #369
Description
--expert-cache-per-layer is unusable on any native pack: startup aborts with slot 0 differs from the arena at byte 0
Summary
--expert-cache-per-layer works on uniform-sized caches but fails on every native (sized) pack — that is,
every IQ/Q4_K/Q5_K/Q6_K model. The run aborts at startup, before the first token:
strata generate: R4.2g PER-LAYER: each layer owns 39 slots (0..38).
strata generate: ExpertCache::verify_slot: slot 0 differs from the arena at byte 0 (of 5222400)
The startup verify_slot catch works exactly as designed here: it refuses to serve tokens from a residency table
whose bytes are wrong. Two independent defects cause it, and the attached patch fixes both.
Environment
| Version | 0.1.31 (9259cad) |
| GPU | NVIDIA RTX 5090, 32 GB (driver 580.x), PCIe |
| Model | Qwen3.8-Flash-Next Q8_0, 48 layers, native pack (--native + tools/iq_pack.py --compat-bf16) |
| CPU | 20 cores, engine reports AVX-2 for the expert path |
| RAM | 376 GB (105.41 GiB expert tier resident) |
| CUDA | 12.x, CMAKE_BUILD_TYPE=Release |
| Flags | --expert-cache 1909 --expert-cache-per-layer --expert-profile data/expert-profile.bin --prefill auto --spec 4 --spec-min-p 0.5 --vram-reserve-mib 10880 |
Root cause 1 — open_sized() never seeds the per-layer cursors
ExpertCache::open() seeds layer_next_ with each layer's own start offset:
layer_next_.assign((size_t) n_layers, 0);
for (int64_t l = 0; l < n_layers; ++l) {
int64_t lo = 0, hi = 0;
layer_slot_range(l, lo, hi);
layer_next_[(size_t) l] = (int32_t) lo; // R4.2g
}ExpertCache::open_sized() — the path every native pack takes — seeds the vector with zeros only and never
calls layer_slot_range(). So under per-layer admission every layer's cursor starts at 0, all 48 layers contend
for slots 0..q-1, each fill_slot overwrites slot 0, and the startup compare finds slot 0 holding a different
expert than slot_of(profile[0]) reports.
This is why the bug survived: the uniform path (open()) is correct, so per-layer appears to work everywhere
until you run a native pack.
Root cause 2 — the profile fill walks the global ranking, not the layers
generate.cpp filled the cache with one pass over the profile in its global frequency order, breaking at the
first kNotResident. Per-layer admission gives each layer its own quota, so one layer exhausting its quota ended
the whole fill. On the model above:
| Fill | Slots filled | Cache hit rate |
|---|---|---|
| documented behaviour | 4096 | 54.4 % |
| actual (single global pass) | 882 of 1909 | 46.0 % |
27 of 48 layers never received a single resident expert. The patch groups the profile by layer first, then lets
each layer draw from the profile filtered to itself, hottest first, until its own range is full — which is what
the flag's documentation already describes.
Measured effect of the patch
Same machine, same model, same flags, only --expert-cache count and --vram-reserve-mib differ.
| slots / per layer | cache hit rate | decode | tok/s (median of 4) | |
|---|---|---|---|---|
| before, 39/layer | 882 / 1909 | 46.0 % | yes | 16.4 |
| before, shared cache | 2033 | 62.4 % | yes | 19.4 |
| patched, 64/layer | 2904 / 3072 | 72 % | yes | 23.6 |
The patched 64-slots-per-layer run reproduces the documented 70.4 % "at 64 slots/layer" figure (measured
72.0–72.8 % over four consecutive requests, cold first request 59.8 %).
Honest assessment
Per-layer is not a throughput win on this model — at equal VRAM it is slightly slower than a shared
cache. Measured with --spec-min-p 0.65 (an orthogonal tuning change, not part of this patch), all three
runs at --vram-reserve-mib 10880, 4 requests each, median of 4:
| config | slots granted | slots/layer | cache hit rate | median tok/s | VRAM free |
|---|---|---|---|---|---|
shared (--expert-cache auto) |
1922 | — | 74.5 % | 20.7 | 10264 MiB |
shared (--expert-cache 3072) |
1982 | — | 76.4 % | 20.2 | 9886 MiB |
per-layer (--expert-cache 3072) |
1979 | 41 | 73.9 % | 19.1 | 9903 MiB |
Note the third column: --expert-cache 3072 does not buy 3072 slots. The VRAM reserve clamps the
allocation, so both 3072-slots runs get ~1980 — i.e. 41 slots per layer, not the 64 that reproduce the
documented hit rate. Reaching 64/layer requires dropping the reserve to ~5120 MiB (3072 slots granted, but
only ~4.3 GB VRAM free), which measured 22.1 tok/s median.
Conclusion: patch 1 and patch 2 make the flag correct, not fast. Without patch 1 the flag cannot run a
native pack at all; without patch 2 it fills only 46 % of its slots. Once both are in, per-layer trades ~8 %
throughput for a per-layer residency guarantee that this model does not need. The shared cache is the better
default here.
The 20.2 vs 20.7 tok/s gap between the two shared runs is noise (the request-to-request spread is 18.3-21.7
tok/s on its own), but the per-layer gap of 1.6 tok/s is outside it across the 12 requests measured.
Nothing in this patch changes behaviour when --expert-cache-per-layer is absent: the non-per-layer loop is the
original code, kept verbatim inside the else branch.
Tests
- Builds clean at
-DCMAKE_BUILD_TYPE=Release(only pre-existing warning: unusedargmaxatgenerate.cpp:797). ctest: 30/33 pass. The 3 failures (quantize_act_parity,ple_parity,platform_memory_test) are
pre-existing — verified by stashing the patch and rebuilding: identical failures without it.
platform_memory_testfails onmlock(raise ulimit -l) in the container; the other two are quantize/PLE
parity.- The patched binary passes the engine's own startup
verify_slotbyte-compare, which is the check this bug
failed. - Reachable at runtime:
pre-filled 2904 of 3072 slots from the profile; slot 0 verified.
The two defects are independent, so the patch can be split or kept as one commit — that is your call.
It applies cleanly to main at 9259cad.
Activity
Niko1221 commentedon Oct 1, 2026
Thank you, an excellent analysis. Both defects are fixed for the next release, as you proposed:
open_sizedseeds each layer's cursor in its own range, and the profile fill lets each layer take its hottest experts until its range is full. We found one more problem: per-layer ranges ignore the profile rank a sized slot was cut for, so on packs with different blob sizes per layer (UD-Q4_K_XL, mixed IQ) a larger blob could land in a smaller slot. Per-layer mode therefore uses slots of the largest blob. The default shared cache is unchanged. A test covers both cache types. This issue closes with the release.pipeob0 commentedon Oct 1, 2026
+1, and it is not Q4_K-specific — IQ4_XS through a native IQ pack hits it too, on 0.1.31 and 0.1.32.
2662400is the whole expert blob (48 layers x 512 experts, gate/up IQ4_XS + down IQ4_NL, 2.66 MB per expert), so the slot being verified is sized to the blob and the first byte already disagrees.Setup: Ryzen 7 5700X3D (AVX2 only), 64 GB, RTX 5060 Ti 16 GB,
orcarouter/Qwen3.8-Flash-Next-Uncensored-GGUFIQ4_XS, pack built from the three split shards (native_experts.txtv4, per-role shard column for the two split layers, 303 matrices served natively), launched with--mmap-experts --expert-cache auto --expert-cache-per-layer. The same pack without--expert-cache-per-layerruns fine at 37.5-39.0 tok/s, so the residency table itself is not the problem — the per-layer path is.Practical consequence: on this class of model the flag is unusable, so there is no way to measure what per-layer residency would buy. If it helps reproduce, the byte count differs from yours (2662400 against 5222400) because the blob size differs, not the failure.
expert cache: --expert-cache-per-layer starts on native packs (#369, …
Engine 0.1.33: a portable image encoder again (#411 #412), setup reco…
Niko1221 commentedon Oct 1, 2026
Fixed in 0.1.33, as you proposed plus the slot-size guard. Thanks for the analysis!
Merge Strata v0.1.33 (upstream 0.1.32+0.1.33): gfx12 WMMA prompt atte…
expert cache: --expert-cache-per-layer starts on native packs (#369, …
Engine 0.1.33: a portable image encoder again (#411 #412), setup reco…
expert cache: --expert-cache-per-layer starts on native packs (Niko12…
Engine 0.1.33: a portable image encoder again (Niko1221#411 Niko1221#412
expert cache: --expert-cache-per-layer starts on native packs (Niko12…
Engine 0.1.33: a portable image encoder again (Niko1221#411 Niko1221#412
--expert-cache-per-layer のネイティブのパックでは、各層のスロットをその層のブロブの大きさにする(upstream 8…
Fork 0.1.35-nvfp4.1: README, docs/NVFP4.md and the bundle version