[Kernel][HY V4] Add Triton iHC pre/post fallback (#55059)

Signed-off-by: linitra24 <renshuang.zhou@daocloud.io> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>

linitra24 committed Sep 5, 2026 at 23:12 UTC 7985444339e2ad7e249b88a50081e16e34637dfd
3 files changed +531 -3
benchmarks/kernels/benchmark_hy_v4_ihc.py new
+199
@@ -0,0 +1,199 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Benchmark the HY V4 Triton iHC pre/post kernels against eager PyTorch."""
4 +
5 +import os
6 +import subprocess
7 +from functools import partial
8 +from importlib.metadata import version
9 +from statistics import median
10 +
11 +import torch
12 +import torch.nn.functional as F
13 +
14 +import vllm
15 +from vllm.models.hy_v4.nvidia.triton_ihc import (
16 + triton_ihc_post,
17 + triton_ihc_pre,
18 +)
19 +from vllm.triton_utils import triton
20 +from vllm.utils.argparse_utils import FlexibleArgumentParser
21 +
22 +
23 +def eager_pre(
24 + x: torch.Tensor,
25 + weight: torch.Tensor,
26 + scale: torch.Tensor,
27 + base: torch.Tensor,
28 + magnitude: float,
29 + hc_eps: float,
30 + norm_eps: float,
31 +) -> tuple[torch.Tensor, torch.Tensor]:
32 + num_tokens, hc_mult, hidden_size = x.shape
33 + x_flat = x.flatten(1).float()
34 + reciprocal_rms = torch.rsqrt(x_flat.square().mean(-1, keepdim=True) + norm_eps)
35 + mixes = F.linear(x_flat, weight) * reciprocal_rms
36 + pre = torch.sigmoid(mixes[:, :hc_mult] * scale[0] + base[:hc_mult])
37 + post = torch.sigmoid(mixes[:, hc_mult:] * scale[1] + base[hc_mult:])
38 + pre = pre + hc_eps
39 + post = magnitude * post + hc_eps
40 + output = torch.sum(pre.unsqueeze(-1) * x.float(), dim=1)
41 + return output.to(x.dtype).reshape(num_tokens, hidden_size), post
42 +
43 +
44 +def eager_post(
45 + x: torch.Tensor,
46 + residual: torch.Tensor,
47 + post: torch.Tensor,
48 +) -> torch.Tensor:
49 + return (post.float().unsqueeze(-1) * x.float().unsqueeze(-2) + residual.float()).to(
50 + x.dtype
51 + )
52 +
53 +
54 +def _timer(method: str):
55 + if method == "cupti":
56 + from flashinfer.testing import bench_gpu_time_with_cupti
57 +
58 + return partial(
59 + bench_gpu_time_with_cupti,
60 + use_cuda_graph=True,
61 + cold_l2_cache=True,
62 + )
63 + if method == "cudagraph":
64 + from flashinfer.testing import bench_gpu_time_with_cudagraph
65 +
66 + return partial(bench_gpu_time_with_cudagraph, cold_l2_cache=True)
67 + raise ValueError(f"unknown timing method: {method}")
68 +
69 +
70 +@torch.inference_mode()
71 +def run_benchmark(
72 + token_counts: list[int],
73 + hidden_size: int,
74 + dtype: torch.dtype,
75 + method: str,
76 +) -> None:
77 + hc_mult = 4
78 + device = torch.device("cuda")
79 + torch.manual_seed(0)
80 + weight = torch.randn(
81 + 2 * hc_mult,
82 + hc_mult * hidden_size,
83 + device=device,
84 + dtype=torch.float32,
85 + )
86 + scale = torch.randn(2, device=device, dtype=torch.float32) * 0.01
87 + base = torch.randn(2 * hc_mult, device=device, dtype=torch.float32)
88 + timer = _timer(method)
89 +
90 + properties = torch.cuda.get_device_properties(device)
91 + git_branch = subprocess.run(
92 + ["git", "branch", "--show-current"],
93 + check=False,
94 + capture_output=True,
95 + text=True,
96 + ).stdout.strip()
97 + git_commit = subprocess.run(
98 + ["git", "rev-parse", "--short", "HEAD"],
99 + check=False,
100 + capture_output=True,
101 + text=True,
102 + ).stdout.strip()
103 + print(f"device: {properties.name}")
104 + print(f"branch: {git_branch}; commit: {git_commit}")
105 + print(
106 + f"vllm: {vllm.__version__}; torch: {torch.__version__}; "
107 + f"CUDA: {torch.version.cuda}"
108 + )
109 + print(
110 + f"triton: {triton.__version__}; flashinfer: {version('flashinfer-python')}; "
111 + f"dtype: {dtype}; method: {method}; cache: cold L2"
112 + )
113 + print(
114 + "env: VLLM_ENABLE_HPC_OPS="
115 + f"{os.getenv('VLLM_ENABLE_HPC_OPS', '<unset>')}; "
116 + "VLLM_BATCH_INVARIANT="
117 + f"{os.getenv('VLLM_BATCH_INVARIANT', '<unset>')}"
118 + )
119 + print(f"hidden_size: {hidden_size}; hc_mult: {hc_mult}")
120 + print(
121 + f"{'tokens':>8} {'op':>6} {'eager (us)':>12} "
122 + f"{'triton (us)':>12} {'speedup':>9} {'GiB':>8} "
123 + f"{'eager GB/s':>12} {'triton GB/s':>13}"
124 + )
125 +
126 + for num_tokens in token_counts:
127 + x = torch.randn(
128 + num_tokens,
129 + hc_mult,
130 + hidden_size,
131 + device=device,
132 + dtype=dtype,
133 + )
134 + block_output = torch.randn(num_tokens, hidden_size, device=device, dtype=dtype)
135 + residual = torch.randn_like(x)
136 + eager_output, post = eager_pre(x, weight, scale, base, 2.0, 1e-6, 1e-5)
137 + triton_output, triton_post = triton_ihc_pre(
138 + x, weight, scale, base, 2.0, 1e-6, 1e-5
139 + )
140 + torch.testing.assert_close(triton_output, eager_output, atol=2e-2, rtol=1e-2)
141 + torch.testing.assert_close(triton_post, post, atol=2e-5, rtol=1e-5)
142 + torch.testing.assert_close(
143 + triton_ihc_post(block_output, residual, post),
144 + eager_post(block_output, residual, post),
145 + atol=0,
146 + rtol=0,
147 + )
148 +
149 + benchmarks = (
150 + (
151 + "pre",
152 + partial(eager_pre, x, weight, scale, base, 2.0, 1e-6, 1e-5),
153 + partial(triton_ihc_pre, x, weight, scale, base, 2.0, 1e-6, 1e-5),
154 + x.nbytes
155 + + weight.nbytes
156 + + scale.nbytes
157 + + base.nbytes
158 + + eager_output.nbytes
159 + + post.nbytes,
160 + ),
161 + (
162 + "post",
163 + partial(eager_post, block_output, residual, post),
164 + partial(triton_ihc_post, block_output, residual, post),
165 + block_output.nbytes + residual.nbytes + post.nbytes + residual.nbytes,
166 + ),
167 + )
168 + for op_name, eager_fn, triton_fn, logical_bytes in benchmarks:
169 + eager_us = median(timer(eager_fn)) * 1e3
170 + triton_us = median(timer(triton_fn)) * 1e3
171 + speedup = eager_us / triton_us
172 + logical_gib = logical_bytes / 2**30
173 + eager_gbps = logical_bytes / eager_us / 1e3
174 + triton_gbps = logical_bytes / triton_us / 1e3
175 + print(
176 + f"{num_tokens:>8} {op_name:>6} {eager_us:>12.1f} "
177 + f"{triton_us:>12.1f} {speedup:>8.2f}x {logical_gib:>8.3f} "
178 + f"{eager_gbps:>12.1f} {triton_gbps:>13.1f}"
179 + )
180 +
181 +
182 +if __name__ == "__main__":
183 + parser = FlexibleArgumentParser(description=__doc__)
184 + parser.add_argument(
185 + "--token-counts",
186 + type=int,
187 + nargs="+",
188 + default=[1, 2, 4, 8, 16, 64, 256, 1024, 4096, 8192],
189 + )
190 + parser.add_argument("--hidden-size", type=int, choices=[4096, 6144], default=6144)
191 + parser.add_argument("--dtype", choices=["float16", "bfloat16"], default="bfloat16")
192 + parser.add_argument("--method", choices=["cupti", "cudagraph"], default="cupti")
193 + args = parser.parse_args()
194 + run_benchmark(
195 + args.token_counts,
196 + args.hidden_size,
197 + getattr(torch, args.dtype),
198 + args.method,
199 + )
vllm/models/hy_v4/nvidia/hc.py
+19 -3
@@ -9,9 +9,8 @@ scatters the result back over the channels (``HYV4HCPostLayer``). The final
9 ``HYV4HCHeadLayer`` merges the channels before the model's output norm.
10
11 NOTE: Each of the three steps has an optional single-kernel HPC replacement
12 -(``HpcIHCPre`` / ``HpcIHCPost`` / ``HpcIHCHead``). They are only constructed
13 -when the hpc package is installed, ``VLLM_ENABLE_HPC_OPS=1`` and the shape /
14 -device constraints hold; otherwise the eager path below runs unchanged.
12 +(``HpcIHCPre`` / ``HpcIHCPost`` / ``HpcIHCHead``). Pre and post fall back to
13 +in-tree Triton kernels on CUDA when HPC is unavailable, then to the eager path.
14 TODO: port the cross-layer post+pre fusion (``HpcIHCPostPre``) as well; it
15 requires restructuring the decoder-layer forward scheduling.
16 """
@@ -22,6 +21,11 @@ from transformers import PretrainedConfig
21
22 from vllm.model_executor.layers.hpc import HpcIHCHead, HpcIHCPost, HpcIHCPre
23 from vllm.model_executor.layers.linear import ReplicatedLinear
24 +from vllm.models.hy_v4.nvidia.triton_ihc import (
25 + triton_ihc_post,
26 + triton_ihc_pre,
27 + triton_ihc_supported,
28 +)
29
30
31 class HYV4HCPreLayer(nn.Module):
@@ -106,6 +110,16 @@ class HYV4HCPreLayer(nn.Module):
110 """
111 if self.hpc_op is not None:
112 return self.hpc_op(x)
113 + if triton_ihc_supported(x):
114 + return triton_ihc_pre(
115 + x,
116 + self.hc_fn.weight,
117 + self.hc_scale,
118 + self.hc_base,
119 + self.magnitude,
120 + self.hc_eps,
121 + self.layernorm_epsilon,
122 + )
123
124 shape = x.size() # [num_tokens, hc, d]
125 hc = self.hc_mult
@@ -175,6 +189,8 @@ class HYV4HCPostLayer(nn.Module):
189 """
190 if self.hpc_op is not None:
191 return self.hpc_op(x, residual, post)
192 + if triton_ihc_supported(x):
193 + return triton_ihc_post(x, residual, post)
194
195 dtype = x.dtype
196 x = x.float()
vllm/models/hy_v4/nvidia/triton_ihc.py new
+313
@@ -0,0 +1,313 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Triton iHC pre/post kernels for HY V4.
4 +
5 +Adapted from the SGLang HY V4 implementation:
6 +https://github.com/sgl-project/sglang/pull/36805
7 +"""
8 +
9 +import torch
10 +
11 +from vllm import envs
12 +from vllm.platforms import current_platform
13 +from vllm.triton_utils import HAS_TRITON, tl, triton
14 +
15 +_BLOCK_K = 1024
16 +_BLOCK_D = 1024
17 +
18 +
19 +def triton_ihc_supported(x: torch.Tensor) -> bool:
20 + """Return whether the in-tree Triton path can run for this input."""
21 + return (
22 + HAS_TRITON
23 + and current_platform.is_cuda()
24 + and x.is_cuda
25 + and x.dtype in (torch.float16, torch.bfloat16)
26 + and not envs.VLLM_BATCH_INVARIANT
27 + )
28 +
29 +
30 +@triton.jit
31 +def _ihc_pre_stage1(
32 + x_ptr,
33 + weight_ptr,
34 + partial_ptr,
35 + K_TOTAL: tl.constexpr,
36 + HC_MULT: tl.constexpr,
37 + HC_POW2: tl.constexpr,
38 + NUM_SPLITS: tl.constexpr,
39 + BLOCK_K: tl.constexpr,
40 + PARTIAL_STRIDE: tl.constexpr,
41 +):
42 + token_idx = tl.program_id(0).to(tl.int64)
43 + split_idx = tl.program_id(1)
44 + hc_idx = tl.arange(0, HC_POW2)
45 + hc_mask = hc_idx < HC_MULT
46 +
47 + k_offsets = split_idx * BLOCK_K + tl.arange(0, BLOCK_K)
48 + k_mask = k_offsets < K_TOTAL
49 + x = tl.load(
50 + x_ptr + token_idx * K_TOTAL + k_offsets,
51 + mask=k_mask,
52 + other=0.0,
53 + ).to(tl.float32)
54 + sum_squares = tl.sum(x * x, axis=0)
55 +
56 + weight_offsets = hc_idx[:, None] * K_TOTAL + k_offsets[None, :]
57 + weight_mask = hc_mask[:, None] & k_mask[None, :]
58 + pre_mix = tl.sum(
59 + tl.load(weight_ptr + weight_offsets, mask=weight_mask, other=0.0) * x[None, :],
60 + axis=1,
61 + )
62 + post_mix = tl.sum(
63 + tl.load(
64 + weight_ptr + HC_MULT * K_TOTAL + weight_offsets,
65 + mask=weight_mask,
66 + other=0.0,
67 + )
68 + * x[None, :],
69 + axis=1,
70 + )
71 +
72 + partial = partial_ptr + (token_idx * NUM_SPLITS + split_idx) * PARTIAL_STRIDE
73 + tl.store(partial, sum_squares)
74 + tl.store(partial + 1 + hc_idx, pre_mix, mask=hc_mask)
75 + tl.store(partial + 1 + HC_POW2 + hc_idx, post_mix, mask=hc_mask)
76 +
77 +
78 +@triton.jit
79 +def _ihc_pre_stage2(
80 + x_ptr,
81 + partial_ptr,
82 + scale_ptr,
83 + base_ptr,
84 + output_ptr,
85 + post_ptr,
86 + HIDDEN_SIZE: tl.constexpr,
87 + K_TOTAL: tl.constexpr,
88 + HC_MULT: tl.constexpr,
89 + HC_POW2: tl.constexpr,
90 + NUM_SPLITS: tl.constexpr,
91 + PARTIAL_STRIDE: tl.constexpr,
92 + BLOCK_D: tl.constexpr,
93 + MAGNITUDE: tl.constexpr,
94 + NORM_EPS: tl.constexpr,
95 + HC_EPS: tl.constexpr,
96 +):
97 + token_idx = tl.program_id(0).to(tl.int64)
98 + hidden_block_idx = tl.program_id(1)
99 + hc_idx = tl.arange(0, HC_POW2)
100 + hc_mask = hc_idx < HC_MULT
101 +
102 + partial_row = partial_ptr + token_idx * NUM_SPLITS * PARTIAL_STRIDE
103 + sum_squares = tl.zeros((), dtype=tl.float32)
104 + pre_mix = tl.zeros((HC_POW2,), dtype=tl.float32)
105 + post_mix = tl.zeros((HC_POW2,), dtype=tl.float32)
106 + for split_idx in tl.static_range(NUM_SPLITS):
107 + partial = partial_row + split_idx * PARTIAL_STRIDE
108 + sum_squares += tl.load(partial)
109 + pre_mix += tl.load(partial + 1 + hc_idx, mask=hc_mask, other=0.0)
110 + post_mix += tl.load(
111 + partial + 1 + HC_POW2 + hc_idx,
112 + mask=hc_mask,
113 + other=0.0,
114 + )
115 +
116 + reciprocal_rms = tl.rsqrt(sum_squares / K_TOTAL + NORM_EPS)
117 + pre_scale = tl.load(scale_ptr)
118 + post_scale = tl.load(scale_ptr + 1)
119 + pre_base = tl.load(base_ptr + hc_idx, mask=hc_mask, other=0.0)
120 + post_base = tl.load(base_ptr + HC_MULT + hc_idx, mask=hc_mask, other=0.0)
121 + pre = tl.sigmoid(pre_mix * reciprocal_rms * pre_scale + pre_base) + HC_EPS
122 +
123 + if hidden_block_idx == 0:
124 + post = (
125 + MAGNITUDE * tl.sigmoid(post_mix * reciprocal_rms * post_scale + post_base)
126 + + HC_EPS
127 + )
128 + tl.store(
129 + post_ptr + token_idx * HC_MULT + hc_idx,
130 + post,
131 + mask=hc_mask,
132 + )
133 +
134 + hidden_offsets = hidden_block_idx * BLOCK_D + tl.arange(0, BLOCK_D)
135 + hidden_mask = hidden_offsets < HIDDEN_SIZE
136 + x_row = x_ptr + token_idx * K_TOTAL
137 + output = tl.zeros((BLOCK_D,), dtype=tl.float32)
138 + for channel_idx in tl.static_range(HC_MULT):
139 + channel = tl.load(
140 + x_row + channel_idx * HIDDEN_SIZE + hidden_offsets,
141 + mask=hidden_mask,
142 + other=0.0,
143 + ).to(tl.float32)
144 + channel_gate = tl.sum(tl.where(hc_idx == channel_idx, pre, 0.0), axis=0)
145 + output += channel_gate * channel
146 +
147 + tl.store(
148 + output_ptr + token_idx * HIDDEN_SIZE + hidden_offsets,
149 + output.to(output_ptr.dtype.element_ty),
150 + mask=hidden_mask,
151 + )
152 +
153 +
154 +@triton.jit
155 +def _ihc_post_kernel(
156 + x_ptr,
157 + residual_ptr,
158 + post_ptr,
159 + output_ptr,
160 + HIDDEN_SIZE: tl.constexpr,
161 + HC_MULT: tl.constexpr,
162 + HC_POW2: tl.constexpr,
163 + BLOCK_D: tl.constexpr,
164 +):
165 + token_idx = tl.program_id(0).to(tl.int64)
166 + hidden_block_idx = tl.program_id(1)
167 + hc_idx = tl.arange(0, HC_POW2)
168 + hc_mask = hc_idx < HC_MULT
169 + post = tl.load(
170 + post_ptr + token_idx * HC_MULT + hc_idx,
171 + mask=hc_mask,
172 + other=0.0,
173 + )
174 +
175 + hidden_offsets = hidden_block_idx * BLOCK_D + tl.arange(0, BLOCK_D)
176 + hidden_mask = hidden_offsets < HIDDEN_SIZE
177 + x = tl.load(
178 + x_ptr + token_idx * HIDDEN_SIZE + hidden_offsets,
179 + mask=hidden_mask,
180 + other=0.0,
181 + ).to(tl.float32)
182 + residual_row = residual_ptr + token_idx * HC_MULT * HIDDEN_SIZE
183 + output_row = output_ptr + token_idx * HC_MULT * HIDDEN_SIZE
184 + for channel_idx in tl.static_range(HC_MULT):
185 + residual = tl.load(
186 + residual_row + channel_idx * HIDDEN_SIZE + hidden_offsets,
187 + mask=hidden_mask,
188 + other=0.0,
189 + ).to(tl.float32)
190 + channel_gate = tl.sum(tl.where(hc_idx == channel_idx, post, 0.0), axis=0)
191 + output = channel_gate * x + residual
192 + tl.store(
193 + output_row + channel_idx * HIDDEN_SIZE + hidden_offsets,
194 + output.to(output_ptr.dtype.element_ty),
195 + mask=hidden_mask,
196 + )
197 +
198 +
199 +def triton_ihc_pre(
200 + x: torch.Tensor,
201 + weight: torch.Tensor,
202 + scale: torch.Tensor,
203 + base: torch.Tensor,
204 + magnitude: float,
205 + hc_eps: float,
206 + norm_eps: float,
207 +) -> tuple[torch.Tensor, torch.Tensor]:
208 + """Reduce iHC channels and produce the post gates."""
209 + assert x.dim() == 3
210 + assert x.is_cuda and weight.is_cuda and scale.is_cuda and base.is_cuda
211 + assert weight.dtype == torch.float32
212 + assert scale.dtype == torch.float32 and base.dtype == torch.float32
213 +
214 + x = x.contiguous()
215 + assert weight.is_contiguous()
216 + scale = scale.contiguous()
217 + base = base.contiguous()
218 + num_tokens, hc_mult, hidden_size = x.shape
219 + k_total = hc_mult * hidden_size
220 + assert weight.shape == (2 * hc_mult, k_total)
221 + assert scale.shape == (2,)
222 + assert base.shape == (2 * hc_mult,)
223 +
224 + if num_tokens == 0:
225 + return (
226 + torch.empty((0, hidden_size), dtype=x.dtype, device=x.device),
227 + torch.empty((0, hc_mult), dtype=torch.float32, device=x.device),
228 + )
229 +
230 + output = torch.empty((num_tokens, hidden_size), dtype=x.dtype, device=x.device)
231 + post = torch.empty((num_tokens, hc_mult), dtype=torch.float32, device=x.device)
232 + hc_pow2 = triton.next_power_of_2(hc_mult)
233 + num_splits = triton.cdiv(k_total, _BLOCK_K)
234 + partial_stride = 1 + 2 * hc_pow2
235 + partial = torch.empty(
236 + (num_tokens, num_splits, partial_stride),
237 + dtype=torch.float32,
238 + device=x.device,
239 + )
240 +
241 + _ihc_pre_stage1[(num_tokens, num_splits)](
242 + x,
243 + weight,
244 + partial,
245 + K_TOTAL=k_total,
246 + HC_MULT=hc_mult,
247 + HC_POW2=hc_pow2,
248 + NUM_SPLITS=num_splits,
249 + BLOCK_K=_BLOCK_K,
250 + PARTIAL_STRIDE=partial_stride,
251 + num_warps=8,
252 + enable_fp_fusion=False,
253 + )
254 + _ihc_pre_stage2[(num_tokens, triton.cdiv(hidden_size, _BLOCK_D))](
255 + x,
256 + partial,
257 + scale,
258 + base,
259 + output,
260 + post,
261 + HIDDEN_SIZE=hidden_size,
262 + K_TOTAL=k_total,
263 + HC_MULT=hc_mult,
264 + HC_POW2=hc_pow2,
265 + NUM_SPLITS=num_splits,
266 + PARTIAL_STRIDE=partial_stride,
267 + BLOCK_D=_BLOCK_D,
268 + MAGNITUDE=magnitude,
269 + NORM_EPS=norm_eps,
270 + HC_EPS=hc_eps,
271 + num_warps=4,
272 + enable_fp_fusion=False,
273 + )
274 + return output, post
275 +
276 +
277 +def triton_ihc_post(
278 + x: torch.Tensor,
279 + residual: torch.Tensor,
280 + post: torch.Tensor,
281 +) -> torch.Tensor:
282 + """Scatter a sub-block output back over the iHC residual channels."""
283 + assert x.dim() == 2
284 + assert x.is_cuda and residual.is_cuda and post.is_cuda
285 + assert post.dtype == torch.float32
286 +
287 + x = x.contiguous()
288 + residual = residual.contiguous()
289 + post = post.contiguous()
290 + num_tokens, hidden_size = x.shape
291 + hc_mult = post.shape[-1]
292 + assert residual.shape == (num_tokens, hc_mult, hidden_size)
293 + assert post.shape == (num_tokens, hc_mult)
294 +
295 + if num_tokens == 0:
296 + return torch.empty((0, hc_mult, hidden_size), dtype=x.dtype, device=x.device)
297 +
298 + output = torch.empty(
299 + (num_tokens, hc_mult, hidden_size), dtype=x.dtype, device=x.device
300 + )
301 + _ihc_post_kernel[(num_tokens, triton.cdiv(hidden_size, _BLOCK_D))](
302 + x,
303 + residual,
304 + post,
305 + output,
306 + HIDDEN_SIZE=hidden_size,
307 + HC_MULT=hc_mult,
308 + HC_POW2=triton.next_power_of_2(hc_mult),
309 + BLOCK_D=_BLOCK_D,
310 + num_warps=4,
311 + enable_fp_fusion=False,
312 + )
313 + return output