Blog / Engineering

Kernel Surgery on a Production Qwen Deployment

Kernel Surgery on a Production Qwen Deployment

Our production Qwen3.6-35B-A3B deployment is a dense stack of specializations: a hybrid model that interleaves 30 Gated DeltaNet (linear attention) layers with 10 full-attention layers, all 40 carrying a fine-grained MoE (256 experts, top-8, intermediate 512); FP8 weights with FP8 KV cache; block-parallel speculative decoding; a hierarchical prefix cache; and CUDA graphs over both the decode ladder and the torch.compiled extend path. It serves 32–64k-token agent prompts on single H100s at roughly 24,000 total tokens/second per GPU.

We spent two days doing a full profiling pass over this deployment with one question: where does the GPU time actually go, and what can kernel-level surgery reclaim? The answer turned out to be a story in three acts: a memory-traffic bug hiding inside kernel glue that cost more than the kernel it wrapped, a MoE runner unlock that required fixing three separate latent bugs, and (the part we did not expect) a one-character upstream bug that had been silently deleting one expert's output from every layer, invisible until our changes revived the dead code path it lived on.

This article walks the full pass: the profiling method, the kernel tables, both optimizations with their same-host A/B numbers, and the systematic bug hunt. The numbers are reported with their variance, including the ones that did not go our way.

TL;DR

  • Steady-state profiling at production concurrency showed the top costs are MoE expert GEMMs (22.4%), full-attention prefill (15.2%), and, surprisingly, 13.6% of GPU time in pure memory movement around our fastest kernel: transpose-and-materialize glue whose output was 99.6% discarded.
  • Fix #1 (lean glue): return the transposes as strided views and let the one consumed row fuse into its gather. Same-host serial A/B, twice: 64k-token cold prefill −11–12%, concurrent throughput +4.7% and +17.8%, mean TTFT −6% to −20%, outputs bit-identical.
  • Fix #2 (DeepGEMM MoE on TP1): the runner's standard-dispatch path is dead code upstream, and reviving it surfaced three bugs: a kernel that assumed at least one thread per expert (fails for fine-grained MoE), a masked-layout scratch tensor that scales with tokens × experts (16.25 GiB at our 16k bucket), and a combine kernel that silently dropped expert 0's contribution via an expert_id > 0 guard that should have been >= 0.
  • The expert-0 bug cost 20 GSM8K points (0.46 → 0.26) while producing perfectly coherent text. We localized it with a chain of one-boot discriminator experiments and three tensor-level probes: both GEMMs were bit-faithful (0.0017 relative error), the per-slot outputs were correct (0.015), and the error appeared only after the combine (0.061). After the fix, the DeepGEMM route matches the ideal quantized chain exactly as well as the Triton baseline (0.0146).
  • Verdict: lean glue shipped to production. The DeepGEMM MoE runner is accuracy-clean and 1.26× faster at our 16k-token chunk size in isolation, but end-to-end throughput-neutral until small-batch dispatch improves, so it stays off. The expert-0 fix is being upstreamed regardless.

Profiling a deployment, not a model

Kernel tables lie when the capture does not look like production. Our harness boots the verbatim production configuration (same flags, same caches, same speculative block size) on one H100, and takes three kinds of captures:

  • Stage-separated traces (prefill 32,768→1 token; decode 1→2,048 tokens), matching the shape of our agent workload rather than a synthetic default.
  • A graph-off "mapping" boot: with CUDA graphs on, every kernel in a trace attributes to cudaGraphLaunch and nothing else. A second boot with graphs disabled recovers the kernel → Python-callsite mapping, which the analyzer joins back onto the graph-on trace.
  • A steady-state concurrent window: a 40-request 32k/2k benchmark runs in the background, and the profiler arms only once ≥12 requests are in flight, capturing 32 scheduler steps of true steady state.

Even the harness taught us something: our first concurrent capture recorded an empty window because the readiness gauge polled a metrics endpoint the production config does not expose: the poll silently read 0.0 against a 404, armed the profiler after the benchmark had already finished, and produced a trace of an idle GPU. Every gauge in a gate needs a liveness check of its own.

Where the time goes

At steady state under the production workload (19 concurrent requests, 32k in / 2k out), the GPU is ~97% occupied after ramp, so kernel share is throughput. The mix:

GPU-time share, steady state (conc 19, 32k/2k, H100) MoE expert GEMMs (triton) 22.4% full attention (FA3, FP8 KV) 15.2% FlashQLA glue copies 13.6%: pure memory movement GDN prefill chain (4 kernels) 14.7% dense FP8 GEMMs (DeepGEMM) 11.5% norms 5.0% activation FP8 quant 3.6% everything else 14.0% The 32k/2k agent workload is ~94% prefill tokens; with speculative accept length ≈ 5, every decode-side kernel (draft model, verify, LM head) lands below 1% each. On this workload, prefill kernels are the whole game.
Figure 1: kernel-family share of GPU time in the steady-state window. Decode-side kernels do not clear 1% at this workload mix, worth knowing before spending a week optimizing a verify kernel.

Two findings framed everything after. First, single-request decode is 75% GPU-idle: speculative decoding at batch 1 is host-orchestration-bound, so no amount of kernel work moves it; that is a scheduler problem for another day. Second, the third-largest GPU consumer was not a compute kernel at all. It was at::native::elementwise_kernel doing direct_copy, plain tensor copies, attributed to the wrapper around our GDN prefill kernel.

Optimization 1: the glue that cost more than the kernel

Our GDN prefill runs on FlashQLA, a TileLang chunked kernel that is 2–2.9× faster than the Triton alternative on Qwen's shapes. But FlashQLA uses a [K, V] state layout while our memory pool stores [V, K], so the integration wrapped it in adapters: gather the state, transpose, run the kernel, transpose back, materialize, scatter. The profile said this glue cost 12.9% of prefill GPU time, twice the 6.5% of the kernel it wraps.

The dominant term was a tensor most of which nobody reads. For prefix caching, the kernel emits its per-chunk recurrent states: for a 16k-token forward, that is 256 chunk states of shape , about 0.5 GB, which the wrapper transposed and materialized with .contiguous() every layer, every chunk. The consumer, the radix-cache state tracker, then index-selects at most one chunk state per request:

before: materialize everything, keep one FlashQLA kernel h: 256 chunk states transpose(-1,-2).contiguous() ~0.5 GB read+write / forward / layer tracker: h[track_src] keeps ≤ 1 of 256 states 99.6% wasted after: transpose as a view, pay only for what is read FlashQLA kernel h: 256 chunk states transpose(-1,-2): strided view 0 bytes moved tracker gather fuses the transpose: ~2 MB touched Every downstream consumer (index-select, dtype cast, pool scatter) already accepts strided tensors, so the change is numerically identical by construction: same values, same casts, no materialization.
Figure 2: the lean-glue fix. The transpose becomes free; the one consumed row pays for its own transposition inside the gather it was already doing.

Because nothing downstream requires contiguity, the fix is to return the transposes as strided views. We validated it with two independent same-host serial A/B runs (boot baseline, measure, shut down, boot patched, measure; same GPU, because identical configs on different hosts varied by ±10% in our runs, which would drown the effect):

MetricBaselineLean glueΔ
64k cold prefill, median (run 1)1.622 s1.440 s−11.2%
64k cold prefill, median (run 2)1.605 s1.415 s−11.8%
Concurrent throughput (run 1)24,520 tok/s28,878 tok/s+17.8%
Concurrent throughput (run 2)27,016 tok/s28,275 tok/s+4.7%
Mean TTFT (runs 1 / 2)3,340 / 2,820 ms2,658 / 2,657 ms−20.4% / −5.8%
Temp-0 outputs vs baselinebit-identical, 24/24 probes

The 64k prefill number is the one we trust most: it replicated within 0.6 points across runs. The concurrency benchmark's spread (+4.7% to +17.8%) is mostly benchmark variance, not effect variance; the honest claim is "+5–18% depending on load mix, +11–12% on long-prompt prefill." At 32k the effect disappears into fixed host overhead (~±0). This fix is now enabled in the production preset.

Optimization 2: the 22% nobody routes to the fast GEMM

The single largest kernel family, at 22.4%, is the routed-expert GEMMs, running on Triton's fused_moe. Our stack ships DeepGEMM, whose grouped FP8 GEMMs are excellent, but the MoE runner's auto selection only engages DeepGEMM for expert-parallel deployments; on single-GPU tensor parallelism it always picks Triton. Before building anything, we measured whether the switch would even pay, with a microbenchmark of the full pipelines (quantization, scatter, both GEMMs, activation, combine) at production shapes:

MoE layer time, E=256 / top-8 / H=2048 / I=512 (H100, lower is better) 2,048 tokens 0.46 0.57 triton wins (padding tax) 8,192 tokens 1.16 1.02 ≈ even 16,384 tokens (prod chunk) 2.10 1.66 deepgemm 1.26× triton fused_moe (ms) deepgemm contiguous (ms)
Figure 3: the crossover. DeepGEMM's contiguous grouped layout pads every expert's segment to a 128-row multiple; with 256 small experts, that padding dominates below ~4k tokens and amortizes above. Outputs cross-checked at FP8 parity throughout.

A 1.26× win on a 22% share is worth roughly 4–6% of end-to-end prefill, worth building, with routing by forward size baked in from the start. Then came the three bugs.

Bug 1: one thread per expert. DeepGEMM's masked-layout activation kernel launches intermediate/8 threads per block and used one thread per expert to scan the per-expert token counts. Qwen3.6's fine-grained experts give 64 threads against 256 experts, and a host-side check refuses to launch. We rewrote the scan to walk experts in blockDim-sized chunks with a carried prefix, validated against an fp32 reference across four shapes including a worst-case eight-chunk configuration.

Bug 2: scratch that scales with tokens × experts. The masked layout pads every expert to the full token count: at our 16,384-token compile bucket that is a bf16 tensor of 16.25 GiB inside CUDA-graph capture, an instant OOM. Worse, every sub-threshold bucket also pins masked scratch into the shared capture pool at ~1.2 MB per token, so even a 3840-token bucket holds ~4.5 GB hostage. The contiguous route we added caps its scratch with a static worst-case bound,

rows (every expert padded to the next 128-block at most once), with unused rows masked out of the GEMM by a −1 group index, capture-safe because every shape is static per bucket. Routing all buckets ≥1024 tokens through the contiguous layout confines the masked route to the decode graphs (≤720 tokens at our concurrency cap), where its scratch is bounded and its layout is the right design. The scatter workspace is allocated once and sliced per bucket, so nothing transient inflates the capture pools.

With those two fixed, the server booted, captured all 74 piecewise buckets plus the decode ladder, and served. Then the accuracy gate failed.

The hunt: 20 GSM8K points, perfectly coherent text

Same-config GSM8K baseline: 0.465. The DeepGEMM arms: 0.245–0.29, with fluent, plausible outputs and no error anywhere. A drop like that with coherent text means something systematic and mid-sized is wrong in the math. We localized it with a chain of experiments, each one boot or one probe, each designed to cut the hypothesis space in half:

serving, graphs on: GSM8K 0.28 (base 0.46) hypothesis eliminated eager (no graphs, no compile): 0.26 → not capture/replay force masked-only 0.245 / contiguous-only 0.26 → shared machinery, not one route swap in legacy activation kernel: 0.29 → activation kernels innocent real checkpoint weights, fp32 ref: dg 0.072 vs triton 0.041 → systematic excess error exists GEMM-level probe: both grouped GEMMs 0.0017 → GEMMs and scales exact stage probe: per-slot rows 0.015, combined 0.061 → the combine kernel if expert_id > 0: # drops expert 0, should be >= 0 (invalid sentinel is −1)
Figure 4: the discriminator chain. Each step is one boot or one GPU probe; the final probe compares against an "ideal quantized chain" (the same quantizations with exact fp32 math), so a correct implementation has nowhere to hide.

The combine kernel, the weighted sum that folds each token's top-8 expert outputs back together, guarded each slot with expert_id > 0 instead of >= 0. Every token routed to expert 0 simply lost that expert's entire contribution. With 256 experts and top-8 routing, that is ~3% of all token-slots, silently zeroed, in all 40 layers. The model stays fluent, because a residual stream shrugs off a missing summand, but it reasons measurably worse. And because the kernel's only caller is the standard-dispatch DeepGEMM path that upstream never exercises on TP1, the bug was unobservable until our contiguous route revived the path. The sibling kernels in the same file guard correctly; this one had also been accumulating in bf16 where every sibling accumulates in fp32, so we fixed both.

relative error vs ideal quantized chain (real layer-10 weights, lower is better) grouped GEMM 1 (w13)0.0017 grouped GEMM 2 (w2)0.0017 per-slot expert outputs0.0150 combined (before fix)0.0607: created by a weighted SUM combined (after fix)0.0146 triton baseline0.0146, identical A weighted average of correct rows can only reduce error, so 0.015-in / 0.061-out convicted the combine before we read a line of its code.
Figure 5: the stage-level probe, before and after. Post-fix, the DeepGEMM route matches the ideal quantized chain to exactly the Triton baseline's precision.

End-to-end confirmation: with the fix, the DeepGEMM arms scored GSM8K 0.505 and 0.555 against the 0.465 baseline. The gate flipped from a 20-point deficit to at-or-above parity (the positive deltas are within ~2.5σ of the 200-question noise; we claim "no longer worse," not "better," though the combine's new fp32 accumulation plausibly helps).

Results, and what actually shipped

The final same-host gate, all three configurations serially on one H100:

ConfigurationGSM8K-20064k cold prefillMean TTFTConcurrent throughput
baseline (Triton MoE)0.4651.371 s2,815 ms23,976 tok/s
+ DeepGEMM MoE0.5051.389 s (−1.3%)2,675 ms (−5.0%)23,119 (−3.6%)
+ lean glue + DeepGEMM MoE0.5551.289 s (−6.0%)2,445 ms (−13.1%)23,500 (−2.0%)

Our shipping decisions follow the numbers rather than the effort invested:

  • Lean glue: shipped. Replicated, bit-identical, and the largest validated win of the pass.
  • DeepGEMM MoE: built, correct, held. The 1.26× kernel-level win at 16k tokens gets diluted by the neutral 8k and losing sub-4k buckets, and the end-to-end throughput deltas sit inside benchmark noise (identical baseline configs spanned 23,976–27,490 tok/s across the day; any <10% single-run bench delta on this stack is weather, not climate). It goes to production when a mixed dispatch (Triton kernels for small forwards inside the DeepGEMM runner) lets the large-bucket win survive contact with the full workload.
  • The expert-0 fix: upstreaming regardless. It silently corrupts any future TP1 user of this path, and it costs one character to fix.

What we took away

  • Profile the glue, not just the kernels. The fastest kernel in our stack was wrapped in adapters costing 2× its own runtime. Integration seams (layout adapters, .contiguous() calls, dtype casts) deserve their own line in the kernel table.
  • Dead code paths downstream are unexercised code paths upstream. Enabling a configuration nobody runs means becoming its first tester. Budget for that: three latent bugs in one unlock is not bad luck, it is the expected density.
  • Accuracy gates on every perf change, no exceptions. The expert-0 bug produced fluent text at −20 GSM8K points. A throughput-only gate would have shipped it.
  • Discriminators beat debuggers for numeric bugs. Seven binary experiments took us from "accuracy collapsed somewhere in serving" to one guilty kernel; the ideal-quantized-chain reference then convicted a single stage by arithmetic: a weighted average of correct inputs cannot create error.
  • Same-host serial A/B or it did not happen. With ±10% host-to-host variance, parallel arms on different machines measure the hardware lottery, not the patch.

FAQ

Why was the glue so expensive relative to the kernel? Prefill GEMM-heavy kernels run near the GPU's compute roof; a plain copy runs at memory bandwidth on bytes that do no work. Moving ~1 GB per layer-forward of state the consumer discards costs as much as computing attention over thousands of tokens.

Couldn't the expert-0 bug be caught by unit tests? Ours passed: the kernel matched its reference on the routes' components. The guard bug lived in a combine kernel whose test coverage upstream is implicit in end-to-end runs that never execute it on TP1. The stage-level probe against an independent fp32 chain is the test that caught it; we've kept all three probes as permanent gate entrypoints.

Why does a missing expert only cost 20 points instead of destroying the model? The MoE output is added to a residual stream, and the router's top-8 weights spread mass across experts; losing one of eight summands ~3% of the time degrades the computation smoothly. That is precisely what makes this bug class dangerous: there is no crash, only a model that is quietly worse.

Is the 1.26× DeepGEMM win real if end-to-end is neutral? Both are real. The microbenchmark isolates the MoE layer at one forward size; production runs a distribution of forward sizes plus everything else. A 26% win on 22% of GPU time at the dominant bucket is ~4–6% end-to-end ceiling, and the sub-4k buckets currently give a chunk of it back. Closing that gap is dispatch engineering, not GEMM engineering.

What hardware and stack does this apply to? Single-H100 serving of Qwen3.6-35B-A3B (FP8 block-quantized weights, FP8 KV) on our SGLang-based runtime. The expert-0 fix and the chunked expert-scan fix apply to any fine-grained MoE served through the DeepGEMM standard-dispatch path; the lean-glue pattern applies anywhere a layout adapter materializes more than its consumer reads.

Free tools from Netra

All of these run in your browser. No signup, and your data is not uploaded to us.

References