Skip to content

server: prompt cache disk spill - #149

Open
dzannotti wants to merge 352 commits into
masterfrom
fix/prompt-cache-disk-hardening
Open

dzannotti wants to merge 352 commits into
masterfrom
fix/prompt-cache-disk-hardening

Conversation

@dzannotti

@dzannotti dzannotti commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

This PR re-instates #81 back (first commit, a straight reapply of @storm1er's work) with the fixes below on top.

Changes

  • Owner-only metadata. *.bin.meta holds the serialized prompt tokens but was created 0644, readable by any local user (the .bin state file was already 0600). It is now written 0600, and metadata left world-readable by older builds is tightened when the cache is restored.

  • Cache directory. A directory the server creates is now 0700. An existing directory is left unchanged, with a warning if other users can access it.

  • Default disk limit. --cache-dir-max defaults to 32768 MiB instead of no limit, so clients can't grow the directory without bound. -1 keeps the old behaviour.

  • Runaway disk writes on ROCm. Saves and restores copied the slot state straight between the backend and the MAP_SHARED cache file. On ROCm the runtime pins the mapped file pages for that copy, and writeback then keeps rewriting the file until the server exits. The state is now staged through an ordinary heap buffer in both directions, so the backend never touches the mapping. The diagnosis and the staging approach come from a user report against b1015.

    Repro on gfx1151 (Qwen3-1.7B Q8_0, -c 32768 -np 1 --cache-dir on ext4 over a RAM-backed loop device), one 4.5k-token prompt saved, then the server left idle:

    bytes written for one 503 MiB entry restore
    before 5.7 GB within 60 s of idle, still climbing (~208 MiB/s) –
    after 503 MiB, flat for 75 s of idle hit, cached_tokens 4566/4567

Checked, no change needed

  • Restart reuse. On current master, entries restored after a restart are reused, on both a plain-attention model (Qwen3-1.7B: cache_n 1130/1913, 1 token recomputed) and a hybrid one (dummy qwen4exp: cache_n 136/140, context checkpoint restored). The miss reported on 2026-09-29 no longer reproduces.

Tests

tools/server/tests/unit/test_prompt_cache_disk.py, CPU, gfx1151 box:

master this PR
new test_disk_cache_files_are_owner_only fail pass
other tests 6 pass, 2 fail 6 pass, 2 fail (same two)

The two failures, test_disk_cache_strict_extension_matches_predicted_reuse and test_disk_cache_no_usable_checkpoint_skips_candidate, also fail on master in this setup and are not touched here.

Laurent Zuijdwijk and others added 30 commits September 7, 2026 22:18
vulkan : only chunk batched mat-vec for the quant types that were measured
ROCmFPx quant types: CPU codecs + Vulkan kernels (loads the Strix Halo ROCmFP4 GGUFs)
Every check in test_rocmfpx.c is a plain assert(), including the
rocmfpx_validate_row_data_*() calls and the mse ordering comparisons. The
Release configuration the PR builds defines NDEBUG, so all of them compiled
away and the test still exited 0 - it validated nothing.

Undefine NDEBUG before including <assert.h>, the way test-quantize-fns.cpp
and test-quantize-perf.cpp already do. test_rocmfp2_reference.c does not need
this: it uses its own always-active check machinery rather than assert().

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
rocmfpx.c is already a source of ggml-base, and everything test_rocmfpx.c
calls is declared GGML_API. Compiling it into the test as well breaks MSVC
shared-library builds: with BUILD_SHARED_LIBS=ON and GGML_BACKEND_DL=OFF the
test inherits GGML_SHARED through llama -> ggml -> ggml-base but not
GGML_BUILD, so the codec declarations become __declspec(dllimport) in the
same translation unit that defines them, which MSVC rejects.

Drop the extra source and let the test link the library implementation.
test-rocmfp2-reference keeps compiling rocmfp2_reference.c directly: it is a
standalone reference that is not part of ggml-base and carries no GGML_API.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
rocmfpx_choose_scale_fp2_mse() only searched scale bytes 1..126, returning 0
solely when the block was all zeros. ROCmFP2's S40 codebook is
{-4, -1, +1, +4}, which has no zero code, so at any nonzero scale the smallest
magnitude it can emit is one scale step. A block that sits below half of the
smallest UE4M3 scale is then amplified rather than approximated.

A block filled with 2^-14 reconstructed as 2^-10, 16x too large, for a squared
error 225x greater than encoding it as zero. rocmfp2_p1_quantize_block_ref(),
which searches every legal byte 0x00..0x7e, picked scale byte 0 for the same
input - so the two encoders in this tree disagreed.

Seed the search with the zero-scale candidate so bytes 1..126 have to beat it.
Ties keep byte 0, matching both the lower-byte rule the rest of the search uses
and the standalone reference.

  before: e=(1,1)  sse=2.68e-05
  after:  e=(0,0)  sse=1.19e-07   (reference: 5.96e-08 per 16-weight group)

