Skip to content

fix(v4): close the audit's last open finding-4 item; the long-prompt death is not a reference divergence - #97

Merged
heydryft merged 3 commits into
masterfrom
agent/v4-audit-fixes
Aug 17, 2026
Merged

heydryft merged 3 commits into
masterfrom
agent/v4-audit-fixes

Conversation

@heydryft

Copy link
Copy Markdown
Contributor

Closes the reference-audit follow-up (PR #27, task #16) and answers the
long-prompt serving failure (task #22).

The six findings were already fixed

All of PR #27's two CRITICAL and four HIGH findings are on master. Verified by
reading the current code, not by trusting the PR titles:

# Finding State
1 CRITICAL — MTP draft seeded with embed(T0), not the target's hidden state fixed (#30) — propose_chain is seeded from last_hidden; v4_mtp_draft_depends_on_the_target_hidden_state_not_only_the_token fails on the old seeding
2 CRITICAL — MTP attends an empty per-chain KV at absolute RoPE positions fixed (#30) — new_draft_cache is persistent per sequence and extended over accepted tokens (extend_draft_kv_row), mirroring forward_draft_extend; pinned by v4_mtp_draft_kv_holds_the_accepted_context
3 HIGH — b_sz > 1 prefill routed to mask-free sinks_attn_varlen fixed (#28) — is_varlen now requires mask.is_none(), and dsv4_attention always passes Some(&mask), so V4 can never reach it
4 HIGH — caller's attention_mask discarded fixed (#28) — compose_caller_mask folds it in and errors on a width it cannot place. One sub-item was left open and is fixed in this PR (below)
5 HIGH — swiglu clamp missing on 4 of 5 expert paths fixed (#35) — every backend routes activation through swiglu_clamp; the shared expert carries .with_swiglu_limit(cfg.swiglu_limit)
6 HIGH — YaRN applied to Standard (ratio-0) layers fixed (#35) — rope_scaling_for_compress_ratio returns None at ratio 0; the old behaviour is behind ARC_YARN_ON_STANDARD_LAYERS=1

What this PR changes

SdpaParams::sliding_window was inverted (audit §1(e), the one open piece of
finding 4). sinks_attn forwards it to the fused flash-sinks kernels as
window_size — a bottom-right-aligned relative-distance window over whatever
key axis those kernels are handed. dsv4_attention hands them the raw sliding
window on Standard layers and the concatenation [raw ++ compressed] on
CSA/HCA. So Standard is the only ratio that may declare a window, and it was the
one declaring None. On CSA/HCA a window over the union axis deletes exactly the
distant-context keys the compressed branch exists to supply — compressed entry
j stands for absolute position j * ratio, not t_k - j.

Inert today only because flash_sinks_ok rejects head_dim = 512, the head dim
every V4 layer uses. PR #94 adds 512-wide kernels, which arms it — silently,
because the fused path takes no mask that could disagree with it.

Length coverage. v4_long_prompt_prefill_and_decode_sweep prefills and then
decodes through the real V4 dispatch at 127/129/511/513/1400 tokens, bracketing
the 128-token window, the CSA/HCA block boundaries, and CACHE_GROW_SIZE (512).
CPU unit test, labelled as such — not a GPU claim.

Both new tests are mutation-checked: inverting sdpa_sliding_window fails the
first; pinning XsRollingCache's row ceiling to its initial 64 fails the second
at T=511 and only there, which is what makes the sweep length-selective rather
than a smoke test.

Does any finding explain the long-prompt death?

No. All six are eliminated, and the mechanism is throughput, not correctness.

The measurement (FACTS, session 8): 9 words → 100.15 s for 8 tokens; 198 words →
386.15 s for 8 tokens; ~1,055 words → connection refused.

Elimination:

  • Findings 1, 2, 5, 6 carry no length dependence at all — MTP seeding, the
    clamp and the RoPE table are identical at 12 tokens and at 1,400.
  • Finding 3 needs b_sz > 1; a single canary request is b = 1, and the
    route is closed regardless.
  • Finding 4 is the only length-shaped candidate, and the sweep above is the
    direct test: prefill + decode at 1,400 tokens through the real dispatch is
    clean and finite. The shape/mask/position/compressed-KV class — a mask built
    wrong, a position index that mis-bases, a compressed-KV path that only holds
    short — is ruled out on CPU.
  • A panic cannot do it either: step_catching_panics turns an engine panic into
    a failed batch, and handle_pipeline_forward_error! turns an Err into a
    failed request. Neither closes the socket. "Connection refused" means the
    process left.

What is left is cost. V4 has no fused attention kernel at head_dim = 512
(flash_sinks_ok = {64,80,96,112,128,192,256}), so every prefill layer runs
unfused through sinks_attn_cpu, which repeat_kv-expands K and V to all 64
heads with a real Tensor::cat (attention/mod.rs:98) and materialises the full
score matrix. At the real geometry, per layer, bf16, CSA (n_keys = 1.25·T):

  • repeat_kv K+V: 2 · 64 · 1.25T · 512 · 2 B = 163,840·T bytes
  • scores, twice (matmul out + softmax out): 2 · 64 · 1.25T² · 2 B = 320·T² bytes

⇒ 71 MB/layer at T = 280, 856 MB/layer at T = 1400, 5.6 GB/layer at T = 4096,
against 43 layers of arithmetic that is 7.05e6·T² FLOP for the stack.

Fitting the two OK cells to t = a + b·T² (≈1.33 tok/word) gives a ≈ 96 s,
b ≈ 4.3e-3 s/token² — and predicts ≈ 8,500 s (2.4 h) for the 1,055-word
prompt
. A request that never returns is indistinguishable from a dead server to
a canary, and the transient working set is an order of magnitude larger at the
same time.

Stated honestly: two points fit a two-parameter curve exactly, so this is
consistent with quadratic prefill, not a measurement of it. The a ≈ 96 s
length-independent term is separately corroborated — it is 8 decode steps of the
unfused decode path plus the CPU sampler fallback already logged in FACTS
(tensor_device_ptr: unsupported dtype I32, ~10/sec), which another agent owns.
That fallback is additive to a and does not touch b, so fixing it will not
move the long-prompt cell.

⇒ Task #22 needs a separate owner, and the owner is the kernel workstream, not
the audit.
The two things that would settle it on a box, in order:

  1. one prefill-only timing at T ∈ {256, 512, 1024} — three points test the
    quadratic rather than assuming it, and separate a from b;
  2. nvidia-smi peak reserved during the 1,055-word attempt, to say whether it
    died of the clock or of memory.

Both fit inside any rental already up.

Also surfaced, not shipped

  • The CUDA-graph decode arm (deepseek4.rs, has_graph_mode_positions())
    still passes raw_prefix: 0 while reading a fixed cap = sliding_window-wide
    rolling window, so q0 is pinned at cap - 1 and the compressed-block
    threshold freezes at floor(128/ratio) — the distant-context branch stops
    growing past token 128. This is the second half of audit §1(e) note 2. Not
    fixed here: the correct raw_prefix is a host value and the position is
    deliberately device-side, so it needs a design decision (plumb the host
    position, or refuse compressed layers on the graph arm), not a patch. It is
    behind a double opt-in (ARC_V4_CAPTURE_PROBE + ARC_CANDLE_ALLOC_CACHE) and
    is not on the serving path.
  • MtpSpeculativePipeline::extend_draft_kv is dead (extend_draft_kv_row is the
    live one). Pre-existing.

🤖 Generated with Claude Code

…ength

PR #27's two CRITICAL and four HIGH findings are all already fixed on master
(#28 findings 3+4, #30 findings 1+2, #35 findings 5+6). Re-read against the
current code, one sub-item of finding 4 (§1(e)) was never actioned and is
closed here, and the length question the audit raised now has coverage.

**`SdpaParams::sliding_window` was inverted.** `sinks_attn` forwards it to the
fused flash-sinks kernels as `window_size`, a bottom-right-aligned
relative-distance window over the key axis those kernels are handed. That axis
is the raw sliding window on Standard layers and `[raw ++ compressed]` on
CSA/HCA — so Standard is the one ratio that may declare a window, and it was
the one declaring `None`. On CSA/HCA a window over the union axis deletes
precisely the distant-context keys the compressed branch exists to supply:
compressed entry `j` stands for absolute position `j * ratio`, not `t_k - j`.

Inert today only because `flash_sinks_ok` rejects `head_dim = 512`, the head
dim every V4 layer uses — and PR #94 is adding 512-wide kernels, which arms it,
silently, because the fused path takes no mask that could disagree. The
decision now lives in `dsv4_attention::sdpa_sliding_window`, next to the code
that builds the axis it describes.

**Length coverage.** `v4_long_prompt_prefill_and_decode_sweep` prefills and
then decodes through the real V4 dispatch at 127/129/511/513/1400 tokens —
bracketing the 128-token window, the CSA/HCA block boundaries and
`CACHE_GROW_SIZE` (512), the three thresholds a length-dependent defect would
sit on. It is a CPU unit test, not a GPU claim; the fixture's position ceiling
is now a parameter so the sweep measures the model rather than the fixture.

Both tests mutation-checked: inverting `sdpa_sliding_window` fails the first;
pinning `XsRollingCache`'s row ceiling to its initial 64 fails the second at
T=511 and only there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Code Metrics Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 C Header                  5          305          210           52           43
 CSS                       2         1181         1036           34          111
 CUDA                     72        24328        17592         4018         2718
 Dockerfile                1           39           22            8            9
 JavaScript               16         3546         2676          482          388
 Jinja2                    7          694          656            5           33
 JSON                     74         4600         4597            0            3
 Makefile                  1            6            5            0            1
 Metal Shading Lan|       33        12224         9431         1142         1651
 PowerShell                1          300          227           30           43
 Python                  140        14489        11930          782         1777
 Shell                    21         4531         3182         1056          293
 Plain Text                4         3801            0         2479         1322
 TOML                     33         1485         1292           43          150
 YAML                      3           25           23            2            0
─────────────────────────────────────────────────────────────────────────────────
 HTML                      4         2687         2604           43           40
 |- CSS                    2          543          479           37           27
 |- JavaScript             1         1233         1215           12            6
 (Total)                             4463         4298           92           73
─────────────────────────────────────────────────────────────────────────────────
 Jupyter Notebooks         4          122           83           23           16
 |- Markdown               1           60           30           22            8
 |- Python                 1          122          113            1            8
 (Total)                              304          226           46           32
─────────────────────────────────────────────────────────────────────────────────
 Markdown                182        35380            0        27061         8319
 |- BASH                  65         1567         1163          294          110
 |- C                      1           10           10            0            0
 |- CUDA                   2           84           56           16           12
 |- JSON                  18          708          708            0            0
 |- PowerShell             1            1            1            0            0
 |- Python                23         1008          787          113          108
 |- Rust                  64         2043         1710           75          258
 |- TOML                   6          207          164            0           43
 |- YAML                   4           38           33            5            0
 (Total)                            41046         4632        27564         8850
─────────────────────────────────────────────────────────────────────────────────
 Rust                    660       305484       264599        13728        27157
 |- Markdown             475        22019          471        18941         2607
 (Total)                           327503       265070        32669        29764
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1263       444870       327105        70504        47261
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

heydryft and others added 2 commits August 17, 2026 13:51
…fiable

PR #97 fitted `a + b·T²` to two points. Two points determine a two-parameter
curve exactly, so that fit is consistent-with the mechanism, not evidence for
it — and six workstreams have since been re-ranked on it. This script turns it
into evidence or kills it, unattended, in ~80 minutes.

Three arms, all criteria pre-registered in the header before any number exists:

* **Arm B (memory law, runs first)** — peak transient VRAM at T ∈ {512, 1024,
  2048, 4096}. Cheap because the score matrix is allocated in layer 0: fire the
  request, sample `nvidia-smi` at 100 ms, abort after 90 s. Spans 8× for ~6
  minutes and can refute the mechanism before Arm A spends an hour on it.
* **Arm A (timing law)** — prefill isolated, not assumed: `max_tokens` 1 and 5
  at each T give `decode_step = (t5-t1)/4` and `prefill = t1 - decode_step`, so
  the constant and quadratic terms separate instead of confounding. Ascending
  ladder under a wall-clock budget; a timeout is recorded as a lower bound.
* **Arm C (optional discriminator)** — `ARC_V4_WINDOW_ONLY=1` takes `n_keys`
  from 1.25T to T. Score-matrix-bound prefill falls to ~0.64; MoE-bound prefill
  barely moves. This separates "attention" from "everything else" directly.

The predicted peak is **one layer's** working set (`320·T² + 163840·T` bytes),
not 43 layers': the layers are sequential and each frees before the next
allocates. A 43× criterion would reject a correct mechanism, so the falsifiable
claim is the curve's shape — adjacent ratios ~3.0/3.3/3.6 against ~2.0 for any
linear mechanism.

Verdict logic self-tested on synthetic data: quadratic in → `supported`, linear
in → `refuted`, quadratic timing with linear memory → `mixed`, and **two points
in → `insufficient`**, which is the script scoring the evidence that prompted it
as not yet evidence. Exit 2 (environment could not answer) is never conflated
with a refutation.

Also fixes a silent-gate hole in the caller convention: `source
gpu_box_preflight.sh || handler` never fires, because when sourced the script
ends on `[ "$_arc_pf_sourced" = "1" ] || exit 1` — the `[` succeeds,
short-circuits, and `source` returns 0 on a FAILED box. This checks
`_ARC_PF_FAILED` directly and refuses when it is unset, i.e. when the checks
never ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odel it where it is not

The prefill isolation ran a second full prefill at every T purely to measure
that T's decode step. At T=1024 that is ~75 minutes spent on a quantity worth
~2.6 s. Decode is linear in context and prefill is the thing under test, so
decode's share of t1 falls as 1/T: measure it at the cheap end of the ladder
(ARC_PC_SUBTRACT_TS, default "128 256"), fit `d = d0 + d1·T`, evaluate it at the
expensive end. Full ladder drops from ~3.3 h to ~1.8 h.

Every point records `decode_source` (measured | extrapolated) and
`decode_share_of_t1`, so the modelled contribution is auditable rather than
assumed — at T=1024 it is ~0.06% of t1, which is the argument for doing it.

Also documents the run's own cost as a prediction: ~1.8 h if prefill really is
quadratic, ~15 min if it is not. The cheap outcome is the one that refutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heydryft
heydryft merged commit 0aceadb into master Aug 17, 2026
27 of 28 checks passed
@heydryft
heydryft deleted the agent/v4-audit-fixes branch August 17, 2026 18:22
@heydryft

Copy link
Copy Markdown
Contributor Author

Admin-merged without review (this fork has no second reviewer); all 16 checks green at merge time — verified against head ef9dfce9, 0 pending, 0 failures. Merged deliberately ahead of #94: the inverted SdpaParams::sliding_window this fixes is inert today only because flash_sinks_ok rejects head_dim 512, and #94's 512-wide kernels arm it.

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