Skip to content

perf(qwen35): greedy decode allocates nothing and downloads 4 bytes per token (Refs #4215) - #4332

Closed
noahgift wants to merge 5 commits into
mainfrom
perf/qwen35-decode-4215
Closed

noahgift wants to merge 5 commits into
mainfrom
perf/qwen35-decode-4215

Conversation

@noahgift

Copy link
Copy Markdown
Member

Refs #4215. This covers (a), (b) and (c) from alt0693 REPORT.md §A. It does not close the 0.58× gap to ollama; measured below.

What changed:

  • (a) Persistent embedding buffer: Qwen35CudaModel.hidden_buf is allocated once, and each token copies its embedding row into it.
  • (b) Greedy GPU argmax: qwen35_gpu_decode at temperature 0 or top_k == 1 goes to qwen35_gpu_decode_greedy → forward_single_greedy → executor.gpu_argmax. The only download is the 4-byte token id; before, it was the 248,320-entry logits. The GPU argmax tie-break fix (GPU argmax kernel keeps the later index on an exact tie; CPU argmax keeps the first — breaks greedy token identity once #4215(b) moves argmax to GPU #4259, first index of an exact max, like the CPU) is merged in. It is in the batch(B1): emergency fold — serve/perf/runtime (9 sources, #4302 parked) #4311 fold too, and must land first or with this PR.
  • (c) No format!/Vec on the launch path:
    • A module_key! cache interns (fmt, dims) → Arc<str> module keys.
    • The GDN launches pack their arguments into stack arrays (gdn_pack_args) and build arg_data only while recording a graph.
    • Converted wrappers: gdn_*, rmsnorm/per-head rmsnorm, residual, swiglu, the GEMV variants (q4k hw/mwv/dp4a, q6k, q5k, q4_0/q4_1/q5_0/q8_0/f32), q8_quantize, and gpu_argmax.
  • New test counters: trueno_gpu::driver gains device_allocs_total() and device_to_host_bytes_total().

Red first, 0.8B Q4_K_M on the RTX 4090 via gpu-q. A counting global allocator measures steady-state tokens after warm-up:

per token origin/main this PR
host heap allocs 1713 0
device allocs 1 0
device→host bytes 993,280 4
  • greedy_on_the_device_is_token_identical_to_the_cpu_argmax_of_the_logits: 38 tokens identical at temperature 0.
  • The last 36 host allocs/token were found with a backtrace-tallying allocator, not by reading code: q5k_gemv_into's format! key on two DeltaNet projections per layer.

Decode tok/s at 850 prompt tokens (bench_greedy_decode_tok_s_at_850, ignored, release, 128 tokens, base and new interleaved on the same queue slot):

model base (2 runs) this PR (2 runs)
4B Q4_K_M 78.3, 78.3 80.2, 79.6 (+2%)
0.8B Q4_K_M 157.0, 166.8 172.6, 161.1 (noise-dominated)

Host overhead was not the bottleneck at this size; the remaining gap is in the kernels themselves.

Merge note: this touches the same executor files as #4258 (parked in #4311 over release/0.69.3 conflicts). I will merge origin/main into this branch after #4311 lands, with no rebase or force-push.

🤖 Generated with Claude Code

noahgift and others added 5 commits September 24, 2026 14:14
…max it replaces returns the FIRST (Closes #4259)

The block and final tree reductions kept `other` only on strict `>`. After
the strided load, thread 0 holds index 768 and thread 1 holds index 1, so on
an exact tie the reduction keeps 768; in the final pass a subtree that has
absorbed block 128 beats block 1 the same way. `gguf::ops::argmax` (strict
`>` in index order) returns the first maximum, so a temp-0 decode moved onto
the GPU argmax (#4215 (b)) would emit a different token on every exact tie.

`other_wins` = greater OR (equal AND lower index), applied at all three
reduction sites. Both comparisons are ordered, so NaN still never wins. New
builder ops: `setp_eq_f32`, `or_pred`. Top-k is untouched.

tests/argmax_falsification_f114/tie_break.rs: within-block tie, cross-block
tie, all-equal, NaN-at-low-index, and a no-tie regression at Qwen3.5's
248320 vocab, each asserted against the CPU first-max contract.
APR_REQUIRE_CUDA=1 turns a missing device into a failure instead of a skip.

Closes #4259. Cherry-picked from perf/4215 (c8ce988c3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ce and downloads 4 bytes (Refs #4215)

Remaining host allocations were the format! module keys in q5k_gemv_into
(two DeltaNet projections per layer, 36/token), q4_1_gemv_into and
q8_quantize_{async,into}; all now use module_key!. Measured on the 4090:
host allocs 1713 -> 0, device allocs 1 -> 0, D2H 993,280 -> 4 bytes/token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@noahgift noahgift added this to the 0.71.0 milestone Sep 24, 2026
@noahgift

Copy link
Copy Markdown
Member Author

quorum-review (AD-04): three PASS — agreed (auto_merge: checked=true was_armed=false disarmed=false)

{
 "ticket": "PMAT-4215",
 "head": "f80b3f82e2fe862eea432a8f477d529f7d4d7933",
 "width": 3,
 "executor": "agy",
 "agreed": true,
 "auto_merge": {
  "checked": true,
  "was_armed": false,
  "disarmed": false,
  "note": "auto-merge not armed"
 },
 "lanes": [
  {
   "lane": 1,
   "verdict": "PASS",
   "findings": 3
  },
  {
   "lane": 2,
   "verdict": "PASS",
   "findings": 0
  },
  {
   "lane": 3,
   "verdict": "PASS",
   "findings": 4
  }
 ]
}

@noahgift
noahgift enabled auto-merge September 24, 2026 19:35
@github-actions

Copy link
Copy Markdown

§13.11 rung 1 — quorum shadow verdict

S13-SHADOW pr=4332 head=f80b3f82e2fe862eea432a8f477d529f7d4d7933 verdict=REFUSE class=Q1 arm_rc=1

Shadow mode: this records a verdict and merges nothing. A refusal
to arm is not a block (§13 adds zero rows to §7) — the pull request is
exactly as green as it was.

@noahgift

Copy link
Copy Markdown
Member Author

Replaced by #4439: a fresh branch from current main with these commits cherry-picked (-x), per the operator ruling at 16:15 (no main-merges); the cop OK'd this close. #4439 passed a 3/3 quorum plus a 3/3 delta review; GPU tests green.

@noahgift noahgift closed this Sep 25, 2026
auto-merge was automatically disabled September 25, 2026 15:44

Pull request was closed

guyernest pushed a commit to guyernest/aprender that referenced this pull request Sep 29, 2026
…-of-ties (paiml#4259) + allocation-free decode plumbing (paiml#4215) — replaces paiml#4322 paiml#4332 (paiml#4439)

* fix(cuda): a DP4A GEMV requantizes when its input buffer or contents change (Closes paiml#4258)

The DP4A Q4_K/Q6_K GEMVs cache their Q8_1 activation behind
`q8_activation_valid`. Nothing on the Qwen3.5 decode path cleared it, so
every DP4A GEMV after the first reused the first quantized activation
across projections, layers and tokens.

The cache is now keyed by its source: `ensure_q8_activation` quantizes
unless the flag is valid AND the input is the same (pointer, length) that
was last quantized. The writers that rewrite a GEMV input in place on the
qwen35 path (rmsnorm_into, per_head_rmsnorm_into, residual_add_into,
fused_swiglu_into and every gdn_* launch) invalidate it when their output
is the cached source.

tests_q8_activation_staleness.rs: a DP4A GEMV on a second buffer, and after
each of the five writers rewrites its input, must land within Q8_1
rounding of the float GEMV of the new contents, for HwDp4a and MwvDp4a.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1f14aea)
(cherry picked from commit 263958f)

* test(cuda): pre-allocate every input so no staleness case rides a reused address; document the (ptr,len) key's limit (Refs paiml#4258)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit e9d074b)
(cherry picked from commit 56c9f91)

* docs(roadmap): register PMAT-4258 (Refs paiml#4258)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4482ab9)
(cherry picked from commit 4d4a784)

* test(qwen35-cuda): the DP4A falsifier becomes a parity guard — its 'catastrophic' reading was paiml#4258

The stale-Q8_1 fix (263958f) makes qwen35_cuda_dp4a_gemv_is_catastrophic_through_the_recurrence
fail as intended: DP4A now holds the parity contract on 0.8B. Assert that instead, so the
stale-activation reuse cannot come back silently. The float pin stays until 2B/4B re-measure (paiml#4030).

Refs paiml#4258

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 555a677)

* PMAT-4258: impl receipt — carried from paiml#4302, GPU green on 555a677

Refs paiml#4258

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 36f1ea7)

* PMAT-4258: quorum AGREED 3/3 PASS on 36f1ea7 + stale falsifier name in parity thresholds (comment)

Refs paiml#4258

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 3b23f45)

* GPU argmax returned the LAST of two exactly-equal maxima; the CPU argmax it replaces returns the FIRST (Closes paiml#4259)

The block and final tree reductions kept `other` only on strict `>`. After
the strided load, thread 0 holds index 768 and thread 1 holds index 1, so on
an exact tie the reduction keeps 768; in the final pass a subtree that has
absorbed block 128 beats block 1 the same way. `gguf::ops::argmax` (strict
`>` in index order) returns the first maximum, so a temp-0 decode moved onto
the GPU argmax (paiml#4215 (b)) would emit a different token on every exact tie.

`other_wins` = greater OR (equal AND lower index), applied at all three
reduction sites. Both comparisons are ordered, so NaN still never wins. New
builder ops: `setp_eq_f32`, `or_pred`. Top-k is untouched.

tests/argmax_falsification_f114/tie_break.rs: within-block tie, cross-block
tie, all-equal, NaN-at-low-index, and a no-tie regression at Qwen3.5's
248320 vocab, each asserted against the CPU first-max contract.
APR_REQUIRE_CUDA=1 turns a missing device into a failure instead of a skip.

Closes paiml#4259. Cherry-picked from perf/4215 (c8ce988c3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit e4df9c2)

* [WIP] perf(qwen35): allocation-free greedy decode token (Refs paiml#4215)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7e08584)

* test(qwen35): decode tok/s at 850 bench, ignored (Refs paiml#4215)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f08fce5)

* perf(qwen35): a greedy decode token allocates nothing on host or device and downloads 4 bytes (Refs paiml#4215)

Remaining host allocations were the format! module keys in q5k_gemv_into
(two DeltaNet projections per layer, 36/token), q4_1_gemv_into and
q8_quantize_{async,into}; all now use module_key!. Measured on the 4090:
host allocs 1713 -> 0, device allocs 1 -> 0, D2H 993,280 -> 4 bytes/token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f80b3f8)

* style: rustfmt the paiml#4215 driver re-exports after the cherry-pick onto main

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

* fix(cuda): CudaExecutor::new inits the paiml#4215/paiml#4258 fields with Default — the complexity ratchet read cognitive 40 -> 41

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

* test(qwen35-cuda): the paiml#4215 zero-host-alloc token measures the release lookup, not main's paiml#3759 test-build key proof

On main, ensure_kernel_module proves every cache hit in test builds, and the
proof format!s the KernelType on each launch. Cherry-picked onto main, the
paiml#4215 steady-state test counted those allocations and failed its host == 0
assert. Device allocs (0) and D2H (4 B per token) already held.

The warmup still runs the proof over every key the token uses. Only the
measured window suspends it, through a cfg(test) switch on the ledger.
Release builds have no ledger. Also drops a comment that knocked rustfmt's
field alignment out of place.

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

* ci: re-run §6 R-2 against the keep-open body (paiml#3759 paiml#4215 are Refs, reasons stated)

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

* fix(guard): keep par-062's baselined claim line at 257 — the paiml#4215 comment had shifted it by 2

check_no_claim_literals keys its baseline by line; the moved line read as a
NEW claim. Comment-only (3 lines → 1). Delta quorum 3/3 PASS (sonnet-5 ×2,
haiku-4-5; degraded: same-family).

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

* fix(guard-tree test): row 55 read an overlap as 'alone' when the serial guard ran inside the pool guards

serial_alone() only flagged events BETWEEN start/end of l_serial. On a loaded
runner the order was a|b|serial-start|serial-end|a-end|b-end: both pool guards
wrapped the serial one, the window was empty, and the mutant looked serial
(paiml#4439 guard-tree job 108232939859). Count guards still open at the serial
start. Case table: new 6/6, old misses both wrap-around orders. The same fix
removes a false PASS from row 53.

Refs paiml#4439

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@noahgift
noahgift deleted the perf/qwen35-decode-4215 branch October 9, 2026 08:26
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