FP3, FP6 and FP8 all have a zero code and so can already match the zero scale
by rounding; only FP2 needs this.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
The FP6 scale search stops descending once the unavoidable clipping error at a
scale already exceeds the best error found so far. That bound was computed as
max_abs - 31*scale, but rocmfpx_decode_fp6_code() maps code 32 to -32: the
representable range is the asymmetric [-32, 31]. For a block whose largest
magnitude is negative the bound therefore overestimates the error that cannot
be avoided, and the search gives up before reaching the scale that wins.

Sweeping 20000 half-blocks with a single dominant outlier:

  before: 324 suboptimal with a negative outlier, 0 with a positive one
  after:    0 suboptimal in either case

worst observed case: byte 12 at sse 1.783e-04 where byte 11 gives 1.367e-04.
The asymmetry between the two columns is exactly what the wrong bound predicts.

The Vulkan encoder carried the same bound, so fix both together - the two
encoders have to agree on the scale they choose.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
rocmfpx_quantize_fp6_code(), rocmfpx_fp6_decoded_value() and
rocmfpx_quantize_fp8_code() rounded to int first and clamped afterwards.
The isfinite() guard keeps NaN and inf out, but a large finite weight still
overflows the conversion, so the value that gets clamped is already wrong:

  1e12 -> fp6 -0.03125    sign flipped
  1e30 -> fp6 0, fp8 0    magnitude lost entirely

Clamp the float to the representable range first, then convert. For in-range
values this is identical to the old order - rounding cannot push a value past
a bound it was not already past - so ordinary weights encode bit for bit as
before, and the extremes now saturate:

  1e12 -> fp6 31*scale, fp8 127*scale
  1e30 -> fp6 31*scale, fp8 127*scale

The Vulkan encoder had the same shape, and int() of an out-of-range float is
undefined in GLSL, so fix it in the same commit to keep the two encoders in
agreement.

This is extreme-input robustness, not a defect seen on ordinary weights.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
…-ring

Merge: from halobox 2026 09 06 uma ring
…/q5_0/q5_1/iq4_nl)

Evidence branch only - NOT for upstream. Extends the q8_0 dequant-once FA path
to every KV-eligible quant type via per-type fused dequant+transpose shaders,
plus a prefill-only fa_kv_ok gate for iq4_nl (no native coopmat1 path) and a
GGML_VK_FA_DEQUANT env toggle. Correctness: dequant-once == native FA bit-exact
for q4_0/q4_1/q5_0/q5_1; iq4_nl matches CPU. Finding: prefill is quant-type
independent (all dequant to identical f16 scratch); iq4_nl is a poor KV type
(ppl ~2x q4_0 at equal bits). Retained as gating evidence.

Assisted-by: Claude Opus 4.8
The dequant-once path materializes a per-layer f16 K/V scratch (~2 KiB/token).
On discrete devices that resident footprint can push the working set past free
VRAM, at which point the driver silently pages device-local memory: measured
~15x prefill regression on an 8 GB card (RTX 3070, driver 591.86) at long
context, with no error reported. Integrated/UMA devices have no separate device
pool to overflow and are unaffected.

Gate the path on this process's device-local usage against the physical heap
size, keeping a conservative reserve for memory not observable in-process.
heapBudget is deliberately not used as the signal: ggml_backend_vk_get_device_memory
computes heapBudget - heapUsage in unsigned arithmetic, which wraps to a huge
value exactly when the device is oversubscribed. The allocation cannot gate
itself either - on WDDM vkAllocateMemory only fails at roughly physical heap
size, which is above the free-VRAM level where paging begins, so a successful
allocation is not evidence of a resident fit.

Also fix the scratch size check: K and V are bound as one storage buffer, so
their sum must fit maxStorageBufferRange, not each half independently.

GGML_VK_FA_DEQUANT=0 forces the path off and =1 skips the capacity check;
GGML_VK_FA_DEQUANT_RESERVE_MB overrides the reserve.

Assisted-by: Claude (Opus 5)
…G, env-gated)

The KV-cache view reaching FA has head-interleaved rows ([HS, NH, KV]
physically), and the cm1 shader's direct-from-global coopMatLoads run
~2x slower on that stride than on per-head-contiguous K/V: a 16x16 tile
touches 16 distant cache lines instead of 4. Measured on Strix Halo
(RADV gfx1151), hd128/GQA8/kv10240/nb2048 f16: 29.8ms contiguous vs
63.1ms dense-permuted (the model layout; matches the in-model 59.9ms
from the perf logger, where FLASH_ATTN_EXT was 72.6% of the graph at
pp2048@d8192).

GGML_VK_FA_KV_CONTIG=1 extends the dequant-once FA scratch to f16 K/V:
dequant_f16_transpose.comp is a pure strided copy ([HS,NH,KV] ->
[HS,KV,NH], same push-constant ABI and dispatch as the quant transpose
shaders), engaged only when the rows are actually strided, prefill only
(neq1 >= 64). FA op 63.1 -> 30.5ms (2.07x) incl. copy cost.

