Skip to content

perf(pflash): build the Qwen3.5 drafter's causal mask in the graph - #773

Draft
Graffioh wants to merge 30 commits into
Luce-Org:mainfrom
Graffioh:codex/pflash-drafter-device-mask
Draft

Graffioh wants to merge 30 commits into
Luce-Org:mainfrom
Graffioh:codex/pflash-drafter-device-mask

Conversation

@Graffioh

@Graffioh Graffioh commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #746. The branch is #746's current head (555ee2a97) plus one commit, 8fb32b938. Once #746 merges, the diff is that commit only.

The Qwen3.5-0.8B PFlash drafter spent most of its time on a causal mask, not on attention. For every full-attention layer and every 1,024-token ubatch, it filled a [align32(kv_len), align32(n)] F16 mask on the host and uploaded it: about 43 GB of uploads at 120K. The profile put that at 51–58% of drafter time at 79K–118K on the R9700.

This PR makes two changes; output is byte-identical.

  • The mask is built on the device, inside the graph. build_qwen35_causal_mask() writes the same bytes the host loop wrote:
    1. a zero fill (a repeat of one F16 element);
    2. the diagonal block over keys [kv_start, kv_pad), from ggml_tri of an F32 −inf square cast into place, which also covers the kv padding;
    3. −inf over the padded query rows.
      Unaligned resume and checkpoint starts are handled; there is no CPU fallback. The extra memory is about 8 MiB at 120K.
  • The compute buffer is reserved once. The mask grows with kv_len, so gallocr reallocated the compute buffer on almost every ubatch of the first attention layer: 117 times in 119 ubatches at 120K, replayed on the CPU allocator. On HIP each reallocation is a free plus a malloc of up to ~240 MB. The buffer is now reserved before the layer loop, for the full-attention graph of the ubatch with the largest mask.

Both scorers share the change: the strict head path and the legacy running-max path.

Results

R9700 (gfx1201), 27B IQ4_XS target, v14 probe, native head. The A/B compares the same commit with and without the fix, both with PFLASH_DRAFTER_PROFILE=1.

workload drafter before → after TTFT before → after identical to before
dev-qa 32K (30) 2.51 → 1.59 s (−37%) 11.95 → 11.05 s 30/30
code-debug 98K (4) 18.59 → 8.18 s (−56%) 38.31 → 28.08 s (−27%) 4/4
NIAH 120K (5) 28.24 → 11.63 s (−59%) 50.61 → 34.27 s (−32%) 5/5
NIAH 120K, --prefill-skip-park (5) 28.84 → 11.54 s (−60%) 46.44 → 29.49 s (−36%) 5/5
  • "Identical" means answers, selected_chunks, compressed_ids and chunk_scores all match, with the scores bitwise equal (max abs diff 0.0).
  • Peak VRAM is equal or lower in every run (e.g. 22.13 → 20.81 GB on code-debug).
  • Attention, DeltaNet and scoring times are unchanged. The whole gain is the mask and the reallocation.

Tests

  • New test/test_qwen35_causal_mask.cpp, in test_server_unit. It compares the device mask with the host mask byte for byte on the CPU backend.
    • Grid: starts {0, 1, 777, 1024, 5120, 18976, 19937} × n {1, 7, 64, 1000, 1024}, with kv up to 20,961.
    • It fails if the diagonal is shifted by one, or if the padded-row write is skipped.
  • test_server_unit: 688 passed, 0 failed, 2 skipped (GPUs hidden).
  • HIP: FILL and TRI are supported for F32, the F16 repeat goes through binbcast, and the F32→F16 copy into a strided view goes through cpy. The flash-attention mask stays F16 and contiguous, as before.

Risks

  • The mask copies run before the attention because of graph node order, not a data dependency. The KV-cache writes in this file already rely on the same ordering. The opt-in GGML_CUDA_GRAPH_OPT=1 reorder pass would break both.
  • The buffer reaches its peak size from layer 0, instead of growing to it by the end of the first attention layer. The peak itself is unchanged, plus about 8 MiB.
  • The served A/B ran only the strict head path. The legacy path is covered by the unit test only.

🤖 Generated with Claude Code

Review in cubic

Graffioh and others added 30 commits September 19, 2026 14:08
…ing head

