Skip to content

--expert-cache-per-layer fails to start on native packs (verify_slot, slot 0) #369

Closed

Description

@dag08

--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: unused argmax at generate.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_test fails on mlock (raise ulimit -l) in the container; the other two are quantize/PLE
    parity.
  • The patched binary passes the engine's own startup verify_slot byte-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.

strata-perlayer-fix.patch

Activity

  1. Niko1221 commented on Oct 1, 2026

    @Niko1221
    Owner

    Thank you, an excellent analysis. Both defects are fixed for the next release, as you proposed: open_sized seeds 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.

  2. pipeob0 commented on Oct 1, 2026

    @pipeob0
    Contributor

    +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.

    strata generate: R4.2g PER-LAYER: each layer owns N slots (0..N-1).
    strata generate: ExpertCache::verify_slot: slot 0 differs from the arena at byte 0 (of 2662400)
    

    2662400 is 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-GGUF IQ4_XS, pack built from the three split shards (native_experts.txt v4, 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-layer runs 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.

  3. Niko1221 commented on Oct 1, 2026

    @Niko1221
    Owner

    Fixed in 0.1.33, as you proposed plus the slot-size guard. Thanks for the analysis!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions