[Kernel] Reuse Qwen4Exp HC combine-norm for MTP input (#54687)
Signed-off-by: Canlin Guo <canlinguosdu@gmail.com> Signed-off-by: Canlin <canlinguosdu@gmail.com>
Canlin Guo committed
Sep 4, 2026 at 17:57 UTC
fd4a1512628ad17944095263c1fe89598710a3ce
5 files changed
+93
-40
tests/models/qwen4_exp/test_hc_ops.py
+33
@@ -68,6 +68,18 @@ def test_hc_combine() -> None:
68
torch.testing.assert_close(actual, expected.flatten(-2).to(torch.bfloat16))
69
70
71
+def test_hc_combine_unit_injection() -> None:
72
+ torch.manual_seed(0)
73
+ block_output = torch.randn(2, HIDDEN_SIZE, dtype=torch.bfloat16, device="cuda")
74
+ residual = torch.randn(2, HYPER_HIDDEN_SIZE, dtype=torch.bfloat16, device="cuda")
75
+
76
+ actual = hc_combine(residual, block_output, None, HC)
77
+ expected = residual.unflatten(-1, (HC, HIDDEN_SIZE))
78
+ expected = expected + block_output.unsqueeze(-2)
79
+
80
+ assert torch.equal(actual, expected.flatten(-2))
81
+
82
+
83
def test_hc_combine_norm() -> None:
84
torch.manual_seed(0)
85
block_output = torch.randn(2, HIDDEN_SIZE, dtype=torch.bfloat16, device="cuda")
@@ -92,3 +104,24 @@ def test_hc_combine_norm() -> None:
104
105
torch.testing.assert_close(actual, expected)
106
torch.testing.assert_close(actual_norm, expected_norm.to(torch.bfloat16))
107
+
108
+
109
+@pytest.mark.parametrize("num_tokens", [1, 17, 2048])
110
+def test_hc_combine_norm_unit_injection(num_tokens: int) -> None:
111
+ torch.manual_seed(0)
112
+ embedding = torch.randn(
113
+ num_tokens, HIDDEN_SIZE, dtype=torch.bfloat16, device="cuda"
114
+ )
115
+ hidden = torch.randn(
116
+ num_tokens, HC, HIDDEN_SIZE, dtype=torch.bfloat16, device="cuda"
117
+ )
118
+ weight = torch.randn(HYPER_HIDDEN_SIZE, dtype=torch.bfloat16, device="cuda")
119
+
120
+ actual, actual_norm = hc_combine_norm(
121
+ hidden.flatten(1), embedding, None, weight, EPS, HC
122
+ )
123
+
124
+ expected = (hidden + embedding.unsqueeze(1)).flatten(1)
125
+ expected_norm = grouped_gemma_rmsnorm(expected, weight, EPS, HC)
126
+ assert torch.equal(actual, expected)
127
+ torch.testing.assert_close(actual_norm, expected_norm)
vllm/models/qwen4_exp/nvidia/hyperconnection.py
+8
-6
@@ -52,9 +52,10 @@ class GatedResidual(nn.Module):
52
53
``combine_and_mix()`` runs the pre pipeline (grouped GemmaRMSNorm -> merged
54
low-rank down+inject GEMM -> silu -> up GEMM -> sigmoid -> gated mean
55
- over the HC streams). When passed a pending block output and an injection,
56
- it fuses their residual combine with the RMSNorm. Final mixers use
57
- ``use_combine=False`` and do not produce a new injection.
55
+ over the HC streams). When passed a pending block output, it fuses its
56
+ residual combine with the RMSNorm. A missing injection selects unit-weight
57
+ combine. Final mixers use ``use_combine=False`` and do not produce a new
58
+ injection.
59
60
Weights: the norm owns the grouped GemmaRMSNorm affine; the projections
61
are vLLM Linear modules (merged replicated linear for down+inject), so
@@ -153,13 +154,14 @@ class GatedResidual(nn.Module):
154
self,
155
hidden_states: torch.Tensor,
156
prev_block_output: torch.Tensor,
156
- prev_injection: torch.Tensor,
157
+ prev_injection: torch.Tensor | None,
158
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
159
"""Consume a pending combine, then prepare the next block input.
160
161
``hidden_states`` is the multi-stream state from before the pending
162
block's mix. Its combine with ``block_output`` is fused with this
162
- module's input RMSNorm.
163
+ module's input RMSNorm. A missing injection applies the block output
164
+ to every stream with unit weight.
165
"""
166
hidden_states, xn = hc_combine_norm(
167
hidden_states,
@@ -189,7 +191,7 @@ class GatedResidual(nn.Module):
191
self,
192
hidden_states: torch.Tensor,
193
block_output: torch.Tensor,
192
- injection: torch.Tensor,
194
+ injection: torch.Tensor | None,
195
) -> torch.Tensor:
196
return hc_combine(hidden_states, block_output, injection, self.hc_count)
197
vllm/models/qwen4_exp/nvidia/model.py
+4
-2
@@ -284,11 +284,13 @@ class Qwen4ExpDecoderLayer(nn.Module):
284
query_start_loc: torch.Tensor | None,
285
ngram_context: torch.Tensor | None,
286
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
287
+ if prev_block_output is None:
288
+ assert prev_injection is None
289
attn_hc = self.attn_hyper_connection
290
if self.ple is not None:
291
# PLE adds directly to the multi-stream state, so pending HC state
292
# must be materialized before the addition.
291
- if prev_block_output is not None and prev_injection is not None:
293
+ if prev_block_output is not None:
294
hidden_states = attn_hc.combine(
295
hidden_states, prev_block_output, prev_injection
296
)
@@ -304,7 +306,7 @@ class Qwen4ExpDecoderLayer(nn.Module):
306
)
307
308
# Fuse a pending combine with this HC module's mix when possible.
307
- if prev_block_output is not None and prev_injection is not None:
309
+ if prev_block_output is not None:
310
hidden_states, block_input, injection = attn_hc.combine_and_mix(
311
hidden_states, prev_block_output, prev_injection
312
)
vllm/models/qwen4_exp/nvidia/mtp.py
+3
-4
@@ -280,6 +280,7 @@ class Qwen4ExpMultiTokenPredictor(nn.Module):
280
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor] | IntermediateTensors:
281
hc_count = self.hc_count
282
hidden_size = self.hidden_size
283
+ prev_block_output: torch.Tensor | None = None
284
285
if get_pp_group().is_first_rank:
286
assert hidden_states is not None
@@ -300,10 +301,8 @@ class Qwen4ExpMultiTokenPredictor(nn.Module):
301
num_tokens, hc_count, hidden_size
302
)
303
hidden_states = self.fc_hidden(hidden_states)
303
- # Add the embedding residual to every branch, then fold back
304
- # to [T, hc_count*H] (HC outer, HS inner) for the HC decoder.
305
- hidden_states = inputs_embeds.unsqueeze(-2) + hidden_states
304
hidden_states = hidden_states.flatten(-2)
305
+ prev_block_output = inputs_embeds
306
else:
307
assert intermediate_tensors is not None
308
hidden_states = intermediate_tensors["hidden_states"]
@@ -312,7 +311,7 @@ class Qwen4ExpMultiTokenPredictor(nn.Module):
311
layer = self.layers[current_step_idx]
312
hidden_states, block_output, injection = layer(
313
hidden_states=hidden_states,
315
- prev_block_output=None,
314
+ prev_block_output=prev_block_output,
315
prev_injection=None,
316
positions=positions,
317
input_ids=None,
vllm/models/qwen4_exp/nvidia/ops/hc.py
+45
-28
@@ -215,14 +215,17 @@ def _hc_combine_kernel(
215
if launch_pdl:
216
tl.extra.cuda.gdc_wait()
217
218
- inj = tl.load(inj_ptr + row * stride_inj + offs_hc, mask_hc, other=0.0)
218
+ if inj_ptr is not None:
219
+ inj = tl.load(inj_ptr + row * stride_inj + offs_hc, mask_hc, other=0.0)
220
block = tl.load(block_ptr + row * stride_block + offs_inner, mask_inner, other=0.0)
221
res = tl.load(res_ptr + row * stride_res + offs, mask, other=0.0)
222
223
# Keeping HC as a broadcast dimension is faster here than four separate
224
# residual load/store sequences.
224
- inj = 2.0 * tl.sigmoid(inj.to(tl.float32) / HC)
225
- out = res.to(tl.float32) + block.to(tl.float32)[None, :] * inj[:, None]
225
+ if inj_ptr is not None:
226
+ inj = 2.0 * tl.sigmoid(inj.to(tl.float32) / HC)
227
+ block = block.to(tl.float32)[None, :] * inj[:, None]
228
+ out = res.to(tl.float32) + block.to(tl.float32)
229
230
if launch_pdl:
231
tl.extra.cuda.gdc_launch_dependents()
@@ -232,17 +235,20 @@ def _hc_combine_kernel(
235
def _hc_combine(
236
residual: torch.Tensor,
237
block_output: torch.Tensor,
235
- injection_logits: torch.Tensor,
238
+ injection_logits: torch.Tensor | None,
239
hc_count: int,
240
) -> torch.Tensor:
241
N, DIM = residual.shape
242
assert DIM % hc_count == 0
243
hc_dim = DIM // hc_count
244
assert block_output.shape == (N, hc_dim)
242
- assert injection_logits.shape == (N, hc_count)
245
assert residual.stride(1) == 1
246
assert block_output.stride(1) == 1
245
- assert injection_logits.stride(1) == 1
247
+ if injection_logits is not None:
248
+ assert injection_logits.shape == (N, hc_count)
249
+ assert injection_logits.stride(1) == 1
250
+
251
+ stride_injection = injection_logits.stride(0) if injection_logits is not None else 0
252
253
out = residual.new_empty(residual.shape)
254
BLOCK_SIZE = 512
@@ -253,7 +259,7 @@ def _hc_combine(
259
out,
260
block_output.stride(0),
261
residual.stride(0),
256
- injection_logits.stride(0),
262
+ stride_injection,
263
out.stride(0),
264
hc_dim,
265
hc_count,
@@ -287,8 +293,9 @@ def _hc_combine_norm_kernel(
293
NUM_TILES: tl.constexpr = triton.cdiv(HC_DIM, BLOCK_SIZE)
294
NUM_TILES_PAD: tl.constexpr = triton.next_power_of_2(NUM_TILES)
295
290
- row = tl.program_id(0)
291
- stream = tl.program_id(1)
296
+ pid = tl.program_id(0)
297
+ row = pid // HC
298
+ stream = pid % HC
299
offs_hc = tl.arange(0, HC_PAD)
300
mask_hc = offs_hc < HC
301
tile_ids = tl.arange(0, NUM_TILES_PAD)
@@ -305,27 +312,34 @@ def _hc_combine_norm_kernel(
312
# Start the uncached residual load first, then issue the other combine
313
# loads before consuming any of them.
314
res = tl.load(res_ptr + row * stride_res + offs, mask_inner, other=0.0)
308
- inj = tl.load(inj_ptr + row * stride_inj + offs_hc, mask_hc, other=0.0)
309
- block = tl.load(block_ptr + row * stride_block + offs_inner, mask_inner, other=0.0)
310
- inj = 2.0 * tl.sigmoid(inj.to(tl.float32) / HC)
311
- inj = tl.sum(tl.where(offs_hc == stream, inj, 0.0))
315
+ if inj_ptr is not None:
316
+ inj = tl.load(inj_ptr + row * stride_inj + offs_hc, mask_hc, other=0.0)
317
+ block = tl.load(
318
+ block_ptr + row * stride_block + offs_inner,
319
+ mask_inner,
320
+ other=0.0,
321
+ )
322
+ if inj_ptr is not None:
323
+ inj = 2.0 * tl.sigmoid(inj.to(tl.float32) / HC)
324
+ block = block.to(tl.float32) * tl.sum(tl.where(offs_hc == stream, inj, 0.0))
325
# Round the materialized combine result before normalization. This matches
326
# the unfused combine -> RMSNorm boundary.
314
- out = (res.to(tl.float32) + block.to(tl.float32) * inj).to(out_ptr.dtype.element_ty)
327
+ out = (res.to(tl.float32) + block.to(tl.float32)).to(out_ptr.dtype.element_ty)
328
+ if inj_ptr is None:
329
+ w = tl.load(w_ptr + w_offs, mask_inner, other=0.0)
330
tl.store(out_ptr + row * stride_out + offs, out, mask=mask_inner)
331
332
out = out.to(tl.float32)
318
- # Keep the two-axis reduction: flattening the padded tile is ~40% slower
319
- # at decode sizes.
333
sum_sq = tl.sum(tl.sum(out * out, axis=1), axis=0)
334
rrms = tl.rsqrt(sum_sq / HC_DIM + EPS)
335
336
if launch_pdl:
337
tl.extra.cuda.gdc_launch_dependents()
338
326
- # Loading the weight earlier helps decode but keeps the tile live across
327
- # the reduction and regresses larger batches, so defer it to the norm.
328
- w = tl.load(w_ptr + w_offs, mask_inner, other=0.0)
339
+ if inj_ptr is not None:
340
+ # Loading the weight earlier helps decode but keeps the tile live across
341
+ # the reduction and regresses larger batches, so defer it to the norm.
342
+ w = tl.load(w_ptr + w_offs, mask_inner, other=0.0)
343
y = out * rrms
344
y += y * w.to(tl.float32)
345
tl.store(y_ptr + row * stride_y + offs, y, mask_inner)
@@ -334,7 +348,7 @@ def _hc_combine_norm_kernel(
348
def _hc_combine_norm(
349
residual: torch.Tensor,
350
block_output: torch.Tensor,
337
- injection_logits: torch.Tensor,
351
+ injection_logits: torch.Tensor | None,
352
norm_weight: torch.Tensor,
353
eps: float,
354
hc_count: int,
@@ -343,17 +357,20 @@ def _hc_combine_norm(
357
assert DIM % hc_count == 0
358
hc_dim = DIM // hc_count
359
assert block_output.shape == (N, hc_dim)
346
- assert injection_logits.shape == (N, hc_count)
360
assert residual.stride(1) == 1
361
assert block_output.stride(1) == 1
349
- assert injection_logits.stride(1) == 1
362
+ if injection_logits is not None:
363
+ assert injection_logits.shape == (N, hc_count)
364
+ assert injection_logits.stride(1) == 1
365
assert norm_weight.is_contiguous()
366
assert norm_weight.numel() in (hc_dim, DIM)
367
368
+ stride_injection = injection_logits.stride(0) if injection_logits is not None else 0
369
+
370
out = residual.new_empty(residual.shape)
371
y = residual.new_empty(residual.shape)
372
BLOCK_SIZE = 512
356
- _hc_combine_norm_kernel[(N, hc_count)](
373
+ _hc_combine_norm_kernel[(N * hc_count,)](
374
block_output,
375
residual,
376
injection_logits,
@@ -362,7 +379,7 @@ def _hc_combine_norm(
379
y,
380
block_output.stride(0),
381
residual.stride(0),
365
- injection_logits.stride(0),
382
+ stride_injection,
383
out.stride(0),
384
y.stride(0),
385
hc_dim,
@@ -389,7 +406,7 @@ def _hc_gate_mix_fake(
406
def _hc_combine_fake(
407
residual: torch.Tensor,
408
block_output: torch.Tensor,
392
- injection_logits: torch.Tensor,
409
+ injection_logits: torch.Tensor | None,
410
hc_count: int,
411
) -> torch.Tensor:
412
del block_output, injection_logits, hc_count
@@ -399,7 +416,7 @@ def _hc_combine_fake(
416
def _hc_combine_norm_fake(
417
residual: torch.Tensor,
418
block_output: torch.Tensor,
402
- injection_logits: torch.Tensor,
419
+ injection_logits: torch.Tensor | None,
420
norm_weight: torch.Tensor,
421
eps: float,
422
hc_count: int,
@@ -452,7 +469,7 @@ def hc_gate_mix(x: torch.Tensor, gate: torch.Tensor, hc_count: int) -> torch.Ten
469
def hc_combine(
470
residual: torch.Tensor,
471
block_output: torch.Tensor,
455
- injection_logits: torch.Tensor,
472
+ injection_logits: torch.Tensor | None,
473
hc_count: int,
474
) -> torch.Tensor:
475
return torch.ops.vllm.qwen4_exp_hc_combine(
@@ -463,7 +480,7 @@ def hc_combine(
480
def hc_combine_norm(
481
residual: torch.Tensor,
482
block_output: torch.Tensor,
466
- injection_logits: torch.Tensor,
483
+ injection_logits: torch.Tensor | None,
484
norm_weight: torch.Tensor,
485
eps: float,
486
hc_count: int,