Teach the PFlash drafter to score with a Qwen3.5-0.8B hybrid prefix instead
of only the Qwen3-0.6B tail-attention scorer. The new drafter lives in
qwen35_drafter.cpp behind qwen35_drafter_score_and_compress, its GGUF and
optional companion files load through qwen35_loader.cpp, and the pieces both
architectures share moved into qwen3_drafter_common.cpp. An optional scoring
head replaces the block-15 Q/K projections and an optional segment probe
proposes variable-length candidates instead of fixed chunks; both are GGUF
files validated against an explicit contract and fail closed when it is not
met. pflash_selection.{cpp,h} turns the PFLASH_SELECT_* environment into a
strict budget selector that ranks candidates, honours structurally required
spans and stops at the token budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ression trace

Wire the request path to the strict selector. A request may now carry an
explicit `pflash_query`, mapped to drafter tokens by the parser rules, and
`required_text` spans that must survive compression; tool definitions and the
instruction structure of the chat template are mapped the same way and passed
to the drafter as required spans. Compression failures fail closed instead of
silently falling back, and a trace records the resolved config, the mapped
spans and the retained budget. The adaptive keep-ratio controller now seeds a
new session from the configured curve for its prompt length rather than a
fixed default. The drafter IPC gains compress2/compress3 so a remote drafter
receives the unquantized keep ratio, the query window and the required spans,
with one formatter and one parser shared by both ends. The server unit tests
move with the query-mapping API they exercise, since the older helpers this
replaces had no other callers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cover the strict selector's budget, ordering and required-span rules in
test_pflash_selection.cpp, and the compress2/compress3 wire format — its
round trips, the legacy quantized parser and its malformed inputs — in
test_pflash_drafter_ipc.cpp, including a case that shows a remote drafter
selects exactly what the local one would. Add parser coverage for the
explicit `pflash_query` request field at both the top level and inside
extra_body. Document the Qwen3.5-0.8B drafter, the two optional GGUF files
and the PFLASH_SELECT_* contract in the server README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… scorer

src/qwen3/ had become a catch-all mixing three things: the Qwen3-0.6B
standalone inference backend, the legacy Qwen3-0.6B PFlash drafter, and
the Qwen3.5-0.8B PFlash scorer that replaced it. Split along the real
boundary:

- src/pflash/ now owns the whole PFlash pipeline: pflash_drafter (was
  qwen3_drafter), pflash_compress (was qwen3_drafter_common),
  pflash_selection, qwen35_drafter/qwen35_loader, kvflash_drafter_scorer
  (was qwen3_kvflash_scorer), anchor_scan/anchor_params.
- src/qwen3/ keeps only standalone Qwen3-0.6B inference, with the
  drafter terminology removed: qwen3_model.h (Qwen3Weights,
  load/free_qwen3_model).
- The Qwen3-0.6B PFlash path is deleted outright: qwen3_graph.cpp,
  qwen3_buffer_plan.h, score_range.h, DrafterArch dispatch, the 0.6B
  scoring-head loader, and the tests that covered them. Qwen3.5-0.8B is
  now the only scorer; the optional drafter_arch command arg is still
  accepted but ignored.
- PFlash selection symbols move from dflash::qwen3 to dflash::pflash.
- score_query_end < 0 ("legacy tail window") is still on the
  CompressRequest/IPC wire formats; backends and the IPC daemon now
  translate it to input_ids.size() at the boundary since the qwen35
  scorer requires an explicit end.
- Default/located drafter paths, help text, docs and scripts point at
  Qwen3.5-0.8B-BF16.gguf.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
PFlash is the whole compression concept; pflash_drafter is the scorer
model behind it — Qwen3.5-0.8B for now, not permanently. Make the seam
explicit in the file headers instead of implying the coupling is fixed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The budget selector fills a fixed fraction of the prompt by descending
segment score, but on compact-evidence traffic the evidence sits in the
first few ranks, so a rank rule reaches it for far fewer tokens. TopK
keeps the mandatory candidates and then the K highest-scoring optional
ones in score order, with the token budget still a hard ceiling and
oversized candidates skipped exactly as budget_only does, and stops with
top_k_reached when K binds before the budget. PFLASH_SELECT_TOPK is
required and validated in that mode, the config line and the compression
trace both carry the K that applied, and split selection rejects the mode
rather than quietly keeping up to 2K segments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-segment density ranking throws away what the head already knows at
document level: on BRIGHT the gold document ranks first by aggregated
mass in 16 of 28 prompts while its median gold segment ranks 68 of 215.
PFLASH_SELECT_DOC_PRIOR scales an optional candidate's ranking score by
its document's share of the prompt's total mass raised to the exponent,
so a document the head likes as a whole lifts its own segments. Zero,
the default, leaves the ranking untouched, and the prior applies in
every mode, so it composes with top_k.

Documents come from a new pflash_documents request field or, absent it,
from the served prompt's own "Document <n>:" and [DOC-<n>] markers;
fewer than three documents makes the prior a no-op, which the trace
records with the exponent and the document count. The drafter IPC
compress protocol carries no document spans, so the prior is inert on
that path until the wire format gains them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeping a document's body while dropping the header that names it leaves
the model unable to cite what it is quoting, and the score ranking drops
headers first because they are short and low density.
PFLASH_SELECT_FORCE_DOC_HEADS keeps the first segment of each of the D
highest-mass documents, charged after the structurally required spans
and before the fill, so a header can never starve a mandatory span and a
header that no longer fits is dropped rather than fatal. It reuses the
document detection the prior added and composes with the prior and with
top_k. The trace and the selector log record the configured D and the
headers actually forced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Production PFlash is now chat-only: the scorer query is the last N
tokens of the final message's content, located by the rendered
prompt's own control markers (ChatMarkers) instead of benchmark
message bookkeeping. Template machinery ("<|im_start|>", role names,
"<|im_end|>", generation prompts) is never scored, and the last
turn's role header is pinned mandatory so compression keeps the role
envelope. Marker-less prompts and the latest_user parser keep the
sentinel-render path; pflash_query stays a benchmark-only override,
mapped against decoded text and pinned in full while the scorer
consumes its bounded tail.

Strict selection now owns multi-turn chit-chat: continuations run
whole-prompt PFlash on the full history plus current turn instead of
failing closed or routing to FlowKV. Request-scoped FlowKV disk
compression remains the only rejected combination; unconfigured
requests keep the legacy FlowKV continuation path.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
PFlash scores the input prompt against the query and nothing else: no
document detection, no client pflash_documents ranges, no mass prior
and no forced document headers. Reverts 2455efb and 739a8c3; the
decoded-offset helpers they introduced stay because the chat-tail
query mapping uses them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every chat template appends a think or channel prefix after the
assistant generation marker ("<|im_start|>assistant\n<think>\n",
"<|Assistant|></think>", "<|turn>model\n<|channel>thought..."). The
marker-based tail extractor required only whitespace there, so on real
prompts it took the generation prompt as the last message: the scorer
query became "<think>", the assistant header was pinned instead of the
user's, and pflash_required failed to map. The legacy unconfigured
path used the same span and regressed with it.

The extractor now scans the rendered prompt's turns: an assistant turn
left open at the end is the generation prompt whatever follows its
marker, tool output wrapped in a user turn does not count, and the
query comes from the latest user turn (else the latest turn with
content). The derived query then takes the explicit pflash_query path:
its span is the scorer window, pinned under strict selection together
with the turn's role header; an explicit pflash_query still replaces
it. Tests render prompts through render_chat_template for Qwen, Gemma,
DeepSeek and Laguna with thinking on and off, instead of hand-written
strings that omitted the think prefix. The stale default-parser
assertion is updated, and the BPE tokenizer fixture now writes
per-process paths so parallel ctest runs stop overwriting each other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In an agent loop the latest user turn is followed by assistant and tool
turns. Selection treated every token after the query window as a kept
suffix and the head scorer masked those keys, so the tool output was
pinned whole: a 2K-token tool result against a 0.3 keep ratio failed
with mandatory_query_exceeds_budget.

The prompt minus the query is now the candidate pool whatever side of
the query it sits on. When turns follow the query's turn, the server
pins only the rest of that turn through its closing marker and the
generation prompt, and sets CompressRequest::query_suffix_candidates.
The block-15 head then scores keys after the query window too (NoPE:
no position term) and the selector stops treating that suffix as
structural. A query in the final user turn keeps the old contract, so
single-turn and plain multi-turn requests select exactly as before.
The running-max scorer, alone or in the split, and the remote drafter
IPC keep the kept-suffix contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Strict selection keeps system and developer messages, tool definitions,
the query with its turn's envelope and the generation prompt, and it
charged them against keep_ratio x the whole prompt. A 15K system prompt
under the default 5% ratio exhausted the budget of anything shorter
than 300K tokens and the request failed with
mandatory_query_exceeds_budget.

The server now counts the kept tokens with the selector's own
mandatory-chunk rule and passes an effective ratio,
(kept + keep_ratio x (input - kept) + 1) / input, so the selector budget
and the target-token ceiling both add the kept part on top of the
ratio's share of the rest. Auto mode compares the threshold with the
droppable tokens, so a large system prompt plus a short chat is served
as is. Instructions that alone would not fit the context (a document
pasted into the system prompt) lose their pin and compete for the
budget against the query.

Live on the R9700 (27B target, 0.8B drafter, keep 0.3, 16K context): a
2K system-prompt document answers instead of failing; a 19K one
compresses to 5.8K and answers correctly; the four chat shapes still
answer, keeping their kept tokens on top (2237 -> 701 instead of 637).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A system prompt that alone does not fit the context now fails the
request instead of losing its pin: PFlash does not compress system
prompts. Developer messages and tool definitions that would not fit
still lose their pin and are scored like any other context.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Strict selection re-selected the whole history on every turn, so each
turn's compressed prompt diverged from the last early on and the target
prefilled it from scratch; evidence the model had just used could also
vanish between turns.

The server now remembers the prompt it served for each conversation,
matched by raw prompt prefix up to the old generation prompt (most
clients send no session id). The next turn serves that view plus the
new turns. The fresh compression still runs: whatever it keeps for the
new question that the view lacks is recalled as excerpts at the start
of the new user turn, after everything the target cached, so the view
always holds the fresh selection. The view is rebuilt from the fresh
prompt when it outgrows twice the fresh prompt or the context. The
selector reports its kept spans (CompressResult::kept_spans) for this,
and the view asks for its prefix-cache snapshot at the start of its
generation prompt, where the next turn branches off; the default
second-to-last boundary lands on the system prompt of a first turn.
PFLASH_CHAT_VIEW=0 turns views off.

Live on the R9700 (27B target, 0.8B drafter, keep 0.1, 20.6K-token
conversation): three turns answer correctly, turn 2 recalls the
segment turn 1 dropped, and target prefill drops from 7.7 s to 2.8 s
(turn 2, 2048 tokens restored) and from 2.7 s to 0.9 s (turn 3, 3584
restored). The drafter still reads the whole history each turn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Qwen3.5 drafter re-read the whole prompt on every call, so every turn
of a conversation paid a forward over its entire history (about 4 s at
20K tokens on the R9700) even when only the last few hundred tokens were
new.