Model-level (Qwen3-Coder-30B Q6_K_XL, ub2048, f16 KV, r=3, vs ROCm
571d0d5 nowmma): pp8192 877 -> 1199 t/s (ROCm 1216, was -28% now
parity); pp2048@d4096/8192/16384: 776/513/300 -> 1120/850/580. Shallow
prefill unchanged-to-better (pp2048 1542 -> 1633).

Not the fix: shmem staging on AMD (loses on occupancy, 29.8 -> 54.4ms
contiguous), bigger-tile/GQA-packed streaming (1-KV-head L2-resident
probe runs identical -> kernel is issue-bound, not bandwidth-bound).

test-backend-ops -o FLASH_ATTN_EXT green with the flag off and on
(pre-existing iq4_nl+sinks failures unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
test_flash_attn_ext always built K/V as sparse views (physical dim1
doubled, half viewed), which can never satisfy the contiguize path's
ggml_is_contiguously_allocated gate - so no permuted correctness case
exercised it. Add a kv_view parameter (default true = unchanged) and
dense-permuted eval cases matching the real KV-cache layout, including
ALiBi and logit-softcap variants; all pass vs CPU with
GGML_VK_FA_KV_CONTIG=1.

Perf additions: Qwen3-Coder-30B prefill-at-depth shapes (hd128, 4 KV
heads, GQA 8, kv up to 10240, nb 512/2048), the dense-permuted variant
(model layout), and the probe set used to establish that the contiguous
cm1 kernel is issue-bound: 32-distinct-KV-head MALL-spill (flat),
1-KV-head L2-resident (flat), mask=0 (-5.5%), f16 acc (-3%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…ONTIG=0 opts out)

Flip 442d7df from opt-in to default-on, matching the quant dequant-once
path (GGML_VK_FA_DEQUANT) convention. The pass still self-gates: f16 K/V
only, prefill only (neq1 >= 64), only when rows are actually strided,
dense allocation, and the shared scratch-capacity check.

Validated on Strix Halo (RADV gfx1151): FLASH_ATTN_EXT suite green with
default env (dense-permuted cases exercise the pass) and with the
opt-out; model-level pp2048@d8192 with no FA env matches the explicit
GGML_VK_FA_KV_CONTIG=1 validation run (846.6 vs 847.9 t/s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…ard-gate

Rebased adaptation of the iq4_nl routing fix: upstream 8161641 made
iq4_nl a native FA type, so the original motivation (iq4_nl had no
native shader and silently read garbage outside the dequant-once path)
no longer applies to any currently-admitted type. Keep the machinery as
hardening: ggml_vk_fa_kv_native() is the one list, supports_op mirrors
every hard condition of the dispatch-time dequant gate for any future
non-native type, and dispatch asserts the invariant instead of falling
back to a garbage-reading shader. Native list synced with upstream
(iq4_nl in, q1_0 out to match current admission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
iq4_nl has no native Vulkan FA shader; the dequant-once scratch path is
its only route (1abdd92). The sweep's iq4_nl cases are all sparse-view,
which that path correctly rejects, so iq4_nl had zero passing FA
coverage. Add model-layout (dense [0,2,1,3]-permuted) cases at prefill
batch size: iq4_nl/iq4_nl with sinks off and on, hd72+GQA with sinks,
and mixed K=iq4_nl/V=f16.

Validated on RADV gfx1151 at 146fb73: FLASH_ATTN_EXT 4765/4765 with
default env and with GGML_VK_FA_KV_CONTIG=0/1; 4761/4761 with
GGML_VK_FA_DEQUANT=0 (new cases correctly report unsupported); full
test-backend-ops suite 15538/15538.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
The contiguize/dequant pass is dispatched inside the FLASH_ATTN_EXT node handler,
and the perf logger writes one timestamp per graph node, so its cost was charged
to FLASH_ATTN_EXT with no way to separate the two. That made the copy invisible
and the kernel look correspondingly slower.

Adds a sub-node timestamp: a handler can close an interval mid-node and have it
logged under its own name. Measured on Coder-30B UD-Q6_K_XL, pp2048/ub2048 at
d32768, f16 KV:

  graph total          4612.3 ms
  FLASH_ATTN_EXT       3490.6 ms   75.68% of graph
  FA_KV_CONTIGUIZE       33.1 ms    0.72% of graph  (0.95% of FA)

so the copy is under 1% of the graph and FA is 75.7% of it at that depth. Bench
throughput is unchanged with the instrumentation compiled in (441.09 vs 442.14
t/s), since the marks are only emitted when the logger is on in per-op mode.

Two latent bugs in the query-pool handling fall out of this and are fixed here:

- The pool is created with n_nodes+100 slots but only the first n_nodes+1 were
  reset each graph, so anything using the headroom would read stale results.
- The results buffer was sized n_nodes+1 while getQueryPoolResults was asked for
  query_idx entries. Equal today, but it is an overflow waiting for the first
  caller that writes an extra timestamp.

Sub-op intervals log no flops, since they move bytes rather than doing math;
attributing the node's flop count to them would corrupt the GFLOPS column for
both halves.

Assisted-by: Claude (Opus 5)
acc is an int32 sum of dotPacked4x8EXT results. With q8_0 both operands are
full int8, so its bound is d_per_step*4*127*127, which overflows f16 when
ACC_TYPE is f16 (GGML_PREC_DEFAULT): the score goes +inf, softmax is destroyed
and the output comes back -FLT_MAX. Nibble types bound at ~30480 and stay in
range, which is why only q8_0 tripped it. Apply the scales in fp32, then narrow.
Identical arithmetic when ACC_TYPE is float, so the f32acc path is untouched.

This is an upstream bug, not a fork regression, and it was fixed here once
before - 61e77f4 carried it and the rebase onto b10133 dropped it. Only the
scalar shader has MMQ, so the failing shape is hsk=128 + q8_0 K + prec=def +
nb=1 + nr23=[1,1]; GQA>1 and nb>1 route to coopmat1 and escape.

test-backend-ops -o FLASH_ATTN_EXT on gfx1151, quiet box: 13295/13295 twice,
up from 13257/13295. All 38 failures were type_K=q8_0 prec=def. Both
ggml_flash_attn_ext call sites in the tree force GGML_PREC_F32, so no model
path reaches this - it is a landmine for the next person touching precision,
not a live bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Assisted-by: GitHub Copilot
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A quantized type size describes a block rather than one tensor element. Include the block size when converting saved bytes into view dimensions in the device writer, reader and fragmented-copy fallback. Extend the existing save/load regression with fragmented-to-compact restoration.

Assisted-by: GPT 6 Astra
…store

With full-checkpoint rollback, a partial draft acceptance restores the
pre-round state and re-decodes the accepted tokens to rebuild it. The replay
went through the same verification as a fresh draft. On backends where logits
change with batch shape or memory layout (Vulkan), that re-verification can
reject a token the original verification accepted; the rejection restores the
same checkpoint and replays again, and the slot loops on one position without
emitting anything. qwen35moe with --spec-type draft-mtp stalled this way a few
hundred tokens into long generations (the v0.6.8 MTP hang): the loop repeated
"accepted 2/3, restore at pos 995" every 27 ms with the GPU at 90 percent.

Accept the replayed tokens without re-verifying and sample only the
continuation from the final position. The replayed prefix was accepted by the
verification that triggered the restore; the replay exists to rebuild state.
On backends with batch-shape invariant logits the re-verification always
agreed, so behavior there is unchanged (verified bit-identical on CPU with
and without this change, 800-token greedy pair, 118 restore rounds).

Assisted-by: Claude Fable 5
(cherry picked from commit 9c5d899)
server : do not re-verify replayed draft tokens after a checkpoint restore
Implements GGML_OP_LIGHTNING_INDEXER on Vulkan (scalar subgroup shader for
small batches, coopmat 16x16 tiles for prefill, dedicated decode variant)
and an indexed sparse flash-attention path that consumes the indexer's
top-k selection directly via a new ggml_flash_attn_ext_add_top_k() API
(FA src[5] + op_param[4] = n_kv_raw dense prefix), instead of attending
densely over the full compressed KV.

The sparse path engages only for V4's CSA shape (hd 512, 64 heads, MQA,
f16 K==V latent) when dense_kv >= 3x active_kv; everything else falls
through to the dense path, which stays correct because the kq_mask still
carries the top-k selection.

Dropped from the original: the mul_mat_id tokens-per-expert pipeline
selection, which duplicates GGML_VK_MMID_SMALLN already on this branch.

Originally by Gaetan Puleo (llama-cpp-nathan-toolbox-deepseek-v4-poc,
branch deepseek-v4-flash-strix-halo); cherry-picked with the mmid hunk
dropped.
- flash_attn_top_k.comp: remove the dead bounds check that would skip
  barriers for part of the workgroup if it ever fired (barrier divergence
  is UB; the dispatch gate sizes the grid exactly), pin per-lane sizing
  to LANES=64 instead of the SUBGROUP_SIZE spec constant (array bounds
  fold from the spec default at compile time), name the f16 mask
  threshold constant
- ggml.h: document ggml_flash_attn_ext_add_top_k semantics (index base,
  dense prefix, backends-may-ignore contract)
- tests: cover the scalar indexer variant (batch 4 and boundary 15),
  previously only the cm and decode-cm variants had eval parity cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Adds test_flash_attn_ext_top_k: builds the DeepSeek V4 CSA attention
shape (hd 512, 64-head MQA, V as a view of K) with a consistent
per-token top-k/mask pair, one deliberately invalid index, and cases on
both sides of the Vulkan engagement gates. nb >= 64 cases are the first
numerical parity coverage the sparse prefill shader has had; nb < 64
and sub-3x-kv cases pin the dense-fallback contract.

Perf cases sweep kv 8k/32k/64k at nb 1/8/64/512 with a fixed active
set. Measured on gfx1151: the sparse shader is flat vs kv at prefill
(~2.2 TFLOPS active-only) while nb < 64 falls back to dense and scales
with kv (1326 us at 64k, nb=1) - the gap a sparse decode path needs to
close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…ction

The sparse prefill shader gates on q->ne[1] >= 64, so single-token decode
attends densely over the whole compressed KV and its cost grows with
context. This adds a gather pass (flash_attn_gather.comp): copy the
active rows (dense prefix + top-k selection; MQA, so all 64 query heads
share one set) plus their mask values into a compact contiguous scratch
in prealloc_y, then run the ordinary dense FA over the compacted
K/V/mask. V is the K latent, so one gather serves both. Invalid indices
and padding get zeroed K and -inf mask.

The FA function itself only has its inputs swapped: KV, mask geometry,
strides and the K/V/mask bindings are overridden up front and every
downstream decision (pipeline choice, split-k, workgroup sizing,
use_mask_opt) sizes itself to the compact KV unchanged. Engages for the
V4 CSA decode shape when kv >= 2x the padded active set;
GGML_VK_FA_TOPK_GATHER=0 disables.

Measured (gfx1151, test-backend-ops perf, active set 1536):
  kv=8192  nb=1:  255.6 us -> 55.8 us  (4.6x)
  kv=32768 nb=1:  986.4 us -> 56.1 us  (17.6x)
  kv=65536 nb=1: 1333.8 us -> 58.7 us  (22.7x)
Time is flat vs context. nb>1 still falls back to dense pending a
union gather. FLASH_ATTN_EXT eval suite green incl. the top-k parity
cases (the kv=4096 nb=1 case exercises this path end-to-end vs CPU).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Fold-in note for the toolbox branch: use_dequant_kv is additionally gated
on !fa_compact.active (the compact scratch is already contiguous f16, and
the two scratch layers must not stack), and the compact stride overrides
chain through the toolbox's nb*_eff values so the contiguize/dequant path
keeps its strides when the gather is inactive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Ports ggml-cuda/dsv4-hc.cu to Vulkan: hc_pre (mix the HC input streams
down to one embedding), hc_comb (per-token 4x4 stream-mixing matrix -
per-source softmax then eps-stabilized alternating column/row sinkhorn
normalization, whole matrix in registers, one thread per token), and
hc_post (redistribute the layer output back into the streams with the
mixed residual). Same launch geometry as the CUDA kernels; plain f32
compute, no subgroup or coopmat requirements, so the pipelines are
created unconditionally.

The value at decode is dispatch-count collapse: the unfused fallback
runs the decomposed graph (measured on gfx1151 config-a partial offload:
SUM_ROWS alone 79 dispatches x 39.7us = 3.1ms per graph, plus DIV/MUL/
ADD shares at 4x4 shapes) where the fused form is 3 dispatches per
layer. resolve_fused_ops now keeps all three fusions enabled on Vulkan
instead of printing 'not supported, set to disabled'.

Parity: upstream test-backend-ops DSV4_HC_PRE/COMB/POST cases green vs
CPU on first build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…-ctk

The fused indexer kernels read f16 keys only, so quantizing the small
(128-dim) indexer cache silently disables them and falls back to the
decomposed full-KV indexer path, whose contiguize cost grows superlinearly
with depth (measured 0.8ms -> 97ms per dispatch by 12k context on Vulkan).
Pin the indexer key cache to f16 in both DSA cache variants; the memory
cost vs q8_0 is ~120 bytes per token per layer.

Assisted-by: Claude Fable 5
…(DSv4)

The permuted view feeding the grouped wo_a matmul gives B a 128 KiB
power-of-2 token stride. Single-token decode never touches it, but
small multi-token batches (speculative verify, n=2-4) hit a strided-B
matmul path that runs ~11x slower than contiguous (40 vs 460 GFLOPS
measured on gfx1151, ~54% of GPU time in a draft-verify window). A
contiguous copy for 2..8 tokens is far cheaper than the stride tax;
n=1 and large prefill batches are unaffected.

Assisted-by: Claude Fable 5
pwilkin and others added 6 commits October 6, 2026 15:30
…nt types

The routed MMB path decoded a whole 256-value super-block with dequantize_* and
then threw away 192 of 256 values per 64-wide K slice. Replace that with true
per-slice decoders for Q2_0, Q5_K, Q6_K, IQ2_XXS, IQ2_XS, IQ2_S and IQ3_XXS,
plus the mmb_lb grid-decoder family generalised from the existing IQ3_S path
(IQ3_S, IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS), the Q2_0 register prefetch, and the
mmb_store8 / mmb_store_grid8 / mmb_grid8_bf16 / mmb_ksigns helpers. Each branch
reproduces the reference dequantize_* fp32 arithmetic, so the BF16 WMMA weights
are bit-identical to the whole-super-block path.

This is decode arithmetic only; the routed type gate is unchanged, so the Q2_0 /
IQ2_* / IQ3_XXS branches stay unreachable until commit 4 widens the gate, and
Q5_K / Q6_K keep their existing routed dispatch. Verified on gfx1151 / ROCm 10.0:
qwen4exp_gateup kbench 15/15 hashes identical to the pre-change path, and
test-backend-ops MUL_MAT_ID,MMB_QUANT,MMB_QUANT_HC,MOE_PREFILL 7709/7709 with the
full series applied. Not measured: non-gfx1151 arches.

Assisted-by: Claude Opus 4.8 (1M context)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Map the routed down small tile as <128,32,16,32> (WAVES_M=8 x WAVES_N=1) instead
of <128,32,32,16> (WAVES_M=4 x WAVES_N=2). The old shape split the 8 waves into
two token-column groups; a tile with <=16 real tokens (the common small-expert
case at typical top-k/E ratios) left the wn=1 group's 4 waves WMMA-idle.
WAVES_N=1 keeps every wave issuing WMMA. The mapping is bit-identical: same
output (m,n) set, same per-element K-reduction order.

gfx1151 / ROCm 10.0: qwen4exp_gateup kbench 15/15 hashes unchanged,
test-backend-ops 7709/7709. Measured +6.8% on the fn_iq3xxs PP512 routed+glu
kernel time at the dispatch level; end-to-end pp2048 / pp4096 / tg128 stay within
run-to-run noise on this shared-memory APU (routed MMB is about 37% of GPU time),
so this is a kernel-level win, not a claimed end-to-end speedup.

Assisted-by: Claude Opus 4.8 (1M context)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A model that stores ffn_gate_up_exps as one fused tensor has it split into gate
and up by two views, so the MMB SwiGLU fusion (ggml_cuda_mmb_supported_glu) can
never fire for it. MMB would then run the gate and up (and the down, whose
activation is the SwiGLU of those views) as plain BF16 WMMA mul_mat_id, paying
MMB's precision cost with none of the fused-kernel payoff that justifies it.
Guard ggml_cuda_mmb_supported_mmid against both, exempting Q8_0 / IQ4_NL which
master already routes to MMB (rejecting them would move those ops off their
master dispatch and create a fresh difference).

The guard matches by tensor layout, not type, so it is inert for the split-tensor
low-bit models this series targets (fn_iq3xxs, Ornith: gate and up are their own
mul_mat_id) until commit 4 admits their types. For a fused gate_up model it fires
immediately: gemma-4-26B-A4B / gfx1151 / ROCm 10.0, four repo corpora, mean KL vs
master drops from 0.19-0.31 (same-top-1 90.3-93.5%) with those experts on MMB to
-0.000000..0.000000 (max 0.00005, same-top-1 100.000%) with them on MMQ, i.e.
exact master parity. pp4096 on UD-Q4_K_XL gained only +0.9% from routing them to
MMB, so nothing of value is lost. Hardcoded on.

Assisted-by: Claude Opus 4.8 (1M context)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen mmb_quant_type_routed from 9 to 14 types: add Q2_0, IQ2_XXS, IQ2_XS,
IQ2_S and IQ3_XXS, which commit 1 gave per-slice decoders and which measure
faster than MMQ at 512-4096 routed rows on gfx1151. The commit-3 fused gate_up
guard now has effect for these types: a fused gate_up model in one of them stays
on MMQ, a split-tensor model (the typical low-bit MoE) takes the BF16 path.
Add the production-shape test_mul_mat_id cases (the shapes and quant types the
local MoE files use) so test-backend-ops covers the newly admitted types.

gfx1151 / ROCm 10.0, fn_iq3xxs (exercises all five) llama-perplexity c1024,
per-type attribution via a temporary allowlist: none 4.4021, +Q2_0 4.4027,
+IQ2_XXS 4.4038, +IQ2_XS 4.4029, +IQ2_S 4.4055, +IQ3_XXS 4.4023, all five 4.4036
- every delta is an order of magnitude under the +/-0.0256 corpus sampling
uncertainty, so no type is a quality outlier. Ornith-1.5 IQ2_XXS four-corpus KL
vs master: mean 0.0047-0.0647, same-top-1 97.1-99.3% (the small BF16-vs-MMQ
margin, unchanged from before this series). test-backend-ops 7709/7709. Not
measured: throughput (correctness-first) and non-gfx1151 arches.

Assisted-by: Claude Opus 4.8 (1M context)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ypes

hip : let routed experts take every mmb weight type (restores Qwen3.8-Flash-Next prefill after #123)
hip : keep the F32 HC gate output when no BF16 xn copy is expected
@github-actions github-actions Bot added documentation Improvements or additions to documentation server labels Oct 6, 2026
dzannotti and others added 8 commits October 6, 2026 22:17
ggml-cuda : round growing pool requests up to a power of two
scripts : log instead of print in gather_required_libs.py
--ple FILE opens one more GGUF after the model files. Each tensor in it
replaces the lookup table of the same name in the model, so a model can
use a PLE / n-gram table of another precision (e.g. BF16 or Q8_0 instead
of the IQ4_NL table it ships) without a rebuilt model file. The table of
the model file is never read. Works for every load and lazy mode,
including the qwen4exp on-direct reader, which reads the rows from the
new file.

- every key of the file must match the model in type and value, except
  split.* and general.* (general.architecture is compared); a file without
  such keys loads with a warning
- a replaced tensor must keep its shape; only get_rows lookup tables can
  be replaced; a file without tensors is an error
- the replaced byte ranges of the model are kept out of the mmap prefetch
- the path is part of the model params in common, so a draft or prefill
  model made from a copy of the params does not get the file
- vocab-only loads do not open the file
- path_ple is the last member of llama_model_params

Assisted-by: Claude Opus 5.5
Assisted-by: Claude Fable 5.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
--ple <file|none> takes several values like the other params, so tables of
different precision can be compared in one run. Adds a ple output field.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copies the PLE table of a model (any shard of a split model) to its own
GGUF with the arch's ple.* keys, as a byte copy or converted to f32 / f16 /
bf16 / q8_0 / q5_x / q4_x. --metadata-from takes the keys from another
file, for a lone shard that has none.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RowStream duck-typed the ndarray that GGUFWriter.write_tensor_data()
declares, which ty rejects; the previous commit silenced that with an
ignore. Let GGUFWriter write the header, keys and tensor infos only, and
append the data section with its alignment from write_rows(). The output
is byte-identical to before.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
llama : load per-layer embedding tables from a separate GGUF (--ple)
This reverts commit 0f36c46, reversing
changes made to 307c50d.

--cache-dir drives runaway disk writes on ROCm: the slot state is copied
straight between the GPU and a MAP_SHARED file, the runtime pins the
mapped pages, and writeback keeps rewriting the file until the server
exits. Users reported ~1.6 TB written to NVMe in an hour, and decode
stalling under 5+ GB/s of continuous writes with a 75k-token context.
Reproduced on gfx1151: one 503 MiB entry became 5.7 GB of writes within
60 s of idle. Even with that fixed (#149), an agent-style workload
rewrites the whole state on every turn. Remove the feature until it
can come back with bounded writes; --cache-ram is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5NBdiBqRmGomhSRXBoucx
dzannotti and others added 3 commits October 8, 2026 01:24
Brings back #81 by @storm1er (reverted in #154 after the ROCm write
storm reports), so the fixes that follow can land on top of it.

This reverts commit a3a9bb3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5NBdiBqRmGomhSRXBoucx
The disk prompt cache metadata holds the serialized prompt tokens, but it
was created 0644 and the cache directory 0755, so any local user could read
cached prompts (the state file itself was already 0600).

- write the metadata 0600, and tighten metadata left by older builds when
  the cache is restored
- create a missing cache directory 0700; warn when an existing one is
  accessible to other users, without changing it
- default --cache-dir-max to 32768 MiB instead of no limit, so remote
  clients cannot grow the directory without bound (-1 keeps the old
  behaviour)

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With --cache-dir the slot state was copied straight between the backend
and the MAP_SHARED cache file. On ROCm the runtime pins the mapped file
pages for that copy; writeback then keeps rewriting the file for as long
as the server runs. On gfx1151 one 503 MiB entry turned into 5.7 GB of
writes within 60 s of idle (ext4 on a RAM-backed loop device), and users
saw TBs per hour on NVMe. Copy through an ordinary heap buffer in both
directions instead, so the backend never touches the mapping.

Diagnosis and the staging approach come from a user report against
b1015 (reproduced on a RAM disk, fix staged through RAM).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5NBdiBqRmGomhSRXBoucx
@dzannotti
dzannotti force-pushed the fix/prompt-cache-disk-hardening branch from 9c8ef65 to c99aae6 Compare October 8, 2026 00:24
@dzannotti dzannotti changed the title server : owner-only prompt cache files and a default disk cache limit server: prompt cache disk spill Oct 8, 2026
@gaetan-puleo
gaetan-puleo requested a review from pwilkin as a code owner October 16, 2026 09:42
gaetan-puleo pushed a commit to rcfa/strix-llama.cpp that referenced this pull request Oct 16, 2026
…t-cache"

This reverts commit 0f36c46, reversing
changes made to 307c50d.

--cache-dir drives runaway disk writes on ROCm: the slot state is copied
straight between the GPU and a MAP_SHARED file, the runtime pins the
mapped pages, and writeback keeps rewriting the file until the server
exits. Users reported ~1.6 TB written to NVMe in an hour, and decode
stalling under 5+ GB/s of continuous writes with a 75k-token context.
Reproduced on gfx1151: one 503 MiB entry became 5.7 GB of writes within
60 s of idle. Even with that fixed (halo-box#149), an agent-style workload
rewrites the whole state on every turn. Remove the feature until it
can come back with bounded writes; --cache-ram is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5NBdiBqRmGomhSRXBoucx

[rebase: applied as the revert of the rebased commits 73e0238 and 3f91881, the linear-history form of PR halo-box#81]
(cherry picked from commit a3a9bb3)
@rcfa

rcfa commented Oct 20, 2026 •

Copy link
Copy Markdown

Data point from running this PR in production, a suggestion for the bounded-writes question from the #81 revert, and a rebase onto the rebuilt master.

This comment was written by an AI agent (Claude Code) working for @rcfa. The numbers are from rcfa's machine and were measured as described below.

Setup. Radeon 890M (gfx1150, Strix Point - not Strix Halo), Vulkan, Qwen3.8-Flash-Next IQ3_XXS with MTP, -np 4 -kvu -c 524288 --no-cache-idle-slots --cache-dir <dir> --cache-dir-max 262144 --ctx-checkpoints-dir <dir> (#180). The client is an agent with long tool-using conversations. This PR's three commits, on strix master 02e2c2b, have served it since 2026-10-14.

Write volume, 2026-10-15 03:33 to 2026-10-20 11:50 UTC (5.3 days):

  • 488 completed requests, mean context 79,421 tokens, max 197,474.
  • Disk cache writes: 16 entries, 65.9 GB in total. The entry file ids are consecutive, so no entry was written and removed in between.
  • Writing each request's full state once instead would be at least 1.36 TB (KV only at 35.1 KB/token on this model; the context checkpoints in each entry not counted).

So with idle slots kept resident, writes are bounded by evictions, not by turns: a conversation stays in its slot between turns and is written only when its slot goes to another conversation, or at shutdown (patch 1 below).

Where the per-turn rewrite comes from. --cache-idle-slots is on by default. On every new task, every idle slot is saved to the prompt cache and, with -kvu, cleared (the params_base.cache_idle_slots loop in process_single_task). With the RAM cache that is a copy; with --cache-dir it is a full state write per idle slot per task, which the next turn reads back. That matches the agent workload described in the revert.

Suggestion. With --cache-dir, skip the idle-slot path and write only when a slot is reassigned (get_available_slot -> prompt_save) and at exit. The simplest form is that --cache-dir implies --no-cache-idle-slots, with a log line saying so. Keeping conversations resident is only safe across restarts if they are saved at shutdown, which is what patch 1 does.

Two small patches we run on top of this PR. I can open them as separate PRs once this one is in, one concern each:

  1. server : save the idle slots to the disk prompt cache on exit - after start_loop() returns, each idle slot is written to the disk cache; busy slots are skipped. Checked CPU-only with Qwen2.5-1.5B (SIGTERM -> "saved 1 idle slot(s)", the next start resumed a 6000-token prompt with a few tokens computed, also after a rebuild at another commit; a request still running at SIGTERM -> "saved 0 idle slot(s)", after which the process waits for that request to end, as it does without the patch). In production it saved 2 slots (82,117 tokens) at a reboot today, and the next start restored all 16 entries (62.9 GiB).
  2. server : key the disk prompt cache by the state format, not the build commit - the key holds LLAMA_STATE_SEQ_VERSION and LLAMA_SESSION_VERSION instead of the commit, so entries survive a rebuild that keeps the state format and are still discarded when it changes. Current master bumps both (ggml-org kv-cache: fix restoring mismatched KV cache rotation by saving exact rotation metadata ggml-org/llama.cpp#28498, KV rotation metadata), which invalidates old entries as it should.

Rebase. Since the master rebuild (#181) this PR conflicts. For our own deployment I rebased its three commits onto master ab06204, authorship unchanged: commit 3deeb10 (the branch is now https://github.com/rcfa/strix-llama.cpp/tree/pr149-rebased, kept current; see the update below). The conflicts were with master's cache_ram_explicit (decision models default to no RAM cache): master's decision-model block now comes first and the cache-dir logic after it, so --cache-dir stays on for decision models (it is explicit by nature), and the README and common.h keep both sides. Take it if it helps, or ignore it if you prefer to rebase yourself.

Verified on the rebase: it builds (Vulkan); a CPU round trip with Qwen2.5-1.5B, -np 2 --cache-dir: 4 conversations on 2 slots, the 2 pushed out of their slots are written to the cache, both are restored after a restart, and continuing each computes 30 of ~2.5k-3.2k tokens. The same three commits are in our production stack on this master, validated today on gfx1150 (perplexity identical to the old build, the disk cache with checkpoints in files: the same tokens with and without). Not verified: Strix Halo / ROCm (no gfx1151 here), Windows.

@rcfa

rcfa commented Oct 21, 2026

Copy link
Copy Markdown

Update: rebased again onto current master 67b998b (after #202 and #203). The three commits applied without conflicts and are patch-identical to the ab06204 version above, authorship unchanged: https://github.com/rcfa/strix-llama.cpp/tree/pr149-rebased-67b998bdd (compare: master...rcfa:strix-llama.cpp:pr149-rebased-67b998bdd, 3 ahead, 0 behind).

Verified on this base: it builds (Vulkan), and the same CPU round trip as above passes (Qwen2.5-1.5B, -np 2 --cache-dir: the 2 conversations pushed out of their slots are written to the cache, both are restored after a restart, and continuing each computes 30 of ~2.5k-3.2k tokens).

Written by an AI agent (Claude Code) for @rcfa.

rcfa added a commit to rcfa/strix-llama.cpp that referenced this pull request Oct 23, 2026
Joins halo-box#149 and halo-box#180: a disk cache entry is written from the checkpoint files, and the checkpoints
restored from an entry move to --ctx-checkpoints-dir again.

Assisted-by: Claude Code

This branch has not been deployed

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

Labels

documentation Improvements or additions to documentation server

Projects

None yet

Development

Successfully merging this pull request may close these issues.