[Hardware][XPU] Register matmul and linear batch-invariant kernels for XPU (#49209)
Signed-off-by: tzielinski-habana <tomasz.zielinski@intel.com> Signed-off-by: Tomasz Zielinski <85164140+tzielinski-habana@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Tomasz Zielinski committed
Sep 2, 2026 at 15:31 UTC
ffe3bb3c723d9e5e5f5a0c8aeab1d857a4975b8b
7 files changed
+272
-33
docs/features/batch_invariance.md
+23
-1
@@ -17,7 +17,29 @@ Batch invariance is crucial for several use cases:
17
18
## Hardware Requirements
19
20
-Batch invariance requires NVIDIA GPUs with compute capability 8.0 or higher.
20
+Batch invariance is supported on the following platforms:
21
+
22
+- NVIDIA GPUs with compute capability 8.0 or higher.
23
+- Intel XPUs with Triton support.
24
+
25
+### Attention Backend Selection for XPU
26
+
27
+On XPU, Triton Attention backend is required for batch invariance.
28
+Select this backend using Qwen/Qwen3-1.7B model as an example:
29
+
30
+```python
31
+llm = LLM(
32
+ model="Qwen/Qwen3-1.7B",
33
+ attention_config={"backend": "TRITON_ATTN"},
34
+)
35
+```
36
+
37
+Or via the CLI:
38
+
39
+```bash
40
+VLLM_BATCH_INVARIANT=1 vllm serve Qwen/Qwen3-1.7B \
41
+ --attention-config.backend TRITON_ATTN
42
+```
43
44
## Enabling Batch Invariance
45
tests/v1/determinism/test_batch_invariance.py
-11
@@ -17,7 +17,6 @@ from utils import (
17
18
import vllm.envs as envs
19
from vllm import LLM, SamplingParams
20
-from vllm.platforms import current_platform
20
21
22
@skip_unsupported
@@ -53,11 +52,6 @@ def test_v1_generation_is_deterministic_across_batch_sizes_with_needle(
52
seed.
53
- Keep max_tokens and max_model_len bounded for speed and memory use.
54
"""
56
- # Not all batch-invariant kernels are registered on XPU yet
57
- # (e.g. attention, custom ops), so e2e determinism is not guaranteed.
58
- if current_platform.is_xpu():
59
- pytest.xfail("Not all batch-invariant kernels registered on XPU yet")
60
-
55
seed = int(os.getenv("VLLM_TEST_SEED", "12345"))
56
random.seed(seed)
57
@@ -177,11 +171,6 @@ def test_logprobs_bitwise_batch_invariance_bs1_vs_bsN(
171
block_m,
172
block_n,
173
):
180
- # Not all batch-invariant kernels are registered on XPU yet
181
- # (e.g. attention, custom ops), so e2e determinism is not guaranteed.
182
- if current_platform.is_xpu():
183
- pytest.xfail("Not all batch-invariant kernels registered on XPU yet")
184
-
174
seed = int(os.getenv("VLLM_TEST_SEED", "12345"))
175
random.seed(seed)
176
tp_size = int(os.getenv("VLLM_TEST_TP_SIZE", "1"))
vllm/engine/arg_utils.py
+13
@@ -2435,6 +2435,19 @@ class EngineArgs:
2435
self.attention_backend
2436
)
2437
2438
+ # Batch-invariant mode requires deterministic attention behavior.
2439
+ # If no backend is explicitly requested, prefer Triton Attention.
2440
+ if (
2441
+ envs.VLLM_BATCH_INVARIANT
2442
+ and attention_config.backend is None
2443
+ and current_platform.is_xpu()
2444
+ ):
2445
+ attention_config.backend = AttentionBackendEnum.TRITON_ATTN
2446
+ logger.info(
2447
+ "VLLM_BATCH_INVARIANT is enabled and no attention backend was "
2448
+ "specified; defaulting to TRITON_ATTN."
2449
+ )
2450
+
2451
# TurboQuant requires FlashAttention 2 — FA3 boundary layers assert
2452
# FlashAttentionImpl which fails with TurboQuantAttentionImpl.
2453
if resolved_cache_dtype.startswith("turboquant_") and (
vllm/model_executor/determinism/batch_invariant.py
+180
-19
@@ -9,6 +9,7 @@ import torch
9
10
import vllm.envs as envs
11
from vllm.model_executor.determinism.batch_invariant_configs import (
12
+ _get_descriptor_matmul_config,
13
_get_matmul_config,
14
resolve_tuned_matmul_configs,
15
)
@@ -133,6 +134,149 @@ def matmul_kernel_persistent(
134
tl.store(c_ptrs, c, mask=c_mask)
135
136
137
+@triton.jit(launch_metadata=_matmul_launch_metadata)
138
+def matmul_kernel_descriptor_persistent(
139
+ a_ptr,
140
+ b_ptr,
141
+ c_ptr, #
142
+ bias_ptr,
143
+ M,
144
+ N,
145
+ K, #
146
+ BLOCK_SIZE_M: tl.constexpr, #
147
+ BLOCK_SIZE_N: tl.constexpr, #
148
+ BLOCK_SIZE_K: tl.constexpr, #
149
+ GROUP_SIZE_M: tl.constexpr, #
150
+ NUM_SMS: tl.constexpr, #
151
+ HAS_BIAS: tl.constexpr,
152
+):
153
+ """Persistent matmul using tensor descriptors for 2D block I/O.
154
+
155
+ Expects b_ptr to point to a transposed B matrix of shape [N, K] with
156
+ row-major (K-contiguous) layout. The dot product transposes each loaded
157
+ B-tile back: dot(A_tile, B_tile.T).
158
+
159
+ ~3x faster than the pointer-based persistent kernel on Intel XPU because
160
+ tensor descriptors leverage hardware 2D block load/store with automatic
161
+ bounds checking (no explicit masks needed).
162
+ """
163
+ dtype = c_ptr.dtype.element_ty
164
+ start_pid = tl.program_id(axis=0)
165
+ num_pid_m = tl.cdiv(M, BLOCK_SIZE_M)
166
+ num_pid_n = tl.cdiv(N, BLOCK_SIZE_N)
167
+ k_tiles = tl.cdiv(K, BLOCK_SIZE_K)
168
+ num_tiles = num_pid_m * num_pid_n
169
+
170
+ a_desc = tl.make_tensor_descriptor(
171
+ a_ptr,
172
+ shape=[M, K],
173
+ strides=[K, 1],
174
+ block_shape=[BLOCK_SIZE_M, BLOCK_SIZE_K],
175
+ )
176
+ b_desc = tl.make_tensor_descriptor(
177
+ b_ptr,
178
+ shape=[N, K],
179
+ strides=[K, 1],
180
+ block_shape=[BLOCK_SIZE_N, BLOCK_SIZE_K],
181
+ )
182
+ c_desc = tl.make_tensor_descriptor(
183
+ c_ptr,
184
+ shape=[M, N],
185
+ strides=[N, 1],
186
+ block_shape=[BLOCK_SIZE_M, BLOCK_SIZE_N],
187
+ )
188
+
189
+ tile_id_c = start_pid - NUM_SMS
190
+ num_pid_in_group = GROUP_SIZE_M * num_pid_n
191
+
192
+ for tile_id in tl.range(start_pid, num_tiles, NUM_SMS, flatten=True):
193
+ pid_m, pid_n = _compute_pid(tile_id, num_pid_in_group, num_pid_m, GROUP_SIZE_M)
194
+ offs_am = pid_m * BLOCK_SIZE_M
195
+ offs_bn = pid_n * BLOCK_SIZE_N
196
+
197
+ accumulator = tl.zeros((BLOCK_SIZE_M, BLOCK_SIZE_N), dtype=tl.float32)
198
+ for ki in range(k_tiles):
199
+ offs_k = ki * BLOCK_SIZE_K
200
+ a = a_desc.load([offs_am, offs_k])
201
+ b = b_desc.load([offs_bn, offs_k])
202
+ accumulator = tl.dot(a, b.T, accumulator)
203
+
204
+ tile_id_c += NUM_SMS
205
+ pid_m, pid_n = _compute_pid(
206
+ tile_id_c, num_pid_in_group, num_pid_m, GROUP_SIZE_M
207
+ )
208
+ offs_cm = pid_m * BLOCK_SIZE_M
209
+ offs_cn = pid_n * BLOCK_SIZE_N
210
+
211
+ if HAS_BIAS:
212
+ bias_offs = offs_cn + tl.arange(0, BLOCK_SIZE_N)
213
+ bias_vals = tl.load(bias_ptr + bias_offs, mask=bias_offs < N, other=0.0).to(
214
+ tl.float32
215
+ )
216
+ accumulator += bias_vals[None, :]
217
+
218
+ c = accumulator.to(dtype)
219
+ c_desc.store([offs_cm, offs_cn], c)
220
+
221
+
222
+def matmul_descriptor_persistent(
223
+ a: torch.Tensor, b: torch.Tensor, bias: torch.Tensor | None = None
224
+):
225
+ """Persistent matmul using tensor descriptors (Intel XPU fast path).
226
+
227
+ Args:
228
+ a: Input matrix [M, K], must be contiguous.
229
+ b: Weight matrix [K, N] (standard layout — transposed internally).
230
+ bias: Optional 1D bias vector [N].
231
+
232
+ Returns:
233
+ Output matrix [M, N] with dtype matching the inputs.
234
+ """
235
+ assert a.shape[1] == b.shape[0], "Incompatible dimensions"
236
+ assert a.dtype == b.dtype, "Incompatible dtypes"
237
+ assert bias is None or bias.dim() == 1, "bias must be 1D"
238
+
239
+ M, K = a.shape
240
+ _, N = b.shape
241
+ dtype = a.dtype
242
+
243
+ # Tensor descriptors require contiguous row-major layout.
244
+ a = a.contiguous()
245
+ # Descriptor kernel expects B in [N, K] layout (K-contiguous).
246
+ b_t = b.t().contiguous()
247
+
248
+ c = torch.empty((M, N), device=a.device, dtype=dtype)
249
+ # This path is XPU-only. _NUM_SMS is set by enable_batch_invariant_mode()
250
+ # before torch.compile traces this function, so Dynamo can lift it
251
+ # as a compile-time constant.
252
+ NUM_SMS = _NUM_SMS if _NUM_SMS > 0 else num_compute_units(a.device.index)
253
+
254
+ def grid(META):
255
+ return (
256
+ min(
257
+ NUM_SMS,
258
+ triton.cdiv(M, META["BLOCK_SIZE_M"])
259
+ * triton.cdiv(N, META["BLOCK_SIZE_N"]),
260
+ ),
261
+ )
262
+
263
+ cfg = _get_descriptor_matmul_config(M, N, K, dtype)
264
+
265
+ matmul_kernel_descriptor_persistent[grid](
266
+ a,
267
+ b_t,
268
+ c,
269
+ bias,
270
+ M,
271
+ N,
272
+ K,
273
+ NUM_SMS=NUM_SMS,
274
+ HAS_BIAS=bias is not None,
275
+ **cfg,
276
+ )
277
+ return c
278
+
279
+
280
def matmul_persistent(
281
a: torch.Tensor, b: torch.Tensor, bias: torch.Tensor | None = None
282
):
@@ -589,15 +733,17 @@ def mean_dim(
733
return output
734
735
592
-def mm_batch_invariant(a, b):
593
- return matmul_persistent(a, b)
736
+def mm_batch_invariant(a, b, bias=None):
737
+ if a.device.type == "xpu":
738
+ return matmul_descriptor_persistent(a, b, bias=bias)
739
+ return matmul_persistent(a, b, bias=bias)
740
741
742
def matmul_batch_invariant(a, b, *, out=None):
743
# torch.matmul can handle various dimensions
744
# For 2D x 2D, it's the same as mm
745
if a.ndim == 2 and b.ndim == 2:
600
- result = matmul_persistent(a, b)
746
+ result = mm_batch_invariant(a, b)
747
if out is not None:
748
out.copy_(result)
749
return out
@@ -609,7 +755,7 @@ def matmul_batch_invariant(a, b, *, out=None):
755
hidden = a.shape[-1]
756
out_dim = b.shape[-1]
757
a_2d = a.reshape(-1, hidden)
612
- result_2d = matmul_persistent(a_2d, b)
758
+ result_2d = mm_batch_invariant(a_2d, b)
759
result = result_2d.reshape(batch_dims + (out_dim,))
760
if out is not None:
761
out.copy_(result)
@@ -732,7 +878,7 @@ def bmm_batch_invariant(a, b, *, out=None):
878
879
880
def addmm_batch_invariant(bias, a, b):
735
- return matmul_persistent(a, b, bias=bias)
881
+ return mm_batch_invariant(a, b, bias=bias)
882
883
884
def _log_softmax_batch_invariant(input, dim, _half_to_float):
@@ -903,11 +1049,13 @@ _batch_invariant_LIB = None
1049
_fp16_block_size_n = 256
1050
_fp32_block_size_n = 128
1051
_fp32_num_stages = 3
1052
+_NUM_SMS: int = 0
1053
1054
1055
def enable_batch_invariant_mode():
1056
global _batch_invariant_MODE, _batch_invariant_LIB
1057
global _fp16_block_size_n, _fp32_block_size_n, _fp32_num_stages
1058
+ global _NUM_SMS
1059
1060
if _batch_invariant_MODE:
1061
return
@@ -916,7 +1064,6 @@ def enable_batch_invariant_mode():
1064
_batch_invariant_LIB = torch.library.Library("aten", "IMPL")
1065
1066
key = current_platform.dispatch_key
919
-
1067
if current_platform.is_cuda():
1068
if current_platform.is_device_capability_family(80):
1069
# SM80 (Ampere) cannot rely on cuBLASLt-only determinism; install the
@@ -945,24 +1092,38 @@ def enable_batch_invariant_mode():
1092
_fp32_block_size_n = 32
1093
_fp32_num_stages = 2
1094
elif current_platform.is_xpu():
1095
+ # Tensor descriptors need a global-memory allocator; must be set outside
1096
+ # torch.compile regions (triton.set_allocator modifies global state).
1097
+ def _triton_alloc_fn(size: int, alignment: int, stream: int | None):
1098
+ return torch.empty(size, device="xpu", dtype=torch.int8)
1099
+
1100
+ triton.set_allocator(_triton_alloc_fn)
1101
+
1102
+ _NUM_SMS = num_compute_units(0)
1103
_batch_invariant_LIB.impl("aten::mm", mm_batch_invariant, key)
1104
_batch_invariant_LIB.impl("aten::addmm", addmm_batch_invariant, key)
950
- # TODO: register matmul and linear for XPU
951
- # once suitable Triton kernels are implemented
1105
+ _batch_invariant_LIB.impl("aten::matmul", matmul_batch_invariant, key)
1106
+ _batch_invariant_LIB.impl("aten::linear", linear_batch_invariant, key)
1107
1108
_fp16_block_size_n = 128
1109
955
- _batch_invariant_LIB.impl("aten::_log_softmax", _log_softmax_batch_invariant, key)
956
- _batch_invariant_LIB.impl("aten::softmax", softmax_batch_invariant, key)
957
- _batch_invariant_LIB.impl("aten::_softmax", softmax_batch_invariant, key)
958
- _batch_invariant_LIB.impl("aten::mean.dim", mean_batch_invariant, key)
959
- # torch 2.12+ registers a built-in Triton bmm kernel for CUDA
960
- # (torch._native.ops.bmm_outer_product), so we need allow_override
961
- # to replace it at the dispatcher level.
962
- _batch_invariant_LIB.impl(
963
- "aten::bmm", bmm_batch_invariant, key, allow_override=True
964
- )
965
- torch.bmm = bmm_batch_invariant
1110
+ # Softmax, log_softmax, mean, and bmm are already batch-invariant on XPU
1111
+ # (oneDNN backend produces bitwise-identical results regardless of batch
1112
+ # context). Only register these overrides on CUDA where they are needed.
1113
+ if not current_platform.is_xpu():
1114
+ _batch_invariant_LIB.impl(
1115
+ "aten::_log_softmax", _log_softmax_batch_invariant, key
1116
+ )
1117
+ _batch_invariant_LIB.impl("aten::softmax", softmax_batch_invariant, key)
1118
+ _batch_invariant_LIB.impl("aten::_softmax", softmax_batch_invariant, key)
1119
+ _batch_invariant_LIB.impl("aten::mean.dim", mean_batch_invariant, key)
1120
+ # torch 2.12+ registers a built-in Triton bmm kernel for CUDA
1121
+ # (torch._native.ops.bmm_outer_product), so we need allow_override
1122
+ # to replace it at the dispatcher level.
1123
+ _batch_invariant_LIB.impl(
1124
+ "aten::bmm", bmm_batch_invariant, key, allow_override=True
1125
+ )
1126
+ torch.bmm = bmm_batch_invariant
1127
1128
reduced_precision_val = (
1129
(False, False) if is_torch_equal_or_newer("2.10.0") else False
vllm/model_executor/determinism/batch_invariant_configs.py
+50
@@ -327,3 +327,53 @@ def _get_matmul_config(
327
"num_warps": m_config.num_warps,
328
"num_stages": m_config.num_stages,
329
}
330
+
331
+
332
+def _get_descriptor_matmul_config(
333
+ M: int, N: int, K: int, dtype: torch.dtype
334
+) -> dict[str, int]:
335
+ """Select a config for the XPU descriptor matmul from shape and dtype.
336
+
337
+ The default 128×128×64 config is optimized for large square-ish GEMMs but
338
+ wastes resources on skinny shapes (small M during decode, or very tall/thin
339
+ matrices during prefill). Shape-dependent tuning closes the gap with oneDNN.
340
+
341
+ Only BLOCK_SIZE_M/N and the launch parameters vary with M; dtype alone
342
+ decides BLOCK_SIZE_K, so the K-reduction order never depends on batch size.
343
+ """
344
+ # fp32 uses smaller BLOCK_SIZE_K due to register pressure
345
+ block_k = 32 if dtype == torch.float32 else 64
346
+
347
+ if M <= 16:
348
+ # Decode: M=1-16. Tiny M means most of a 128-row tile is wasted.
349
+ # Use small M-block, wide N-block to maximize useful work per tile.
350
+ return {
351
+ "BLOCK_SIZE_M": 16,
352
+ "BLOCK_SIZE_N": 256,
353
+ "BLOCK_SIZE_K": block_k,
354
+ "GROUP_SIZE_M": 1,
355
+ "num_stages": 4,
356
+ "num_warps": 8,
357
+ }
358
+ elif M <= 64:
359
+ # Small batch decode or very short prefill.
360
+ return {
361
+ "BLOCK_SIZE_M": 32,
362
+ "BLOCK_SIZE_N": 128,
363
+ "BLOCK_SIZE_K": block_k,
364
+ "GROUP_SIZE_M": 4,
365
+ "num_stages": 4,
366
+ "num_warps": 8,
367
+ }
368
+ else:
369
+ # Medium and large prefill (M > 64). 64×128 tiles provide the best
370
+ # balance of register pressure vs parallelism on Intel XPU.
371
+ # M=2048, N=4096 → 32×32 = 1024 tiles, well above ~160 compute units.
372
+ return {
373
+ "BLOCK_SIZE_M": 64,
374
+ "BLOCK_SIZE_N": 128,
375
+ "BLOCK_SIZE_K": block_k,
376
+ "GROUP_SIZE_M": 8,
377
+ "num_stages": 3,
378
+ "num_warps": 8,
379
+ }
vllm/model_executor/layers/linear.py
+3
-1
@@ -228,7 +228,9 @@ class UnquantizedLinearMethod(LinearMethodBase):
228
x: torch.Tensor,
229
bias: torch.Tensor | None = None,
230
) -> torch.Tensor:
231
- if envs.VLLM_BATCH_INVARIANT and current_platform.is_cuda_alike():
231
+ if envs.VLLM_BATCH_INVARIANT and (
232
+ current_platform.is_cuda_alike() or current_platform.is_xpu()
233
+ ):
234
return linear_batch_invariant(x, layer.weight, bias)
235
return self._gemm_impl(layer, x, layer.weight, bias)
236
vllm/model_executor/layers/vocab_parallel_embedding.py
+3
-1
@@ -71,7 +71,9 @@ class UnquantizedEmbeddingMethod(QuantizeMethodBase):
71
x: torch.Tensor,
72
bias: torch.Tensor | None = None,
73
) -> torch.Tensor:
74
- if envs.VLLM_BATCH_INVARIANT and current_platform.is_cuda_alike():
74
+ if envs.VLLM_BATCH_INVARIANT and (
75
+ current_platform.is_cuda_alike() or current_platform.is_xpu()
76
+ ):
77
return linear_batch_invariant(x, layer.weight, bias)
78
return dispatch_unquantized_gemm()(layer, x, layer.weight, bias)
79