[Bugfix][Kernel] Fix batch invariance in RMSNorm kernels by pinning block size (#48391)
Signed-off-by: oops-oom <73481342@qq.com> Signed-off-by: oops-oom <liubin8905@vip.qq.com> Co-authored-by: oops-oom <73481342@qq.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Wentao Ye <44945378+yewentao256@users.noreply.github.com> Co-authored-by: Shengqi Chen <harry-chen@outlook.com>
oops-oom committed
Jul 28, 2026 at 22:24 UTC
b6cbba8bc893c61e412a205533aafbee1ae6be31
6 files changed
+189
-21
.buildkite/test_areas/misc.yaml
+7
-7
@@ -398,7 +398,7 @@ steps:
398
399
- label: Batch Invariance (A100)
400
key: batch-invariance-a100
401
- timeout_in_minutes: 40
401
+ timeout_in_minutes: 60
402
device: a100
403
source_file_dependencies:
404
- vllm/v1/attention
@@ -408,11 +408,11 @@ steps:
408
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
409
- pip install pytest-timeout pytest-forked
410
- pytest -v -s v1/determinism/test_batch_invariance.py
411
- - VLLM_TEST_MODEL=deepseek-ai/DeepSeek-V2-Lite-Chat pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle[TRITON_MLA]
411
+ - VLLM_TEST_MODEL=deepseek-ai/DeepSeek-V2-Lite-Chat pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle -k TRITON_MLA
412
413
- label: Batch Invariance (H100)
414
key: batch-invariance-h100
415
- timeout_in_minutes: 40
415
+ timeout_in_minutes: 60
416
device: h100
417
source_file_dependencies:
418
- vllm/v1/attention
@@ -423,8 +423,8 @@ steps:
423
- pip install pytest-timeout pytest-forked
424
- pytest -v -s v1/determinism/test_batch_invariance.py
425
- pytest -v -s v1/determinism/test_rms_norm_batch_invariant.py
426
- - VLLM_TEST_MODEL=deepseek-ai/DeepSeek-V2-Lite-Chat pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle[TRITON_MLA]
427
- - VLLM_TEST_MODEL=Qwen/Qwen3-30B-A3B-Thinking-2507-FP8 pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle[FLASH_ATTN]
426
+ - VLLM_TEST_MODEL=deepseek-ai/DeepSeek-V2-Lite-Chat pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle -k TRITON_MLA
427
+ - VLLM_TEST_MODEL=Qwen/Qwen3-30B-A3B-Thinking-2507-FP8 pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle -k FLASH_ATTN
428
429
- label: Batch Invariance (B200)
430
key: batch-invariance-b200
@@ -439,8 +439,8 @@ steps:
439
- pip install pytest-timeout pytest-forked
440
- pytest -v -s v1/determinism/test_batch_invariance.py
441
- pytest -v -s v1/determinism/test_rms_norm_batch_invariant.py
442
- - VLLM_TEST_MODEL=deepseek-ai/DeepSeek-V2-Lite-Chat pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle[TRITON_MLA]
443
- - VLLM_TEST_MODEL=Qwen/Qwen3-30B-A3B-Thinking-2507-FP8 pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle[FLASH_ATTN]
442
+ - VLLM_TEST_MODEL=deepseek-ai/DeepSeek-V2-Lite-Chat pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle -k TRITON_MLA
443
+ - VLLM_TEST_MODEL=Qwen/Qwen3-30B-A3B-Thinking-2507-FP8 pytest -v -s v1/determinism/test_batch_invariance.py::test_v1_generation_is_deterministic_across_batch_sizes_with_needle -k FLASH_ATTN
444
- pytest -v -s v1/determinism/test_nvfp4_batch_invariant.py
445
- pytest -v -s v1/determinism/test_nvfp4_batch_invariant_scaled_mm.py
446
- pytest -v -s v1/determinism/test_matmul_batch_invariant.py
csrc/libtorch_stable/layernorm_kernels.cu
+10
-4
@@ -249,7 +249,9 @@ void rms_norm(torch::stable::Tensor& out, // [..., hidden_size]
249
int64_t input_shape_d3 = (num_dims >= 4) ? input.size(-3) : 0;
250
251
// For large num_tokens, use smaller blocks to increase SM concurrency.
252
- const int max_block_size = (num_tokens < 256) ? 1024 : 256;
252
+ const bool batch_invariant_launch = vllm::vllm_is_batch_invariant();
253
+ const int max_block_size =
254
+ batch_invariant_launch ? 1024 : ((num_tokens < 256) ? 1024 : 256);
255
dim3 grid(num_tokens);
256
const torch::stable::accelerator::DeviceGuard device_guard(
257
input.get_device_index());
@@ -325,8 +327,13 @@ void fused_add_rms_norm(torch::stable::Tensor& input, // [..., hidden_size]
327
/* This kernel is memory-latency bound in many scenarios.
328
When num_tokens is large, a smaller block size allows
329
for increased block occupancy on CUs and better latency
328
- hiding on global mem ops. */
329
- const int max_block_size = (num_tokens < 256) ? 1024 : 256;
330
+ hiding on global mem ops. In batch-invariant mode the block size must
331
+ not depend on num_tokens, otherwise the same token would use a different
332
+ reduction width (and thus a different floating-point summation order)
333
+ across batches; lock it to 1024 to keep results bit-exact. */
334
+ const bool batch_invariant_launch = vllm::vllm_is_batch_invariant();
335
+ const int max_block_size =
336
+ batch_invariant_launch ? 1024 : ((num_tokens < 256) ? 1024 : 256);
337
dim3 block(std::min(hidden_size, max_block_size));
338
const torch::stable::accelerator::DeviceGuard device_guard(
339
input.get_device_index());
@@ -337,7 +344,6 @@ void fused_add_rms_norm(torch::stable::Tensor& input, // [..., hidden_size]
344
auto res_ptr = reinterpret_cast<std::uintptr_t>(residual.data_ptr());
345
bool offsets_are_multiple_of_vector_width =
346
hidden_size % vector_width == 0 && input_stride % vector_width == 0;
340
- bool batch_invariant_launch = vllm::vllm_is_batch_invariant();
347
const bool has_weight = weight.has_value();
348
if (has_weight) {
349
auto wt_ptr = reinterpret_cast<std::uintptr_t>(weight->data_ptr());
csrc/libtorch_stable/layernorm_quant_kernels.cu
+6
-3
@@ -215,7 +215,9 @@ void rms_norm_static_fp8_quant(
215
int num_tokens = input.numel() / hidden_size;
216
217
// For large num_tokens, use smaller blocks to increase SM concurrency.
218
- const int max_block_size = (num_tokens < 256) ? 1024 : 256;
218
+ const bool batch_invariant_launch = vllm::vllm_is_batch_invariant();
219
+ const int max_block_size =
220
+ batch_invariant_launch ? 1024 : ((num_tokens < 256) ? 1024 : 256);
221
dim3 grid(num_tokens);
222
const torch::stable::accelerator::DeviceGuard device_guard(
223
input.get_device_index());
@@ -279,7 +281,9 @@ void fused_add_rms_norm_static_fp8_quant(
281
When num_tokens is large, a smaller block size allows
282
for increased block occupancy on CUs and better latency
283
hiding on global mem ops. */
282
- const int max_block_size = (num_tokens < 256) ? 1024 : 256;
284
+ const bool batch_invariant_launch = vllm::vllm_is_batch_invariant();
285
+ const int max_block_size =
286
+ batch_invariant_launch ? 1024 : ((num_tokens < 256) ? 1024 : 256);
287
dim3 block(std::min(hidden_size, max_block_size));
288
const torch::stable::accelerator::DeviceGuard device_guard(
289
input.get_device_index());
@@ -296,7 +300,6 @@ void fused_add_rms_norm_static_fp8_quant(
300
auto wt_ptr = reinterpret_cast<std::uintptr_t>(weight.data_ptr());
301
bool ptrs_are_aligned =
302
inp_ptr % 16 == 0 && res_ptr % 16 == 0 && wt_ptr % 16 == 0;
299
- bool batch_invariant_launch = vllm::vllm_is_batch_invariant();
303
if (ptrs_are_aligned && hidden_size % 8 == 0 && input_stride % 8 == 0 &&
304
!batch_invariant_launch) {
305
LAUNCH_FUSED_ADD_RMS_NORM(8);
csrc/libtorch_stable/quantization/fused_kernels/fused_layernorm_dynamic_per_token_quant.cu
+4
-1
@@ -2,6 +2,7 @@
2
#include "../../torch_utils.h"
3
4
#include "../../dispatch_utils.h"
5
+#include "../../../core/batch_invariant.hpp"
6
#include "layernorm_utils.cuh"
7
#include "quant_conversions.cuh"
8
@@ -231,7 +232,9 @@ void rms_norm_per_block_quant_dispatch(
232
auto num_tokens = input.numel() / hidden_size;
233
234
dim3 grid(num_tokens);
234
- const int max_block_size = (num_tokens <= 256) ? 512 : 256;
235
+ const bool batch_invariant_launch = vllm::vllm_is_batch_invariant();
236
+ const int max_block_size =
237
+ batch_invariant_launch ? 512 : ((num_tokens <= 256) ? 512 : 256);
238
dim3 block(std::min(hidden_size, max_block_size));
239
const torch::stable::accelerator::DeviceGuard device_guard(
240
input.get_device_index());
tests/v1/determinism/test_batch_invariance.py
+18
@@ -27,8 +27,10 @@ from vllm.platforms import current_platform
27
"backend",
28
BACKENDS,
29
)
30
+@pytest.mark.parametrize("rms_norm_impl", ["default", "vllm_c"])
31
def test_v1_generation_is_deterministic_across_batch_sizes_with_needle(
32
backend,
33
+ rms_norm_impl,
34
):
35
"""
36
Ensures that the same request (the 'needle' prompt) yields identical output
@@ -60,6 +62,16 @@ def test_v1_generation_is_deterministic_across_batch_sizes_with_needle(
62
random.seed(seed)
63
64
attention_config = {"backend": backend}
65
+ # Force the C++ RMSNorm implementation so we actually exercise the
66
+ # num_tokens-dependent block-size branches.
67
+ kernel_config = None
68
+ if rms_norm_impl == "vllm_c":
69
+ kernel_config = {
70
+ "ir_op_priority": {
71
+ "rms_norm": ["vllm_c"],
72
+ "fused_add_rms_norm": ["vllm_c"],
73
+ }
74
+ }
75
# Allow overrides from environment (useful for CI tuning)
76
# "facebook/opt-125m" is too small, doesn't reliably test determinism
77
model = TEST_MODEL
@@ -96,6 +108,7 @@ def test_v1_generation_is_deterministic_across_batch_sizes_with_needle(
108
gpu_memory_utilization=gpu_mem_util,
109
max_model_len=max_model_len,
110
attention_config=attention_config,
111
+ kernel_config=kernel_config,
112
)
113
114
# Baseline generation for the needle prompt alone.
@@ -923,11 +936,15 @@ def LLM_with_max_seqs(
936
gpu_memory_utilization: float,
937
max_model_len: int,
938
attention_config: dict | None = None,
939
+ kernel_config: dict | None = None,
940
) -> LLM:
941
"""
942
Helper to construct an LLM with a specific max_num_seqs (batch-size limit)
943
using the high-level v1 LLM API, while constraining memory usage.
944
"""
945
+ extra_kwargs: dict = {}
946
+ if kernel_config is not None:
947
+ extra_kwargs["kernel_config"] = kernel_config
948
return LLM(
949
model=model,
950
max_num_seqs=max_num_seqs,
@@ -939,4 +956,5 @@ def LLM_with_max_seqs(
956
attention_config=attention_config,
957
# Enable for MOE models
958
# enable_expert_parallel=True,
959
+ **extra_kwargs,
960
)
tests/v1/determinism/test_rms_norm_batch_invariant.py
+144
-6
@@ -28,16 +28,18 @@ def _rms_norm_reference(
28
29
30
@skip_if_not_cuda
31
-@pytest.mark.parametrize("batch_size", [1, 4, 16, 64])
31
+@pytest.mark.parametrize("batch_size", [1, 4, 64, 300])
32
@pytest.mark.parametrize("hidden_size", [512, 2048, 4096, 8192])
33
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
34
@pytest.mark.parametrize("eps", [1e-6, 1e-5])
35
+@pytest.mark.parametrize("seed", list(range(4)))
36
def test_rms_norm_batch_invariant_vs_reference(
37
default_vllm_config,
38
batch_size: int,
39
hidden_size: int,
40
dtype: torch.dtype,
41
eps: float,
42
+ seed: int,
43
):
44
"""
45
Compare batch-invariant Triton RMS norm against a PyTorch reference.
@@ -48,7 +50,7 @@ def test_rms_norm_batch_invariant_vs_reference(
50
device = torch.device(DEVICE_TYPE)
51
52
# Create test input and weight
51
- torch.manual_seed(42)
53
+ torch.manual_seed(seed)
54
input_tensor = torch.randn(batch_size, hidden_size, dtype=dtype, device=device)
55
weight = torch.randn(hidden_size, dtype=dtype, device=device)
56
@@ -71,7 +73,7 @@ def test_rms_norm_batch_invariant_vs_reference(
73
atol=atol,
74
msg=f"RMS norm mismatch for batch_size={batch_size}, "
75
f"hidden_size={hidden_size}, "
74
- f"dtype={dtype}, eps={eps}",
76
+ f"dtype={dtype}, eps={eps}, seed={seed}",
77
)
78
79
@@ -79,17 +81,21 @@ def test_rms_norm_batch_invariant_vs_reference(
81
@pytest.mark.parametrize("hidden_size", [512, 4096])
82
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
83
@pytest.mark.parametrize("eps", [1e-6])
84
+@pytest.mark.parametrize("n_extra", [3, 299])
85
+@pytest.mark.parametrize("seed", list(range(16)))
86
def test_fused_add_rms_norm_batch_invariant_residual_path(
87
hidden_size: int,
88
dtype: torch.dtype,
89
eps: float,
90
+ n_extra: int,
91
+ seed: int,
92
):
93
"""
94
Test the batch-invariant fused residual-add + RMSNorm helper directly.
95
"""
96
device = torch.device(DEVICE_TYPE)
97
92
- torch.manual_seed(42)
98
+ torch.manual_seed(seed)
99
x_single = torch.randn(1, hidden_size, dtype=dtype, device=device)
100
residual_single = torch.randn(1, hidden_size, dtype=dtype, device=device)
101
weight = torch.randn(hidden_size, dtype=dtype, device=device)
@@ -97,14 +103,14 @@ def test_fused_add_rms_norm_batch_invariant_residual_path(
103
x_batch = torch.cat(
104
[
105
x_single,
100
- torch.randn(3, hidden_size, dtype=dtype, device=device),
106
+ torch.randn(n_extra, hidden_size, dtype=dtype, device=device),
107
],
108
dim=0,
109
)
110
residual_batch = torch.cat(
111
[
112
residual_single,
107
- torch.randn(3, hidden_size, dtype=dtype, device=device),
113
+ torch.randn(n_extra, hidden_size, dtype=dtype, device=device),
114
],
115
dim=0,
116
)
@@ -168,6 +174,138 @@ def test_fused_add_rms_norm_batch_invariant_residual_path(
174
)
175
176
177
+FP8_DTYPE = current_platform.fp8_dtype()
178
+
179
+# The large launch (num_tokens=300 >= 256) drops an un-pinned kernel to block
180
+# 256, while the small launch (255 rows) stays under the threshold and keeps the
181
+# larger block (1024, or 512 for per-block quant). Under the pin the two launches
182
+# use the same block, so the shared first 255 rows must match bit-for-bit; 255 is
183
+# the most rows a single small launch can hold (< 256, and <= 256 for per-block).
184
+_LARGE_TOKENS = 300
185
+_SMALL_TOKENS = 255
186
+
187
+
188
+def _assert_rows_bit_identical(small, large, msg):
189
+ if small.dtype == FP8_DTYPE:
190
+ assert torch.equal(small.view(torch.uint8), large.view(torch.uint8)), msg
191
+ else:
192
+ torch.testing.assert_close(small, large, rtol=0.0, atol=0.0, msg=msg)
193
+
194
+
195
+@skip_if_not_cuda
196
+@pytest.mark.parametrize("hidden_size", [512, 4096])
197
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
198
+@pytest.mark.parametrize("seed", list(range(4)))
199
+def test_rms_norm_batch_invariant_nonresidual_kernel(
200
+ hidden_size: int, dtype: torch.dtype, seed: int
201
+):
202
+ """C++ ``rms_norm`` (no residual) must be batch invariant across the block
203
+ threshold. Reached in compiled mode with ``ir_op_priority.rms_norm=["vllm_c"]``
204
+ (default priority is ``native``/inductor codegen when compiling).
205
+ """
206
+ import vllm._custom_ops as ops
207
+
208
+ device = torch.device(DEVICE_TYPE)
209
+ torch.manual_seed(seed)
210
+ rows = torch.randn(_LARGE_TOKENS, hidden_size, dtype=dtype, device=device)
211
+ weight = torch.randn(hidden_size, dtype=dtype, device=device)
212
+
213
+ def rms_norm(x):
214
+ out = torch.empty_like(x)
215
+ ops.rms_norm(out, x, weight, 1e-6)
216
+ return out
217
+
218
+ large = rms_norm(rows.clone())
219
+ small = rms_norm(rows[:_SMALL_TOKENS].clone())
220
+ _assert_rows_bit_identical(
221
+ small,
222
+ large[:_SMALL_TOKENS],
223
+ "rms_norm output depends on num_tokens (block size)",
224
+ )
225
+
226
+
227
+@skip_if_not_cuda
228
+@pytest.mark.parametrize("hidden_size", [512, 4096])
229
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
230
+@pytest.mark.parametrize("seed", list(range(4)))
231
+@pytest.mark.parametrize("add_residual", [False, True])
232
+def test_rms_norm_static_fp8_quant_batch_invariant(
233
+ hidden_size: int, dtype: torch.dtype, seed: int, add_residual: bool
234
+):
235
+ """C++ static per-tensor fp8-quant RMSNorm must be batch invariant across
236
+ the block threshold. Covers ``rms_norm_static_fp8_quant`` and, with
237
+ ``add_residual``, ``fused_add_rms_norm_static_fp8_quant`` (the compiled fp8
238
+ path where ``RMSNormQuantFusionPass`` rewrites norm + quant into them).
239
+ """
240
+ device = torch.device(DEVICE_TYPE)
241
+ torch.manual_seed(seed)
242
+ rows = torch.randn(_LARGE_TOKENS, hidden_size, dtype=dtype, device=device)
243
+ residual = (
244
+ torch.randn(_LARGE_TOKENS, hidden_size, dtype=dtype, device=device)
245
+ if add_residual
246
+ else None
247
+ )
248
+ weight = torch.randn(hidden_size, dtype=dtype, device=device)
249
+ quant_scale = torch.tensor(1.0, dtype=torch.float32, device=device)
250
+
251
+ def quant(x, res):
252
+ out = torch.empty_like(x, dtype=FP8_DTYPE)
253
+ if add_residual:
254
+ torch.ops._C.fused_add_rms_norm_static_fp8_quant(
255
+ out, x, res, weight, quant_scale, 1e-6
256
+ )
257
+ else:
258
+ torch.ops._C.rms_norm_static_fp8_quant(out, x, weight, quant_scale, 1e-6)
259
+ return out
260
+
261
+ large = quant(rows.clone(), residual.clone() if residual is not None else None)
262
+ small = quant(
263
+ rows[:_SMALL_TOKENS].clone(),
264
+ residual[:_SMALL_TOKENS].clone() if residual is not None else None,
265
+ )
266
+ _assert_rows_bit_identical(
267
+ small,
268
+ large[:_SMALL_TOKENS],
269
+ "static-fp8-quant RMSNorm output depends on num_tokens (block size)",
270
+ )
271
+
272
+
273
+@skip_if_not_cuda
274
+@pytest.mark.parametrize("hidden_size", [512, 4096])
275
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
276
+@pytest.mark.parametrize("seed", list(range(4)))
277
+def test_rms_norm_per_block_quant_batch_invariant(
278
+ hidden_size: int, dtype: torch.dtype, seed: int
279
+):
280
+ """C++ ``rms_norm_per_block_quant`` must be batch invariant across the
281
+ block threshold (compiled fp8 block-quant path; block pinned to 512)."""
282
+ import vllm._custom_ops as ops
283
+
284
+ device = torch.device(DEVICE_TYPE)
285
+ torch.manual_seed(seed)
286
+ rows = torch.randn(_LARGE_TOKENS, hidden_size, dtype=dtype, device=device)
287
+ weight = torch.randn(hidden_size, dtype=dtype, device=device)
288
+ group_size = [1, 128]
289
+
290
+ def per_block_quant(x):
291
+ return ops.rms_norm_per_block_quant(x, weight, 1e-6, FP8_DTYPE, group_size)
292
+
293
+ out_large, scale_large = per_block_quant(rows.clone())
294
+ out_small, scale_small = per_block_quant(rows[:_SMALL_TOKENS].clone())
295
+ _assert_rows_bit_identical(
296
+ out_small,
297
+ out_large[:_SMALL_TOKENS],
298
+ "rms_norm_per_block_quant output depends on num_tokens (block size)",
299
+ )
300
+ torch.testing.assert_close(
301
+ scale_small,
302
+ scale_large[:_SMALL_TOKENS],
303
+ rtol=0.0,
304
+ atol=0.0,
305
+ msg="rms_norm_per_block_quant scales depend on num_tokens (block size)",
306
+ )
307
+
308
+
309
@skip_if_not_cuda
310
@pytest.mark.parametrize("batch_size", [1, 16, 128])
311
@pytest.mark.parametrize("seq_len", [1, 32, 512])