Blocks 0..14 read left to right, so the cache state and the block-15 keys
of a shared prefix never change, and NoPE keys carry no position. The
strict scorer now keeps up to PFLASH_DRAFTER_SESSIONS (default 2) scoring
sessions: a blocks-0..14 cache with headroom, the block-15 keys, the
probe's raw logits, the last query window's block-14 rows, and a
recurrent-state checkpoint 64 tokens before the prompt's end. A prompt
that shares a session's prefix resumes from the session's end or, when it
diverged before it (the previous turn's generation prompt), from the
checkpoint, runs only its new tokens, and scores the query against every
stored key. The query rows are reused while the query stays put (an
agent step appends tool output after the same user turn). Anything else
starts the least recently used session over; any failure forgets the
session's prompt. Sessions live with the loaded drafter.

Live on the R9700 (27B target, 0.8B drafter resident, keep 0.1, 20.6K
tokens, three turns): drafter forward 3.9 s -> 0.04 s on turn 2 (resumed
at 20571, 90 new) and 3.3 s -> 0.13 s on turn 3, with the same selections,
served prompts and answers as scoring from scratch; turns take 3.9 s and
3.3 s instead of 8.8 s and 6.2 s. An agent step resumes at 10993 with the
stored query rows and runs only the 8435-token tool output (1.9 s).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A rebuild (and every turn's fresh selection) scored the conversation
against the latest question alone and could drop the dialogue itself:
earlier questions, the model's own short answers, role headers.

The chat-turn scan now reports every turn. Under strict selection every
other turn keeps its role header, and user turns and assistant answers
up to PFLASH_CHAT_SKELETON_TOKENS (default 256; 0 keeps headers) stay
whole; like developer text and tool definitions, the skeleton is scored
as context when it alone would not fit. The tails of the last
PFLASH_CHAT_HISTORY_QUERIES (default 3) earlier user turns score the
context alongside the current query (CompressRequest::history_query_spans):
the head scores each window against the query's key set and mixes the
masses at weights 1, 1/2, 1/4, 1/8. The drafter session keeps the block-14
rows of up to 8 recent windows, so a new turn's history windows come from
the turns that computed them.

Live on the R9700 (20.6K-token conversation, sessions on): turn 2 recalls
64 tokens instead of 1472 and completes in 1.0 s instead of 3.9 s; turn 3
in 3.0 s; all three answers correct. Mixing in old questions trades the
new question's share of the budget, so both knobs are ablations for the
multi-turn evaluation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Benchmarks could only see PFlash's multi-turn behaviour by scraping
server logs. usage.timings now carries a pflash object on compressed
requests: compress time, drafter input, kept and compressed tokens, the
effective keep ratio, the query rule, the history-query count, the
drafter session's resume point, new tokens and forward time, and the
view outcome (fresh, continue, rebuild or repeat, with served, reused,
delta, recalled and fresh token counts).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…race

A continuing turn appended all its new material verbatim, whatever its
size, and a large paste or tool output only got compressed indirectly,
by outgrowing the view and forcing a rebuild of everything.

What a turn adds is now appended verbatim below
PFLASH_CHAT_COMPRESS_NEW_TOKENS (default 16384), the way full prefill
appends a follow-up; from there on only the parts the fresh selection
keeps of the new material are appended, and the view before it stays
cached (view mode "continue-compressed"). PFLASH_CHAT_RECALL=0 turns
recall off, and then a small follow-up (like a repeated prompt) is served
before any compression runs: no drafter, no scoring, exactly full
prefill's cost on top of the cached view. The view logic is one function
the server calls before compressing (for the turns that need no scoring)
and after.

PFLASH_VIEW_TRACE_PATH appends every compressed request's served prompt
text with its PFlash details as JSONL, so an evaluation can tell evidence
the selection dropped from answers the model got wrong.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the multi-turn development chats every PFlash failure still had its
gold evidence in the served prompt; the losses came from what surrounded
it. For a content-free follow-up ("which documents support that
answer?") recall filled its budget with 20-odd low-relevance documents
right beside the question. Segment lifts from the head (mass per token
relative to uniform attention) separate evidence (typically 20-160x)
from background (90th percentile about 1x).

The selector now reports every candidate's lift
(CompressResult::candidate_lifts), and recall takes only segments the
view lacks with a lift of at least PFLASH_CHAT_RECALL_MIN_LIFT (default
8), strongest first, up to PFLASH_CHAT_RECALL_TOKENS (default 2048
drafter tokens); without lifts it keeps the previous rule.

PFLASH_SELECT_PARAGRAPH_JOIN=1 (off by default, under test) rebuilds
the compressed text from the kept spans with a paragraph break between
pieces that were not adjacent ("...other bands.Document 1:" otherwise),
for fresh selections and compressed follow-ups; the breaks do not count
against the target-token ceiling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t-probe-experiment

# Conflicts:
#	server/src/deepseek4/deepseek4_backend.h
#	server/src/qwen35/qwen35_backend.h
On the multi-turn development chats (7 non-held-out 32K sessions, 42
turns) the paragraph join recovered every citation turn (6/6, like full
prefill, against 5/6 without it) and was never worse elsewhere;
PFLASH_SELECT_PARAGRAPH_JOIN=0 turns it off. Single-turn compressed
prompts change accordingly: the article's single-turn numbers were
measured without it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The chat scorer query was the tail of the latest user turn, so a question
before or inside a pasted document was missed. The query is now the
prompt's last token -- where the model starts answering, having read the
whole request -- and nothing is parsed out of the user's text: the latest
turn follows the skeleton rule like the others, the generation prompt is
pinned, and history queries are the last token of the header of the reply
that followed each earlier user turn. Agent tool turns after the user's
become ordinary context. Marker-less prompts keep the content tail;
pflash_query keeps its explicit span.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The 2048-token recall cap never bound once a lift gate was on (at most
~960 tokens on the dev chats). On the development chats (Jev-judged), a gate of 2 against
the previous 8: 212 vs 197 of 252 turns on the query-layout chats (question
first, in the middle, among the user's sentences, two questions, emails;
full prefill 231), 37 vs 37 of 42 on the original chats, at ~190 recalled
tokens per turn and 1.01 s vs 0.81 s median later-turn TTFT (full prefill
1.42 s). Recall now takes every out-of-view segment at lift >= 2
(PFLASH_CHAT_RECALL_MIN_LIFT); PFLASH_CHAT_RECALL_TOKENS is gone. A question
that needs more than a third of a fresh selection rebuilds the view from it
instead: past that, the fresh prompt costs about the same and keeps order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One request in ~500 of the recall-gate development run failed with
"non-finite Qwen3.5 scoring-head scores" on a fresh chat whose prompt the
other arm scored cleanly; it did not reproduce. The failure already forgets
the scoring session, so score the prompt once more from scratch (one drafter
forward) before failing the request with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On SCBench's multi-turn chats the prompt-end query answered 112 of 171
turns, the benchmark's explicit question 127: the gap is literal lookups
(an identifier, a described function) the last token does not carry.
Replaying those turns through the drafter, adding the latest user turn's
tail (PFLASH_SELECT_QUERY_TOKENS) as a second query window at full weight
serves the gold passage as often as the explicit query (kv follow-ups 31/32
recallable vs 5/32 from the last token alone; repoqa 40/48 vs 41/48 explicit,
28/48 last token). The last token still reads questions placed anywhere in
the message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Recall decides what a follow-up gets beyond the cached compressed prompt.
It used to take segments whose head lift cleared a gate (~300 tokens) and
put them at the start of the new user turn. On MTRAG (IBM, 32 human
conversations, 255 turns, a ~64K shared context per conversation,
Jev-judged against its references) that served every gold passage of a
follow-up on 17 of 207 turns, against 87 when each turn is compressed from
scratch, and answered 102 turns against 126 from scratch and 120 for full
prefill.

Recall now takes what the fresh selection for the new question keeps that
the view lacks, every kept piece the view does not fully hold, whole and in
document order, before the new question (after it measured 91). Recalled
passages are appended whatever their size; the view is rebuilt from the
fresh selection only when it outgrows twice that selection or the context.
PFLASH_CHAT_RECALL_MIN_LIFT is gone.

MTRAG: 118 of 255 (dependent follow-ups 32 of 77, full prefill 30;
standalone 68 of 146, full prefill 74), first turn 24.2 s against 98.1 s,
follow-ups 7.6 s against 2.9 s, whole conversation 187 s against 273 s.
With ~44K tokens pasted at turn 3: 120 against 110 correct, the paste
answered in 33.5 s against 108.7 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The drafter filled a [align32(kv_len), align32(n)] F16 causal mask on the
host and uploaded it for every full-attention layer and 1024-token ubatch:
about 43 GB of uploads at 120K, 51-58% of drafter time at 79K-118K on the
R9700. The graph now writes the same bytes on the device: a zero F16 fill
(repeat of one element), the diagonal block from ggml_tri over keys
[kv_start, kv_pad) copied into place, and -inf over the padded query rows.
Every byte, padded rows and kv padding included, matches the host mask;
the new unit test compares the two on the CPU backend for aligned and
unaligned starts and partial ubatches.

The compute buffer was also reallocated on almost every ubatch of the first
attention layer, since the mask grows with kv_len (117 times in 119
ubatches at 120K, replayed on the CPU allocator). The buffer is now
reserved once for the full-attention graph of the ubatch with the largest
mask, before the layer loop. Both scorers (strict head and legacy
running-max) share the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant