Skip to content

qsa : keep the incremental QSA prefix across mrope images (rebase of #163) - #235

Merged
gaetan-puleo merged 4 commits into
masterfrom
fix/qsa-image-recover
Oct 29, 2026
Merged

gaetan-puleo merged 4 commits into
masterfrom
fix/qsa-image-recover

Conversation

@gaetan-puleo

Copy link
Copy Markdown
Collaborator

Overview

Rebase of #163 (qsa : keep the incremental QSA prefix across mrope images, by David Hauf) onto current master 54e9dccd4. #163 is based on 6aa81b88e and no longer applies: commits 2 and 3 conflict in src/llama-memory-hybrid-idx.cpp and tests/test-qsa-prefix.cpp. This PR carries the same four commits, authorship kept. The design, the selection policy for image histories and the earlier measurements are described in #163 and are not repeated here.

This rebase is opened because the bug that #163 fixes is now also a crash on master, not only a slowdown:

  • With one mrope image anywhere in the cache, qsa_scalar_visibility_cells rejects every later ubatch, so prefill after an image takes the masked route. Its compute buffer grows with ubatch x cache depth (about 40 bytes per query x key).
  • With -ub 8192, a multimodal request on Signal-3.8-Flash-Next fails at about 78K tokens of depth: allocating 28058.43 MiB on device 0: cudaMalloc failed: out of memory -> decode: Compute error. A user reported the same failure at 148K (allocating 46195.12 MiB).
  • Text-only requests with the same settings are not affected.

What changed in the rebase

git range-diff 6aa81b88e..0a3e66dd2 54e9dccd4..HEAD:

No other change.

Measurements

Device:     Ryzen AI Max+ 395 / BeyondMax Series
Memory:     128 GB LPDDR5X (124 GiB visible)
Power:      tuned balanced, power_dpm_force_performance_level auto
BIOS:       UMA split not recorded
Kernel:     7.2.4-200.fc44, amdgpu.gttsize=126976 ttm.pages_limit=32505856 iommu=off
Backend:    ROCm core-10.1 (AMD clang 24.0.0git d6f6cb691), HIP graphs on
Build:      -DGGML_HIP=ON -DGPU_TARGETS=gfx1151 -DCMAKE_BUILD_TYPE=Release -DGGML_HIP_NO_VMM=ON, separate worktree and build dir per revision
Baseline:   54e9dccd4 (master), built and run in this same session
Change:     this branch (tree b1ed4a676b9b); commit 1 alone also tested (tree 6ed53322e66d)
Model:      agentionai/Signal-3.8-Flash-Next-GGUF AP-Q4_K_XL (sha256 427a7a10...), mmproj-F16 (f456cd79...), MTP draft mtp-Qwen3.8-Flash-Next-Q8_0 (58a47d6a...)

Server, the reporter's command with the context reduced to 261888 (see Additional information):

llama-server -m Signal-3.8-Flash-Next-AP-Q4_K_XL.gguf --spec-draft-model mtp-Qwen3.8-Flash-Next-Q8_0.gguf \
  -mm mmproj-Signal-3.8-Flash-Next-f16.gguf --mmproj-device ROCm0 -dev ROCm0 --spec-draft-device ROCm0 \
  --threads 8 --load-mode none --lazy-mode on-direct --ctx-size 261888 --parallel 1 \
  --spec-type draft-mtp,ngram-mod --spec-draft-n-max 3 --spec-draft-ngl all -ngl 99 --n-gpu-layers-draft 99 \
  -ub 8192 -b 8192 --image-min-tokens 1024 -ctk f16 -ctv f16 --reasoning-preserve --jinja -fa on -fit off

Requests, one server per build, sent in this order, non-thinking, max_tokens 32. Each prompt is numbered text lines and asks for the number on the last line. The image is a 1024x1024 ImageMagick plasma:fractal PNG (sha256 ae985c46...).

  • A: text only, 61,057 prompt tokens
  • B: one image, then text, 69,803 prompt tokens
  • C: five images, each followed by about 30K tokens of text, 180,157 prompt tokens
request master 54e9dccd4 commit 1 only this PR
A, text only 2749 (correct), 1149 t/s 2749, 1151 t/s 2749, 1153 t/s
B, 1 image 12749 (correct), 855 t/s 12749, 1130 t/s 12749, 1131 t/s
C, 5 images OOM at pos 78295, allocating 28058.43 MiB, Compute error 26999 (correct), 1065 t/s 26999, 1066 t/s

Prompt t/s is the server's timings.prompt_per_second, one run per cell. On master the time per 8192-token ubatch grows with depth on B (8 s at 25K -> 10 s at 58K) and stays flat on A (7 s from 16K to 61K).

These are single runs of an end-to-end reproduction, not a benchmark: no performance claim is made from them beyond "master fails, the change does not". The decode gains reported in #163 were not re-measured on this rebase.

Correctness:

  • test-qsa-prefix, test-qsa-fast, test-qsa-recover: PASS on the rebased head (ROCm build).
  • The three requests above return the correct number on every build that completes them.

Additional information

Requirements

With one image anywhere in the cache, qsa_scalar_visibility_cells rejected every ubatch, so all later decode and
verify steps took the masked top-k path. The mrope mask compares 2-D extents only between a cell and a query at the
same linear position, and with one sequence in the cache the selection already ranks the cells in the mask's own
order (position, then y, then x). Such a cell is therefore harmless unless a query of the ubatch sits at its
position. Several sequences are not ranked, so any 2-D cell still keeps the masked path there, as do 2-D queries.

Text-only caches are unchanged.

Assisted-by: Claude
…mages

An mrope image puts all its cells at one position, so qsa_apply rejected its ubatch and qsa_recover refused its
cells: after the first image in a conversation every later decode and verify step re-pooled all indexer blocks on
the GPU and rebuilt the block metadata with the per-cell scan on the host, both in proportion to the context.

The tracked prefix now keeps the cells in the mask's order (position, then y, then x), the order the selection
ranks them in once an image is present, and the ranks of a 1-D ubatch after the image are the rank blocks the
selection uses. Tails and block starts are ranks; a block's pooled key takes the rope position the selection gives
it (its first cell's key for rank blocks, the block start otherwise). Image ubatches are tracked but do not match,
so they keep the scan. A prefix with a position hole and no image still matches no selection layout and stays
invalid, the prefill scoring bound stays position-only, and a ranked prefix only matches while its sequence is all
the cache holds.

Recovery (qsa_rebuild_prefix, which skips the cells of other sequences since #116) orders the cells of the sequence the same way: a counting sort by position, then y and x among an image's cells. It builds the prefix through apply, so a recovered prefix has the same ranks, keys and blocks as one kept ubatch by ubatch. A cell shared with another sequence is still refused. A ranked prefix is used only while its sequence is alone in the cache; with other sequences resident the selection does not rank, so those ubatches keep the scan.

Text-only caches keep rank == position and are unchanged.

Assisted-by: Claude
…image)

An MTP draft context never receives an image's cells, so its positions skip each image's extent. Its prefix was
invalid from the first image on, and every later draft step re-pooled all indexer blocks and rebuilt the metadata
with the per-cell scan, in proportion to the context.

Without an image in the cache the selection pools position blocks and numbers the complete ones in order; a block
with a hole is never pooled, and a query's tail is its own position block with -1 for missing cells. The tracked
prefix now keeps that list of complete blocks (first rank, and the start the selection compares and ropes with),
so the closed-form metadata and the incremental pooling work on such a prefix too. Rank blocks after an image are
the same list. The pooled keys are rebuilt if the numbering changes under existing blocks, and a prefix with holes
only matches while its sequence is all the cache holds. The prefill scoring bound keeps its old rule.

Recovery accepts such a prefix too: test-qsa-recover's hole case now expects position blocks instead of a refusal.

Text-only caches (no holes, no image) are unchanged.

Assisted-by: Claude
test-qsa-recover now rebuilds seq 0 after every random step of a cache it shares with two other conversations, cells in
any order: text, mrope images, position holes (an MTP draft), rollbacks, and a cell shared for a while (refused). The
rebuilt tracker must equal the incremental one in every field the selection metadata reads (cells, ranks, mrope keys,
first duplicate and gap, complete blocks and their starts).

Assisted-by: Claude
@gaetan-puleo
gaetan-puleo merged commit e383595 into master Oct 29, 2026
6 of 7 checks passed
@gaetan-puleo
gaetan-puleo deleted the fix/qsa-image-recover branch October 29, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants