Skip to content

perf(qwen35): align the logits GEMM width so the output projection stops landing on an align-1 sm_75 kernel - #1046

Merged
FeathBow merged 12 commits into
pegainfer-project:mainfrom
CAICAIIs:perf/qwen35-align-output-projection
Sep 16, 2026
Merged

FeathBow merged 12 commits into
pegainfer-project:mainfrom
CAICAIIs:perf/qwen35-align-output-projection

Conversation

@CAICAIIs

@CAICAIIs CAICAIIs commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

The once-per-step output-projection GEMM (logits over the selection vocab) was landing on cutlass_75_tensorop_bf16_s1688gemm_bf16_128x64_tn_align1 — an sm_75-era kernel at alignment 1 — costing 1.67 ms/decode-step, ~12% of c16 TPOT. Root cause: Qwen3.5-4B's tokenizer-decodable vocab is 248077 — odd — so both the GEMM M dimension and the logits output leading dimension defeat cublasLt's vectorized ampere kernels.

bound_selection_vocab now rounds the selection width up to the 128-token tile multiple (248077 → 248192), clamped by the checkpoint's 248,320 rows so the GEMM still reads inside the mapped weight. The extra rows are real trained embeddings, and sampling over them matches HF, which computes logits over the full checkpoint vocab. Downstream buffers and the sampler key off the same config width, so nothing else moves. Found via nsys kernel attribution; the full doc ships in the second commit (docs/models/qwen35/decode-kernel-attribution.md).

Measured (1x A100-40GB, upstream/main 139d925e + this change, vLLM 0.27.0 baseline)

1024-token prompts, greedy, vllm bench serve, zero failed requests; single run per cell:

cell before TPOT / ITL p99 / TTFT ms after TPOT / ITL p99 / TTFT ms vLLM 0.27 TPOT ms
1024/256 c8 11.98 / 34.5 / 394 11.23 / ~34 / ~392 9.88
1024/256 c16 14.26 / 36.4 / 691 13.51 / ~36 / ~682 11.00
QPS16 1024/128 23.77 / 41.7 / 1264 22.68 / 40.6 / 1157 23.60

Measured on top of the merged auto+stream posture (#1033/#1034): c16 TPOT −5.3%, c8 −6.3%, QPS16 −4.6% (back below vLLM), throughputs +4–6%, TTFT/ITL unchanged or better.

Review follow-ups

Three commits sit on top of the perf commit:

  1. Padded rows must not be selectable (Codex P1). Tile-aligning the selection width made ids 248077..248192 eligible for greedy/sampled selection. Each logits GEMM is now followed by the existing SuppressIds kernel forcing those rows to -inf, so selection scans exactly the pre-fix decodable vocab. The suppress list is uploaded once at load and runs inside the decode CUDA Graph (no per-step host cost).
  2. Padded rows must not move the routing decision (FeathBow). select_batch read the routing width from logits.hidden_dim, so effectively_greedy's top_p <= 1/vocab threshold moved from 1/248077 to 1/248192 — a request with top_p between those values would drop off the deterministic argmax path onto the rejection sampler, which can pick either member of a bf16-tied top. SampleScratch now carries the arena width and the semantic selection width separately (new keeps selection_width == vocab; qwen35 builds through with_selection_width(selection_vocab, decodable_vocab)), so a padded arena routes exactly the rows an unpadded one would.
  3. The mask cannot be skipped at a logits site (FeathBow). The aligned GEMM and the pad-row mask were two statements repeated at three sites, so a fourth logits site could run the GEMM and skip the mask — and the failure is silent, because the pad rows are trained embeddings with plausible logits. Qwen35Model::output_logits_into now owns both statements and the three sites call it, and output_projection() is private (the GEMM-tuning helper is its only remaining caller), so the GEMM is not reachable without the mask. The ops re-exports the old split needed are gone with it.
  4. A style pass: comments cut to the constraint that cannot be recovered from the code, a test-only getter and a construction-guard test for a programmer-error assertion removed, and the two invariants recorded in the attribution doc rather than only in the diff.

Testing

Verified on 1x A100-SXM4-40GB (dgx, CUDA 11.5, sm_80), branch at fe48b666, model Qwen3.5-4B, built with --features qwen35 and PEGAINFER_TRITON_PYTHON on a Triton 3.7.1 venv.

  • lib tests for the touched packages (pegainfer-sample, pegainfer-qwen35) 102 passed / 0 failed / 7 ignored, including the two bound_selection_vocab alignment/clamp unit tests.

  • pad_columns_are_suppressed_on_the_model_logits_paths — new, and the one test that covers the mask invariant on the model itself rather than on a synthetic arena. It drives the real checkpoint through both logits paths (batch_prefill_logits and batch_decode_graph) and requires every column in decodable_vocab..selection_vocab (248077..248192) to come out -inf while the decodable prefix does not. Mutation-checked: commenting the mask out of output_logits_into fails it with prefill: pad id 248077 survived selection (want -inf), so it is a test with teeth rather than a tautology.

  • pegainfer-sample integration suite 11 passed / 0 failed (was 9), including the new padded_arena_width_does_not_suppress_the_argmax_routing: a 512-column arena with 256 decodable and top_p = 1/256 exactly on the boundary, where the arena-width scratch routes to the sampler and can return the bf16-tied peer, and the decodable-width scratch keeps the deterministic argmax for all 64 seeds.

  • hf_golden_gate 2/2 passed (TP1; the TP2 graph variant is the pre-existing host-environmental NCCL precapture issue noted above). It replays fixed teacher-forced sequences and compares logprobs against HF fixtures — it never selects a token, and the row logsumexp it scores is mask-invariant (exp(-inf) = 0), so it is evidence that the aligned width leaves prefill/decode numerics alone, not evidence about the mask. That distinction is now written down in the attribution doc instead of being implied by a blanket "output equivalence" claim.

  • e2e_scheduler 2/2 passed.

  • Production A/B, same session on the same GPU (base 139d925 vs this branch, real server on GPU 7, vllm bench serve random 1024/256, greedy, seed 42, zero failed requests in every cell):

    cell base TPOT / TTFT / ITL ms branch TPOT / TTFT / ITL ms
    c16 14.90 / 738.7 / 14.84 14.09 / 718.7 / 14.04
    c8 12.23 / 379.1 / 12.19 11.57 / 399.4 / 11.52

    Both cells improve ~5.4%, so the speed-up reproduces against a same-session baseline rather than only across sessions, and the review follow-ups do not give any of it back. Re-measured after the output_logits_into refactor (at 05ffa360; the only later commit makes output_projection private): c16 14.12 ms / TTFT 708.5, c8 11.48 ms / TTFT 377.2, 0 failed.

  • HTTP invariant probe against the branch server, which keys the observed contract rather than raw text equality (greedy decode on this host is not bit-reproducible, see below): across 4 prompts with logprobs=1, all 96 emitted token strings are real tokenizer tokens with zero empty/undecodable strings, so no id at or above the decodable 248077 ever reaches the wire; temperature=1, top_p=4.03e-6 (the case FeathBow raised) returns byte-identical text to greedy on every prompt; and top_p=0.9 differs from greedy on 3 of 4 prompts, confirming the wide-nucleus sampling path is still live rather than everything being pinned to argmax.

  • Widths confirmed against the checkpoint: decodable 248077, weight rows 248320, aligned selection 248192, so the suppressed region is exactly 248077..248192 (115 rows) and the GEMM stays inside the mapped weight.

  • Kernel check on a clean c16 trace: the align-1 kernel no longer appears; the replacement ampere GEMM family drops the step cost by ~0.7–1.3 ms.

What was NOT verifiable, and why

  • Raw output differentials between binaries are not usable as evidence here. Running the unmodified base binary twice under identical parallel load produced different greedy output (4 differing rows), and it also differed run-to-run at concurrency 1. Greedy decode on this setup is not bit-reproducible, so a base-vs-branch text diff measures runtime noise, not this change. The deterministic gates above (hf_golden_gate, the sampler tests) are the sound evidence for output behaviour.
  • This round's new test and the refactor were verified with --test-threads=1; the flake below is why. The full cargo test --workspace --lib could not run: the workspace build pulls the moe feature, which needs NCCL >= 2.30.4, and every NCCL on this host is 2.29.7. The touched-package lib tests were run instead.
  • The sampler integration suite must run single-threaded on this host; in parallel it flakes, on the base commit too. Default (parallel) execution failed 1 of 4 runs on this branch and 1 of 6 runs on unmodified 139d925, always as batch sampling kernel failed with error 1 from whichever test happened to overlap; with --test-threads=1 the suite passed 4/4 on both. Earlier in this PR that flake was misattributed to min_p_row_takes_the_sampler_path_and_filters specifically — it moves between tests, which is what pointed at cross-test GPU contention rather than any one test. Pre-existing, not caused by this PR, and not fixed here. Every serial run quoted above is --test-threads=1.

Claim boundary

Single-run cells on one GPU; the GDN/full-attn decode kernel gaps (2.2×/2.4× per layer-step, 6× at bs1) documented in the attribution doc remain open and are the next optimization targets. The perf table's "before/after" cells predate the review follow-ups; the re-run above is within run noise of the original fix.

Steady-decode kernel captures of both engines on the same GPU and workload
(node-trace, composition and ratio claims only). Four findings for the
remaining serving gap: the c16 GEMM family runs +3.5 ms/step (sm_80 decode
buckets never tuned), the GDN decode kernel is 2.2x and the FlashInfer paged
decode 2.4x per layer-step (6x at bs1) against FLA/flash references, the
once-per-step output-projection GEMM lands on an align-1 sm_75 kernel at
1.67 ms/step because the selection width is odd, and bs1 is 92% GPU-busy
(no host-gap problem). Improvement queue ordered by expected value.

Signed-off-by: CAICAIIs <3360776475@qq.com>
…ops landing on an align-1 sm_75 kernel

Kernel attribution (previous commit) found the once-per-step
output-projection GEMM picking cutlass_75_tensorop_bf16_128x64_tn_align1 at
1.67 ms/decode-step (~12% of c16 TPOT): the selection width is
Qwen3.5-4B's tokenizer-decodable vocab 248077 — odd — so both the GEMM M and
the logits leading dimension defeat cublasLt's vectorized ampere kernels.

bound_selection_vocab now rounds the selection width up to the 128-token
tile multiple (248077 -> 248192), clamped by the checkpoint's 248,320 rows
so the GEMM still reads inside the mapped weight; the extra rows are real
trained embeddings, and sampling over them matches HF, which computes logits
over the full checkpoint vocab. Downstream buffers and the sampler key off
the same config width, so nothing else moves.

Measured (A100-40GB, vLLM 0.27.0 baseline, 1024-token prompts, zero failed
requests): c16 TPOT 14.26 -> 13.51 ms (-5.3%), c8 11.98 -> 11.23, QPS16
23.77 -> 22.68 (vLLM 23.60); throughputs +4-6%. hf_golden_gate TP1 2/2 and
the non-graph TP2 tests green; the tp2_graph NCCL precapture failure
reproduces on clean upstream main without this change (host-environmental,
differential-tested). e2e_scheduler passed.

Signed-off-by: CAICAIIs <3360776475@qq.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 10cea8f5f5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +171 to +172
let aligned = effective_vocab.next_multiple_of(128);
self.selection_vocab = aligned.min(self.vocab_size);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude tile-padding rows from token selection

When the checkpoint has more rows than the tokenizer (the documented 4B case is 248,320 versus 248,077), this makes IDs 248077–248191 eligible for greedy and sampled selection even though tokenizer_effective_vocab explicitly defines only the frontend-decodable prefix. The bridge forwards sampled IDs without a range check, so permissive sampling can emit an undecodable/blank token and then feed that ID back into subsequent decode steps. Keep the GEMM width padded, but mask the extra logits to -inf or retain effective_vocab as a separate sampling bound; the HF top-logprob replay cited here does not exercise selection from these rows, and the repository requires model-eval evidence for output changes.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Review follow-up on pegainfer-project#1046 (Codex P1): tile-aligning the selection width
makes IDs past the tokenizer-decodable vocab (248077..248192 on 4B) eligible
for greedy and sampled selection, and the bridge forwards sampled IDs
without a range check — a permissive sample could emit an undecodable token
and feed it back into later decode steps.

The logits GEMM keeps the aligned width; each logits GEMM is now followed by
the existing SuppressIds kernel forcing the pad rows to -inf (a no-op when
the width needs no padding). Greedy and sampled selection therefore scan
exactly the pre-fix decodable vocab — bit-identical behavior, pure GEMM
perf. The suppress list is uploaded once at model load and the suppression
runs inside the decode CUDA Graph, so there is no per-step host cost.

A100-40GB re-run with the mask: c8/c16/QPS16 TPOT 11.50/14.09/35.48 ms —
within run noise of the unmasked fix; hf_golden_gate TP1 2/2, non-graph TP2
2/2 (tp2_graph NCCL precapture failure is the pre-existing host-environmental
one, differential-tested), e2e_scheduler passed.

Signed-off-by: CAICAIIs <3360776475@qq.com>
@@ -169,6 +173,7 @@ impl Config35 {
// up to the tile-aligned multiple — the checkpoint's remaining rows are
// real trained embeddings and stay inside the mapped weight.
let aligned = effective_vocab.next_multiple_of(128);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep using the decodable vocabulary size when deciding whether a request takes the argmax path? This changes selection_vocab from 248077 to 248192, and select_batch passes that padded width from logits.hidden_dim into effectively_greedy.

For example, with temperature=1, top_k=-1, and top_p=4.03e-6, the old 1 / vocab threshold routes the request to argmax, while the new threshold sends it to the rejection sampler. That can change which token wins when the largest BF16 logits are tied. The existing tiny_top_p_routes_to_argmax_even_under_bf16_ties test covers why this distinction matters.

The -inf mask keeps padded IDs out of the result, but it doesn't preserve this routing decision. Could we use decodable_vocab for that decision while keeping the aligned width for the GEMM and buffer layout?

Review follow-up on pegainfer-project#1046 (FeathBow): tile-aligning the selection width moved
`select_batch`'s `effectively_greedy` threshold from 1/248077 to 1/248192,
because the routing read `logits.hidden_dim`. A request whose `top_p` sits
between those two values (e.g. 4.03e-6) used to take the deterministic argmax
path and would now fall to the rejection sampler, which can pick either member
of a bf16-tied top — the behavior `tiny_top_p_routes_to_argmax_even_under_bf16_ties`
exists to pin.

The GEMM width stays aligned; only the routing width moves. SampleScratch now
carries both: `vocab` is the (possibly aligned) arena width the buffers are
sized for, `selection_width` is the semantic bound the argmax-vs-sample decision
is measured against. `new` keeps its meaning (selection_width == vocab), and
qwen35 builds its scratch through `with_selection_width(selection_vocab,
decodable_vocab)` so a padded arena routes exactly the rows an unpadded one
would. Since every pad row is already suppressed to -inf, this restores the
pre-fix routing with no change to what can be selected, and a width wider than
the arena is refused at construction.

Tests: `padded_arena_width_does_not_suppress_the_argmax_routing` (512-column
arena, 256 decodable, top_p = 1/256 exactly on the boundary) asserts both the
sampler-path control and the argmax result; `selection_width_above_the_arena_is_rejected`
covers the fail-closed bound. Not run here: this checkout has no CUDA toolkit
or GPU, so the GPU test targets still need a run on the A100 box.

Signed-off-by: CAICAIIs <3360776475@qq.com>
Review pass over the two follow-up commits. Comments here were restating the
diff instead of recording a constraint a reader cannot recover from the code;
cut them to the one line that cannot be. Drops the `selection_width()` getter
and its assertions (the behaviour assertions already pin the routing), and the
construction-guard test, which covered a bounds check that is an assertion on a
programmer error rather than an input boundary.

Signed-off-by: CAICAIIs <3360776475@qq.com>
…nds on

The attribution doc recorded the alignment but not what keeps it from leaking
into token selection. Widening the GEMM is a throughput decision, so the pad
rows have to stay both unselectable (suppressed to -inf) and outside the
argmax-vs-sample routing width. Both failure modes are silent, because the pad
rows are real trained embeddings with plausible logits.

Signed-off-by: CAICAIIs <3360776475@qq.com>

@FeathBow FeathBow left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somethings I'd like to settle before merging. The suppression invariant is a call-site convention today and nothing in the suite would catch a fourth logits site that skips it, which matters because this PR's own doc says the failure is silent, btw some parts annotations outdated.

Comment thread pegainfer-qwen35/src/weights.rs Outdated
self.lm_head.as_ref().unwrap_or(&self.embed_tokens)
}

/// Force the tile-alignment pad rows of a logits buffer to -inf.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the aligned GEMM and the pad-row mask are two separate statements repeated at three sites (prefill.rs:120-128, batch_decode.rs:697-705, batch_decode.rs:776-784). A fourth logits site can call the GEMM and skip the mask, and nothing in the suite would catch it. The config unit tests only assert config values, the new sampler test builds its own arena, and hf_golden_gate compares HF top-K, which is only sensitive to the mask if HF's stored top-K lands inside 248077..248192.

That matters more than usual here because this commit's own doc says the failure is silent: the pad rows are trained embeddings with plausible logits, so a missed mask surfaces as an undecodable id on the wire and fed back into later decode steps, not as a crash.

Would you consider folding the two statements into one method so the invariant cannot be skipped?

Review follow-up on pegainfer-project#1046 (FeathBow): the aligned GEMM and the pad-row mask
were two statements repeated at three sites, so a fourth logits site could run
the GEMM and skip the mask with nothing in the suite catching it — and this
PR's own doc says that failure is silent, because the pad rows are trained
embeddings with plausible logits.

output_logits_into now owns both statements, and the three sites call it, so
the GEMM is no longer reachable without the mask. The re-exports the old split
needed are gone with it.

Signed-off-by: CAICAIIs <3360776475@qq.com>
The output-projection GEMM is now reached only through output_logits_into, so
the raw matrix accessor has no callers outside this module (the GEMM tuning
helper being the remaining one). Private stops a future logits site from
reaching the GEMM without the pad-row mask. Verified by compiling with the
tightened visibility.

Signed-off-by: CAICAIIs <3360776475@qq.com>
@FeathBow

Copy link
Copy Markdown
Collaborator

@codex review please

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 423d908a86

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@FeathBow FeathBow left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking but docs outdated.

Review follow-up on pegainfer-project#1046 (FeathBow: annotations outdated). Three claims did
not match the code or the evidence:

- The doc said hf_golden_gate validates output equivalence at TP1/TP2. What
  actually ran on this branch is TP1 2/2; the gate replays fixed teacher-forced
  sequences and compares logprobs, so it never selects a token, and the row
  logsumexp it scores is mask-invariant (exp(-inf) = 0). It cannot see the mask
  by construction, which is now stated instead of implied.
- The optimization ledger's LM-head rows were measured while selection spanned
  the full 248,320 rows; they are marked pre-alignment and pointed at the
  attribution doc.

Signed-off-by: CAICAIIs <3360776475@qq.com>
Review follow-up on pegainfer-project#1046 (FeathBow): the doc admitted nothing in the suite
would catch a logits site that ran the output projection without the mask. The
structural fix removes the sites, but there was still no test of the invariant
on the model itself.

This drives the real checkpoint through both logits paths — batch_prefill_logits
and batch_decode_graph — and asserts every column in decodable_vocab..selection_vocab
came out -inf while the decodable prefix did not. Mutation-checked: commenting
the mask out of output_logits_into fails it with "prefill: pad id 248077
survived selection (want -inf)".

Signed-off-by: CAICAIIs <3360776475@qq.com>
The paragraph said nothing in the suite would catch a logits site that skipped
the mask. That is no longer true, so point at the test instead of the gap.

Signed-off-by: CAICAIIs <3360776475@qq.com>
CI's `-D warnings` rejected six lints in the test added by the previous commit:
manual indexing in the pad loop, two useless f32 conversions (to_host already
returns f32), a float equality compare, a redundant closure, and a one-element
vec. The comparison now checks the value is negative infinity, which is what the
invariant means, and reports the offending value when it fails.

Signed-off-by: CAICAIIs <3360776475@qq.com>
@FeathBow
FeathBow merged commit 72cbbe8 into pegainfer-project:main Sep 16, 2026
17 checks passed
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.

2 participants