[Feat][XPU] VLLM_BATCH_INVARIANT support for Dense/MoE models (#55881)
Signed-off-by: Tony Lin <tony.lin@intel.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
Tony Lin committed
Sep 22, 2026 at 23:06 UTC
e4340e41c9a32b2247cc2442b35aa91b081c424f
10 files changed
+619
-8
.buildkite/intel_jobs/misc_intel.yaml
+29
-1
@@ -2,6 +2,34 @@ group: Miscellaneous Intel
2
depends_on:
3
- image-build-xpu
4
steps:
5
+- label: XPU Batch Invariance
6
+ key: xpu-batch-invariance
7
+ timeout_in_minutes: 60
8
+ num_devices: 2
9
+ device: intel_gpu
10
+ agent_tags:
11
+ label: production
12
+ gpu: 2+
13
+ mem: 24+
14
+ no_plugin: true
15
+ working_dir: "."
16
+ env:
17
+ REGISTRY: "public.ecr.aws/q9t5s3a7"
18
+ REPO: "vllm-ci-test-repo"
19
+ VLLM_TEST_DEVICE: "xpu"
20
+ source_file_dependencies:
21
+ - vllm/
22
+ - tests/v1/determinism/test_xpu_batch_invariant_ut.py
23
+ - .buildkite/scripts/hardware_ci/run-xpu-batch-invariance.sh
24
+ - .buildkite/intel_jobs/misc_intel.yaml
25
+ commands:
26
+ - >-
27
+ bash .buildkite/scripts/hardware_ci/run-intel-test.sh
28
+ 'export VLLM_BATCH_INVARIANT=1 &&
29
+ cd tests &&
30
+ pytest -v -s v1/determinism/test_xpu_batch_invariant_ut.py &&
31
+ bash ../.buildkite/scripts/hardware_ci/run-xpu-batch-invariance.sh'
32
+
33
- label: V1 Core + KV + Metrics
34
key: v1-core-kv-metrics-intel
35
timeout_in_minutes: 45
@@ -290,4 +318,4 @@ steps:
318
- >-
319
bash .buildkite/scripts/hardware_ci/run-intel-test.sh
320
'cd tests &&
293
- pytest -v -s compile/passes/test_qk_norm_rope_fusion.py'
\ No newline at end of file
321
+ pytest -v -s compile/passes/test_qk_norm_rope_fusion.py'
.buildkite/scripts/hardware_ci/run-xpu-batch-invariance.sh
new
+243
@@ -0,0 +1,243 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)"
5
+
6
+model="ibm-research/PowerMoE-3b"
7
+port=8000
8
+num_decode_tokens=128
9
+server_pid=
10
+server_log=
11
+
12
+# XPU MoE kernels may not safely handle the -1 routes emitted for padding.
13
+export VLLM_MOE_SKIP_PADDING=0
14
+
15
+cd "$repo_root"
16
+
17
+cleanup_server() {
18
+ local attempt
19
+
20
+ if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then
21
+ kill "$server_pid" 2>/dev/null || true
22
+
23
+ for ((attempt = 0; attempt < 30; attempt++)); do
24
+ if ! kill -0 "$server_pid" 2>/dev/null; then
25
+ break
26
+ fi
27
+ sleep 1
28
+ done
29
+
30
+ if kill -0 "$server_pid" 2>/dev/null; then
31
+ echo "vLLM did not stop after 30 seconds; sending SIGKILL" >&2
32
+ kill -KILL "$server_pid" 2>/dev/null || true
33
+ fi
34
+
35
+ wait "$server_pid" 2>/dev/null || true
36
+ fi
37
+
38
+ server_pid=
39
+}
40
+
41
+on_exit() {
42
+ local status=$?
43
+
44
+ cleanup_server
45
+ if [[ "$status" -ne 0 && -n "$server_log" && -f "$server_log" ]]; then
46
+ if command -v buildkite-agent >/dev/null 2>&1; then
47
+ buildkite-agent artifact upload "$server_log" ||
48
+ echo "Failed to upload $server_log as a Buildkite artifact" >&2
49
+ fi
50
+ fi
51
+ [[ -z "$server_log" ]] || rm -f "$server_log"
52
+ exit "$status"
53
+}
54
+trap on_exit EXIT
55
+
56
+wait_for_server() {
57
+ for _ in $(seq 1 600); do
58
+ if curl -sf "http://127.0.0.1:$port/health" >/dev/null; then
59
+ return
60
+ fi
61
+ if ! kill -0 "$server_pid" 2>/dev/null; then
62
+ echo "vLLM exited before becoming healthy; see the server log above." >&2
63
+ return 1
64
+ fi
65
+ sleep 1
66
+ done
67
+ echo "Timed out waiting for vLLM server health check" >&2
68
+ return 1
69
+}
70
+
71
+request_outputs() {
72
+ local prompts_json=$1
73
+ local expected_choices=$2
74
+ local response
75
+
76
+ response=$(printf \
77
+ '{"model":"%s","prompt":%s,"temperature":0.7,"top_p":0.95,"seed":0,"max_tokens":%s,"logprobs":64,"ignore_eos":true,"return_token_ids":true}' \
78
+ "$model" "$prompts_json" "$num_decode_tokens" |
79
+ curl --fail --silent --show-error \
80
+ --max-time 1200 \
81
+ "http://127.0.0.1:$port/v1/completions" \
82
+ -H "Content-Type: application/json" \
83
+ --data-binary @-)
84
+
85
+ local -a token_id_arrays logprob_arrays
86
+ mapfile -t token_id_arrays < <(
87
+ grep -o '"token_ids":\[[^]]*\]' <<<"$response" |
88
+ sed 's/^"token_ids":\[//; s/\]$//'
89
+ )
90
+ mapfile -t logprob_arrays < <(
91
+ grep -o '"token_logprobs":\[[^]]*\]' <<<"$response" |
92
+ sed 's/^"token_logprobs":\[//; s/\]$//'
93
+ )
94
+ if [[ "${#token_id_arrays[@]}" -ne "$expected_choices" ]] ||
95
+ [[ "${#logprob_arrays[@]}" -ne "$expected_choices" ]]; then
96
+ echo "Expected $expected_choices choices with token IDs and logits" >&2
97
+ return 1
98
+ fi
99
+ local index
100
+ local array
101
+ local value
102
+ local -a values
103
+ for index in "${!logprob_arrays[@]}"; do
104
+ array="${logprob_arrays[index]}"
105
+ IFS=, read -r -a values <<<"$array"
106
+ if [[ "${#values[@]}" -ne "$num_decode_tokens" ]]; then
107
+ echo "Completion returned an unexpected number of decode steps" >&2
108
+ return 1
109
+ fi
110
+ IFS=, read -r -a values <<<"${token_id_arrays[index]}"
111
+ if [[ "${#values[@]}" -ne "$num_decode_tokens" ]]; then
112
+ echo "Completion returned an unexpected number of generated tokens" >&2
113
+ return 1
114
+ fi
115
+ for value in "${values[@]}"; do
116
+ if [[ ! "$value" =~ ^[0-9]+$ ]]; then
117
+ echo "Completion returned an invalid token ID" >&2
118
+ return 1
119
+ fi
120
+ done
121
+ IFS=, read -r -a values <<<"$array"
122
+ for value in "${values[@]}"; do
123
+ if [[ ! "$value" =~ ^-?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$ ]]; then
124
+ echo "Completion returned non-finite raw logits" >&2
125
+ return 1
126
+ fi
127
+ done
128
+ printf '%s|%s\n' "${token_id_arrays[index]}" "${logprob_arrays[index]}"
129
+ done
130
+}
131
+
132
+make_prompt() {
133
+ local length=$1
134
+ local offset=${2:-0}
135
+ local i
136
+ local prompt="Use the following system-design context to answer the question. Scenario $offset:"
137
+ for ((i = 0; i < length; i++)); do
138
+ prompt+=" context"
139
+ done
140
+ prompt+="\\n\\nQuestion: How should batch-invariant inference behave when this same request is decoded alongside unrelated requests?\\nAnswer:"
141
+ printf '"%s"' "$prompt"
142
+}
143
+
144
+first_difference() {
145
+ local actual=$1
146
+ local expected=$2
147
+ local position=$3
148
+ local actual_tokens=${actual%%|*}
149
+ local expected_tokens=${expected%%|*}
150
+ local actual_logits=${actual#*|}
151
+ local expected_logits=${expected#*|}
152
+ local -a actual_token_values
153
+ local -a expected_token_values
154
+ local -a actual_logit_values
155
+ local -a expected_logit_values
156
+ local step
157
+
158
+ IFS=, read -r -a actual_token_values <<<"$actual_tokens"
159
+ IFS=, read -r -a expected_token_values <<<"$expected_tokens"
160
+ IFS=, read -r -a actual_logit_values <<<"$actual_logits"
161
+ IFS=, read -r -a expected_logit_values <<<"$expected_logits"
162
+ for ((step = 0; step < num_decode_tokens; step++)); do
163
+ if [[ "${actual_token_values[step]}" != "${expected_token_values[step]}" ]]; then
164
+ printf 'batch position %s: step=%s, baseline token=%s, actual token=%s' \
165
+ "$position" \
166
+ "$step" \
167
+ "${expected_token_values[step]}" \
168
+ "${actual_token_values[step]}"
169
+ return
170
+ fi
171
+ if [[ "${actual_logit_values[step]}" != "${expected_logit_values[step]}" ]]; then
172
+ printf 'batch position %s: step=%s, token=%s, baseline logit=%s, actual logit=%s' \
173
+ "$position" \
174
+ "$step" \
175
+ "${expected_token_values[step]}" \
176
+ "${expected_logit_values[step]}" \
177
+ "${actual_logit_values[step]}"
178
+ return
179
+ fi
180
+ done
181
+}
182
+
183
+run_e2e() {
184
+ local needle
185
+ local batch
186
+ local baseline_output
187
+ local batch_response
188
+ local difference
189
+ local position
190
+ local -a fillers
191
+ local -a batch_outputs
192
+ local -a differences=()
193
+
194
+ echo "Testing PowerMoE-3b: TP=2, VLLM_BATCH_INVARIANT=1, VLLM_MOE_SKIP_PADDING=0"
195
+ server_log=$(mktemp "${TMPDIR:-/tmp}/xpu-batch-invariance.XXXXXX.log")
196
+ VLLM_BATCH_INVARIANT=1 vllm serve "$model" \
197
+ --tensor-parallel-size 2 \
198
+ --max-model-len 1024 \
199
+ --max-num-seqs 16 \
200
+ --max-num-batched-tokens 512 \
201
+ --enable-chunked-prefill \
202
+ --no-enable-prefix-caching \
203
+ --enforce-eager \
204
+ --seed 0 \
205
+ --max-logprobs 64 \
206
+ --logprobs-mode raw_logits \
207
+ --port "$port" > >(tee "$server_log") 2>&1 &
208
+ server_pid=$!
209
+ wait_for_server
210
+
211
+ needle=$(make_prompt 257)
212
+ local filler_lengths=(7 31 63 95 127 159 191 223 255 287 319 383 447)
213
+ for position in "${!filler_lengths[@]}"; do
214
+ fillers+=( "$(make_prompt "${filler_lengths[position]}" "$((position + 1))")" )
215
+ done
216
+ batch="[$needle,${fillers[0]},${fillers[1]},${fillers[2]},${fillers[3]},${fillers[4]},${fillers[5]},$needle,${fillers[6]},${fillers[7]},${fillers[8]},${fillers[9]},${fillers[10]},${fillers[11]},${fillers[12]},$needle]"
217
+
218
+ echo "Requesting BS=1 baseline ($num_decode_tokens decode tokens)..."
219
+ baseline_output=$(request_outputs "$needle" 1)
220
+ echo "Received BS=1 baseline."
221
+ echo "Requesting BS=16 batch ($((16 * num_decode_tokens)) decode tokens)..."
222
+ batch_response=$(request_outputs "$batch" 16)
223
+ mapfile -t batch_outputs <<<"$batch_response"
224
+ echo "Received BS=16 batch; comparing generated tokens and logits..."
225
+
226
+ for position in 0 7 15; do
227
+ difference=$(first_difference "${batch_outputs[position]}" "$baseline_output" "$position")
228
+ if [[ -n "$difference" ]]; then
229
+ differences+=("$difference")
230
+ fi
231
+ done
232
+
233
+ if [[ "${#differences[@]}" -ne 0 ]]; then
234
+ echo "Batch-invariant PowerMoE-3b output changed: ${differences[*]}" >&2
235
+ return 1
236
+ fi
237
+ echo "PowerMoE-3b batch invariance check passed"
238
+ cleanup_server
239
+ rm -f "$server_log"
240
+ server_log=
241
+}
242
+
243
+run_e2e
tests/v1/determinism/test_xpu_batch_invariant_ut.py
new
+228
@@ -0,0 +1,228 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""XPU-only batch-invariance tests for norm, collectives, and sampler behavior."""
4
+
5
+import pytest
6
+import torch
7
+
8
+from vllm import envs
9
+from vllm.config import CacheConfig, LoadConfig, ModelConfig, VllmConfig
10
+from vllm.distributed.device_communicators.xpu_communicator import XpuCommunicator
11
+from vllm.platforms import current_platform
12
+
13
+pytestmark = pytest.mark.skipif(
14
+ not current_platform.is_xpu(), reason="XPU batch-invariance tests"
15
+)
16
+
17
+
18
+@pytest.fixture
19
+def tiny_qwen3_moe_path(tmp_path):
20
+ """Create a local Qwen3-MoE config for dummy-loading tests."""
21
+ from transformers import Qwen3MoeConfig
22
+
23
+ config = Qwen3MoeConfig(
24
+ architectures=["Qwen3MoeForCausalLM"],
25
+ vocab_size=256,
26
+ hidden_size=512,
27
+ intermediate_size=1024,
28
+ moe_intermediate_size=512,
29
+ num_hidden_layers=2,
30
+ num_attention_heads=4,
31
+ num_key_value_heads=2,
32
+ head_dim=128,
33
+ num_experts=4,
34
+ num_experts_per_tok=2,
35
+ max_position_embeddings=512,
36
+ bos_token_id=1,
37
+ eos_token_id=2,
38
+ tie_word_embeddings=False,
39
+ )
40
+ config.save_pretrained(tmp_path)
41
+ return tmp_path
42
+
43
+
44
+@pytest.mark.parametrize("source", ["argument", "checkpoint"])
45
+def test_rejects_weight_quantization(tiny_qwen3_moe_path, source):
46
+ """Reject both explicit and checkpoint-inferred quantization before loading."""
47
+ from transformers import Qwen3MoeConfig
48
+
49
+ if source == "checkpoint":
50
+ config = Qwen3MoeConfig.from_pretrained(tiny_qwen3_moe_path)
51
+ config.quantization_config = {
52
+ "quant_method": "fp8",
53
+ "activation_scheme": "dynamic",
54
+ }
55
+ config.save_pretrained(tiny_qwen3_moe_path)
56
+
57
+ model_config = ModelConfig(
58
+ model=str(tiny_qwen3_moe_path),
59
+ skip_tokenizer_init=True,
60
+ dtype="bfloat16",
61
+ quantization="fp8" if source == "argument" else None,
62
+ )
63
+ assert model_config.quantization == "fp8"
64
+ with pytest.raises(ValueError, match="supports only unquantized models"):
65
+ VllmConfig(
66
+ model_config=model_config,
67
+ load_config=LoadConfig(load_format="dummy"),
68
+ )
69
+
70
+
71
+@pytest.mark.parametrize("cache_dtype", ["fp8", "int8_per_token_head", "nvfp4"])
72
+def test_rejects_quantized_kv_cache(cache_dtype):
73
+ with pytest.raises(ValueError, match="does not support quantized KV caches"):
74
+ VllmConfig(cache_config=CacheConfig(cache_dtype=cache_dtype))
75
+
76
+
77
+@pytest.mark.parametrize("cache_dtype", ["auto", "float16", "bfloat16"])
78
+def test_accepts_unquantized_kv_cache(cache_dtype):
79
+ config = VllmConfig(cache_config=CacheConfig(cache_dtype=cache_dtype))
80
+ assert config.cache_config.cache_dtype == cache_dtype
81
+
82
+
83
+def test_quantized_kv_cache_allowed_without_batch_invariance(monkeypatch):
84
+ monkeypatch.setattr(envs, "VLLM_BATCH_INVARIANT", False)
85
+ config = VllmConfig(cache_config=CacheConfig(cache_dtype="fp8"))
86
+ assert config.cache_config.cache_dtype == "fp8"
87
+
88
+
89
+# These dimensions and seeds locally reproduce non-invariant norm mismatches.
90
+# They make the invariant equality assertion exercise a discriminating case.
91
+@pytest.mark.parametrize(
92
+ (
93
+ "norm_name",
94
+ "batch_size",
95
+ "hidden_size",
96
+ "position",
97
+ "weight_seed",
98
+ "input_seed",
99
+ "input_pattern",
100
+ ),
101
+ [
102
+ ("rms", 1024, 16384, 512, 7, 42, "normal"),
103
+ ("gemma", 257, 8192, 255, 1, 2, "normal"),
104
+ ("rms", 5, 8192, 3, 7, 46, "scaled"),
105
+ ("gemma", 5, 8192, 3, 1, 46, "scaled"),
106
+ ],
107
+)
108
+def test_residual_norm_preserves_batch_invariance(
109
+ norm_name,
110
+ batch_size,
111
+ hidden_size,
112
+ position,
113
+ weight_seed,
114
+ input_seed,
115
+ input_pattern,
116
+ monkeypatch,
117
+ default_vllm_config,
118
+):
119
+ """Residual norm output must not change when its row changes batch position."""
120
+ from vllm.model_executor.determinism.batch_invariant import init_batch_invariance
121
+ from vllm.model_executor.layers.layernorm import GemmaRMSNorm, RMSNorm
122
+
123
+ monkeypatch.setattr(envs, "VLLM_BATCH_INVARIANT", True)
124
+ init_batch_invariance()
125
+ norm_cls = RMSNorm if norm_name == "rms" else GemmaRMSNorm
126
+ norm = norm_cls(hidden_size, eps=1e-6).to(device="xpu", dtype=torch.bfloat16)
127
+ with torch.no_grad():
128
+ torch.manual_seed(weight_seed)
129
+ norm.weight.uniform_(-0.5, 0.5)
130
+
131
+ if input_pattern == "scaled":
132
+ # Heterogeneous columns expose native mean's batch-dependent rounding.
133
+ generator = torch.Generator(device="cpu").manual_seed(input_seed)
134
+ scale = torch.randn(hidden_size, generator=generator).exp()
135
+ x = (torch.randn(batch_size, hidden_size, generator=generator) * scale).to(
136
+ device="xpu", dtype=torch.bfloat16
137
+ )
138
+ residual = (
139
+ torch.randn(batch_size, hidden_size, generator=generator) * scale
140
+ ).to(device="xpu", dtype=torch.bfloat16)
141
+ else:
142
+ torch.manual_seed(input_seed)
143
+ x = torch.randn(batch_size, hidden_size, device="xpu", dtype=torch.bfloat16)
144
+ residual = torch.randn_like(x)
145
+
146
+ single_output, single_residual = norm(
147
+ x[position : position + 1].clone(),
148
+ residual[position : position + 1].clone(),
149
+ )
150
+ batch_output, batch_residual = norm(x.clone(), residual.clone())
151
+
152
+ assert torch.equal(single_output, batch_output[position : position + 1])
153
+ assert torch.equal(single_residual, batch_residual[position : position + 1])
154
+
155
+
156
+@pytest.mark.parametrize(
157
+ "operation", ["all_reduce", "reduce_scatter", "reduce_scatterv"]
158
+)
159
+@pytest.mark.parametrize("rank", range(4))
160
+def test_collectives_preserve_rank_order_and_partition(monkeypatch, operation, rank):
161
+ """Cancellation exposes reordered sums; mock ranks require no extra XPUs."""
162
+ from vllm.distributed.device_communicators import xpu_communicator
163
+
164
+ inputs = [
165
+ torch.full((8, 3), value, dtype=torch.float32, device="xpu")
166
+ for value in (1e20, -1e20, 3.0, 0.0)
167
+ ]
168
+ inputs[2].add_(torch.arange(24, device="xpu").reshape(8, 3))
169
+
170
+ def all_gather_single(output, input_, group):
171
+ torch.testing.assert_close(input_, inputs[rank].reshape(-1), rtol=0, atol=0)
172
+ output.copy_(torch.stack(inputs).reshape(-1))
173
+
174
+ monkeypatch.setattr(xpu_communicator.dist, "all_gather_single", all_gather_single)
175
+ communicator = XpuCommunicator.__new__(XpuCommunicator)
176
+ communicator.world_size = 4
177
+ communicator.rank_in_group = rank
178
+ communicator.device_group = object()
179
+ expected = inputs[0].clone()
180
+ for value in inputs[1:]:
181
+ expected.add_(value)
182
+ assert torch.equal(expected, inputs[2])
183
+
184
+ if operation == "all_reduce":
185
+ actual = communicator.all_reduce(inputs[rank])
186
+ elif operation == "reduce_scatter":
187
+ actual = communicator.reduce_scatter(inputs[rank], dim=0)
188
+ expected = expected.chunk(4)[rank]
189
+ else:
190
+ sizes = [1, 3, 2, 2]
191
+ actual = communicator.reduce_scatterv(inputs[rank], dim=0, sizes=sizes)
192
+ expected = expected.split(sizes)[rank]
193
+ torch.testing.assert_close(actual, expected, rtol=0, atol=0)
194
+
195
+
196
+# This batch size, vocabulary size, and positions locally reproduce an
197
+# unseeded non-invariant XPU sampler mismatch.
198
+@pytest.mark.parametrize("position", [0, 128, 255])
199
+def test_seeded_sampler_preserves_full_distribution_and_rng(position, monkeypatch):
200
+ """Moving a request must preserve its distribution and per-request RNG."""
201
+ from vllm.triton_utils import HAS_TRITON
202
+ from vllm.v1.sample.ops.topk_topp_sampler import TopKTopPSampler
203
+
204
+ assert HAS_TRITON, "XPU sampling must exercise the Triton top-k/top-p path"
205
+ monkeypatch.setattr(envs, "VLLM_XPU_USE_SAMPLER_KERNEL", True)
206
+ sampler = TopKTopPSampler(logprobs_mode="processed_logprobs")
207
+ torch.manual_seed(42)
208
+ logits = torch.randn(256, 8192, device="xpu", dtype=torch.float32)
209
+ single_rng = torch.Generator(device="xpu").manual_seed(123)
210
+ batch_rng = torch.Generator(device="xpu").manual_seed(123)
211
+ k = torch.full((256,), 32, dtype=torch.int32, device="xpu")
212
+ p = torch.full((256,), 0.9, device="xpu")
213
+ for _ in range(3):
214
+ single_token, single_logprobs = sampler(
215
+ logits[position : position + 1].clone(),
216
+ {0: single_rng},
217
+ k[position : position + 1],
218
+ p[position : position + 1],
219
+ )
220
+ batch_tokens, batch_logprobs = sampler(
221
+ logits.clone(), {position: batch_rng}, k, p
222
+ )
223
+ assert single_logprobs is not None and batch_logprobs is not None
224
+ torch.testing.assert_close(
225
+ single_logprobs[0], batch_logprobs[position], rtol=0, atol=0
226
+ )
227
+ assert single_token.item() == batch_tokens[position].item()
228
+ assert torch.equal(single_rng.get_state(), batch_rng.get_state())
vllm/distributed/device_communicators/xpu_communicator.py
+35
-3
@@ -6,6 +6,7 @@ import torch
6
import torch.distributed as dist
7
from torch.distributed import ProcessGroup
8
9
+import vllm.envs as envs
10
from vllm.logger import init_logger
11
12
from .base_device_communicator import DeviceCommunicatorBase
@@ -45,7 +46,25 @@ class XpuCommunicator(DeviceCommunicatorBase):
46
self.all2all_manager = AgRsAll2AllManager(self.cpu_group)
47
logger.info("Using AgRs manager on XPU device.")
48
49
+ def _fixed_rank_sum(self, input_: torch.Tensor) -> torch.Tensor:
50
+ flat_input = input_.reshape(-1)
51
+ gathered = torch.empty(
52
+ (self.world_size, flat_input.numel()),
53
+ dtype=input_.dtype,
54
+ device=input_.device,
55
+ )
56
+ dist.all_gather_single(gathered.view(-1), flat_input, group=self.device_group)
57
+ output = gathered[0].clone()
58
+ for rank in range(1, self.world_size):
59
+ output.add_(gathered[rank])
60
+ return output.view(input_.shape)
61
+
62
def all_reduce(self, input_: torch.Tensor) -> torch.Tensor:
63
+ if envs.VLLM_BATCH_INVARIANT:
64
+ # XCCL changes its reduction order with message size. Gather first
65
+ # and accumulate in rank order, independent of the token batch.
66
+ return self._fixed_rank_sum(input_)
67
+
68
output = input_.clone()
69
dist.all_reduce(output, group=self.device_group)
70
return output
@@ -69,7 +88,12 @@ class XpuCommunicator(DeviceCommunicatorBase):
88
output_shape, dtype=input_tensor.dtype, device=input_tensor.device
89
)
90
72
- dist.reduce_scatter_tensor(output, input_tensor, group=self.device_group)
91
+ if envs.VLLM_BATCH_INVARIANT:
92
+ reduced = self._fixed_rank_sum(input_tensor)
93
+ start = self.rank_in_group * chunk_size
94
+ output.copy_(reduced.narrow(0, start, chunk_size))
95
+ else:
96
+ dist.reduce_scatter_tensor(output, input_tensor, group=self.device_group)
97
98
# Reshape before returning
99
return output.movedim(0, dim).contiguous()
@@ -99,7 +123,15 @@ class XpuCommunicator(DeviceCommunicatorBase):
123
output = torch.empty(
124
output_shape, dtype=input_tensor.dtype, device=input_tensor.device
125
)
102
- if sizes is None or sizes.count(sizes[0]) == len(sizes):
126
+ if envs.VLLM_BATCH_INVARIANT:
127
+ reduced = self._fixed_rank_sum(input_tensor)
128
+ start = (
129
+ self.rank_in_group * chunk_size
130
+ if sizes is None
131
+ else sum(sizes[: self.rank_in_group])
132
+ )
133
+ output.copy_(reduced.narrow(0, start, chunk_size))
134
+ elif sizes is None or sizes.count(sizes[0]) == len(sizes):
135
dist.reduce_scatter_tensor(output, input_tensor, group=self.device_group)
136
elif torch.xpu.device_count() < self.world_size:
137
# uneven reduce_scatter is not supported when the
@@ -191,7 +223,7 @@ class XpuCommunicator(DeviceCommunicatorBase):
223
(self.world_size,) + input_size, dtype=input_.dtype, device=input_.device
224
)
225
# All-gather.
194
- dist.all_gather_into_tensor(output_tensor, input_, group=self.device_group)
226
+ dist.all_gather_single(output_tensor, input_, group=self.device_group)
227
if self.rank_in_group == dst:
228
# Reshape
229
output_tensor = output_tensor.movedim(0, dim)
vllm/model_executor/determinism/batch_invariant.py
+4
-2
@@ -1104,7 +1104,10 @@ def enable_batch_invariant_mode():
1104
1105
_fp16_block_size_n = 128
1106
1107
- # Softmax, log_softmax, mean, and bmm are already batch-invariant on XPU
1107
+ # Native mean can change reduction order with the row count on XPU too.
1108
+ _batch_invariant_LIB.impl("aten::mean.dim", mean_batch_invariant, key)
1109
+
1110
+ # Softmax, log_softmax, 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():
@@ -1113,7 +1116,6 @@ def enable_batch_invariant_mode():
1116
)
1117
_batch_invariant_LIB.impl("aten::softmax", softmax_batch_invariant, key)
1118
_batch_invariant_LIB.impl("aten::_softmax", softmax_batch_invariant, key)
1116
- _batch_invariant_LIB.impl("aten::mean.dim", mean_batch_invariant, key)
1119
# torch 2.12+ registers a built-in Triton bmm kernel for CUDA
1120
# (torch._native.ops.bmm_outer_product), so we need allow_override
1121
# to replace it at the dispatcher level.
vllm/model_executor/layers/fused_moe/oracle/unquantized.py
+7
-1
@@ -94,7 +94,13 @@ def _get_priority_backends(moe_config: FusedMoEConfig) -> list[UnquantizedMoeBac
94
_move_to_back(_AVAILABLE_BACKENDS, UnquantizedMoeBackend.FLASHINFER_CUTLASS)
95
96
elif current_platform.is_xpu():
97
- _AVAILABLE_BACKENDS = [UnquantizedMoeBackend.XPU]
97
+ # XPUExperts does not support batch invariance; TRITON is kept as a
98
+ # fallback so VLLM_BATCH_INVARIANT=1 still resolves to a valid
99
+ # (batch-invariant) backend on XPU.
100
+ _AVAILABLE_BACKENDS = [
101
+ UnquantizedMoeBackend.XPU,
102
+ UnquantizedMoeBackend.TRITON,
103
+ ]
104
elif current_platform.is_cpu():
105
_AVAILABLE_BACKENDS = [UnquantizedMoeBackend.CPU]
106
return _AVAILABLE_BACKENDS
vllm/model_executor/layers/layernorm.py
+17
@@ -119,6 +119,14 @@ class RMSNorm(CustomOp):
119
x: torch.Tensor,
120
residual: torch.Tensor | None = None,
121
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
122
+ if envs.VLLM_BATCH_INVARIANT and residual is not None:
123
+ assert self.variance_size_override is None, (
124
+ "Batch invariance is not supported for variance_size_override"
125
+ )
126
+ weight = self.weight.data if self.pass_weight_add else None
127
+ return ir.ops.fused_add_rms_norm.impls["native"].impl_fn(
128
+ x, residual, weight, self.variance_epsilon
129
+ )
130
return self.forward_cuda(x, residual)
131
132
def extra_repr(self) -> str:
@@ -171,6 +179,15 @@ class GemmaRMSNorm(CustomOp):
179
x: torch.Tensor,
180
residual: torch.Tensor | None = None,
181
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
182
+ if envs.VLLM_BATCH_INVARIANT:
183
+ if residual is None:
184
+ return self.forward_cuda(x, residual)
185
+ return ir.ops.fused_add_rms_norm.impls["native"].impl_fn(
186
+ x,
187
+ residual,
188
+ self.weight.float() + 1.0,
189
+ self.variance_epsilon,
190
+ )
191
import vllm._xpu_ops # noqa: F401 registers torch.ops.vllm.xpu_gemma_rms_norm
192
193
# Fall back to the native path if the fused gemma kernels are not
vllm/platforms/xpu.py
+40
@@ -162,6 +162,25 @@ class XPUPlatform(Platform):
162
if selected_backend == AttentionBackendEnum.TRITON_ATTN:
163
logger.info_once("Using Triton backend.")
164
return AttentionBackendEnum.TRITON_ATTN.get_path()
165
+ elif attn_selector_config.use_batch_invariant:
166
+ # Flash Attention on XPU has not been validated for batch
167
+ # invariance. Honor an explicit Flash Attention request;
168
+ # otherwise fall back to Triton Attention, which implements
169
+ # batch-invariant kernels.
170
+ if selected_backend == AttentionBackendEnum.FLASH_ATTN:
171
+ logger.warning_once(
172
+ "Using Flash Attention on XPU with batch invariance "
173
+ "enabled because it was explicitly requested. This "
174
+ "backend has not been validated for batch invariance "
175
+ "on XPU and may produce non-deterministic results "
176
+ "across batch sizes."
177
+ )
178
+ return AttentionBackendEnum.FLASH_ATTN.get_path()
179
+ logger.info_once(
180
+ "VLLM_BATCH_INVARIANT is enabled. Using Triton Attention "
181
+ "backend on XPU, which implements batch-invariant kernels."
182
+ )
183
+ return AttentionBackendEnum.TRITON_ATTN.get_path()
184
elif attn_selector_config.use_mm_prefix:
185
# Flash Attention on XPU has no FA4 kernel, so it cannot apply the
186
# multimodal prefix-LM bidirectional mask. Honor an explicit Flash
@@ -283,6 +302,27 @@ class XPUPlatform(Platform):
302
# lazy import to avoid circular import
303
from vllm.config import CUDAGraphMode
304
305
+ if envs.VLLM_BATCH_INVARIANT:
306
+ model_config = vllm_config.model_config
307
+ if model_config is not None and (
308
+ model_config.quantization is not None
309
+ or vllm_config.quant_config is not None
310
+ ):
311
+ raise ValueError(
312
+ "XPU batch invariance currently supports only unquantized "
313
+ f"models; got quantization={model_config.quantization!r}. "
314
+ "Use an unquantized model or disable VLLM_BATCH_INVARIANT."
315
+ )
316
+
317
+ cache_dtype = vllm_config.cache_config.cache_dtype
318
+ if cache_dtype not in ("auto", "float16", "bfloat16"):
319
+ raise ValueError(
320
+ "XPU batch invariance currently does not support quantized "
321
+ f"KV caches; got kv_cache_dtype={cache_dtype!r}. "
322
+ "Use an unquantized KV cache dtype or disable "
323
+ "VLLM_BATCH_INVARIANT."
324
+ )
325
+
326
compilation_config = vllm_config.compilation_config
327
if compilation_config.compile_sizes is None:
328
compilation_config.compile_sizes = []
vllm/sampling_params.py
+10
@@ -526,6 +526,16 @@ class SamplingParams(
526
527
self._verify_args()
528
529
+ if (
530
+ envs.VLLM_BATCH_INVARIANT
531
+ and self.temperature >= _SAMPLING_EPS
532
+ and self.seed is None
533
+ ):
534
+ logger.warning_once(
535
+ "Random sampling without an explicit seed may not be batch "
536
+ "invariant. Set seed in SamplingParams or use temperature=0."
537
+ )
538
+
539
if self.temperature < _SAMPLING_EPS:
540
# Zero temperature means greedy sampling.
541
self.top_p = 1.0
vllm/v1/sample/ops/topk_topp_sampler.py
+6
-1
@@ -224,9 +224,14 @@ class TopKTopPSampler(nn.Module):
224
else:
225
self.forward = self.forward_cpu
226
elif current_platform.is_xpu():
227
- if xpu_sampler_supported():
227
+ if xpu_sampler_supported() and not envs.VLLM_BATCH_INVARIANT:
228
self.forward = self.forward_xpu
229
else:
230
+ if envs.VLLM_BATCH_INVARIANT:
231
+ logger.info_once(
232
+ "VLLM_BATCH_INVARIANT is enabled. Using the "
233
+ "PyTorch-native sampler on XPU."
234
+ )
235
self.forward = self.forward_native
236
elif (
237
logprobs_mode not in PROCESSED_LOGPROBS_MODES