[ROCm] Fix CI runtime and tests for MI355 DPX (#58244)
Signed-off-by: Andreas Karatzas <Andreas.Karatzas@amd.com> Co-authored-by: Mahesh Kunreddi <mahesh.kunreddi@amd.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Andreas Karatzas <Andreas.Karatzas@amd.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>
mkunredd committed
Sep 24, 2026 at 23:15 UTC
4013f3ad6ceafb063807a62c75bd6828e66913bf
12 files changed
+175
-47
.buildkite/scripts/hardware_ci/run-amd-test.sh
+36
-5
@@ -605,6 +605,33 @@ initialize_native_environment() {
605
fi
606
}
607
608
+check_dpx_gpu_exclusivity() {
609
+ local devices=(/dev/dri/renderD*)
610
+ local device_id lock_status
611
+
612
+ if [[ "${#devices[@]}" -ne 1 || ! -c "${devices[0]}" ]]; then
613
+ echo "DPX guard requires exactly one render device; refusing to start tests." >&2
614
+ return 1
615
+ fi
616
+ # This queue mounts the same host-local HF cache into every pod on the node.
617
+ if ! mountpoint -q "${HF_HOME}"; then
618
+ echo "DPX guard requires the shared HF cache mount; refusing to start tests." >&2
619
+ return 1
620
+ fi
621
+ device_id=$(stat -Lc '%t-%T' "${devices[0]}") || return 1
622
+ mkdir -p "${HF_HOME}/.vllm-dpx-locks" || return 1
623
+ # Hold the descriptor through workload and teardown; never delete the file.
624
+ exec {dpx_gpu_lock_fd}>>"${HF_HOME}/.vllm-dpx-locks/${device_id}.lock" || return 1
625
+ flock -n -E 75 "${dpx_gpu_lock_fd}" && return 0
626
+ lock_status=$?
627
+ if [[ "${lock_status}" -eq 75 ]]; then
628
+ echo "DPX GPU collision: ${devices[0]} (${device_id}) is already locked by another CI job; refusing to start tests." >&2
629
+ else
630
+ echo "DPX GPU lock failed (status ${lock_status}); refusing to start tests." >&2
631
+ fi
632
+ return 1
633
+}
634
+
635
run_native_preflight() {
636
local expected_gpus="${VLLM_CI_EXPECTED_GPU_COUNT:-1}"
637
@@ -1166,15 +1193,15 @@ collect_rocm_failure_diagnostics() {
1193
# Bus data identifies a card within the public node without publishing its
1194
# persistent UUID, serial number, or process list.
1195
run_failure_diagnostic "${diagnostics_path}" "${probe_deadline}" \
1169
- amd-smi static -b -g all
1196
+ amd-smi static --bus --gpu all
1197
run_failure_diagnostic "${diagnostics_path}" "${probe_deadline}" \
1171
- amd-smi metric -e -k -P -x -g all
1198
+ amd-smi metric --ecc --ecc-blocks --pcie --gpu all
1199
run_failure_diagnostic "${diagnostics_path}" "${probe_deadline}" \
1173
- amd-smi bad-pages -p -r -u -g all
1200
+ amd-smi static --ras --gpu all
1201
run_failure_diagnostic "${diagnostics_path}" "${probe_deadline}" \
1175
- amd-smi metric -p -t -u -m -v -g all
1202
+ amd-smi metric --power --temperature --usage --mem-usage --gpu all
1203
run_failure_diagnostic "${diagnostics_path}" "${probe_deadline}" \
1177
- amd-smi xgmi -l -g all
1204
+ amd-smi xgmi --link-status --gpu all
1205
elif [[ "${amd_diagnostics_expected_gpu_count}" != "0" ]] \
1206
&& command -v rocm-smi >/dev/null 2>&1; then
1207
run_failure_diagnostic "${diagnostics_path}" "${probe_deadline}" rocm-smi \
@@ -1485,6 +1512,10 @@ if is_native_runtime; then
1512
echo "Failed to initialize the native test environment"
1513
exit 1
1514
fi
1515
+ if [[ "${BUILDKITE_AGENT_META_DATA_QUEUE:-}" == *dpx* \
1516
+ && "${VLLM_CI_EXPECTED_GPU_COUNT:-1}" != "0" ]]; then
1517
+ check_dpx_gpu_exclusivity || exit 1
1518
+ fi
1519
if [[ "${commands}" == *python_only_compile.sh* ]]; then
1520
# This no-GPU job validates the ROCm precompiled/editable install path,
1521
# rather than CPU runtime platform selection.
tests/basic_correctness/test_mem.py
+6
-4
@@ -364,7 +364,7 @@ def test_end_to_end(model: str):
364
365
366
@create_new_process_for_each_test()
367
-def test_deep_sleep():
367
+def test_deep_sleep(gpu_memory_cleared):
368
model = "hmellor/tiny-random-LlamaForCausalLM"
369
free, total = torch.accelerator.get_memory_info()
370
used_bytes_baseline = total - free # in case other process is running
@@ -395,7 +395,7 @@ def test_deep_sleep():
395
396
397
@create_new_process_for_each_test()
398
-def test_deep_sleep_lora():
398
+def test_deep_sleep_lora(gpu_memory_cleared):
399
"""Level-2 sleep/wake/reload with enable_lora=True.
400
401
LoRA wrapping moves parameters under base_layer and adds LoRA
@@ -488,7 +488,7 @@ def test_deep_sleep_lora_tp2(num_gpus_available, monkeypatch):
488
489
490
@create_new_process_for_each_test()
491
-def test_deep_sleep_async():
491
+def test_deep_sleep_async(gpu_memory_cleared):
492
async def test():
493
model = "hmellor/tiny-random-LlamaForCausalLM"
494
free, total = torch.accelerator.get_memory_info()
@@ -527,7 +527,9 @@ def test_deep_sleep_async():
527
528
529
@requires_fp8
530
-def test_deep_sleep_fp8_kvcache_mrv1(monkeypatch: pytest.MonkeyPatch):
530
+def test_deep_sleep_fp8_kvcache_mrv1(
531
+ monkeypatch: pytest.MonkeyPatch, gpu_memory_cleared
532
+):
533
# Regression test for https://github.com/vllm-project/vllm/pull/28783.
534
# In particular, verify that MRV1 does not rely on post_kv_cache_wake_up()
535
# to restore correct output after level-2 sleep.
tests/conftest.py
+18
@@ -1790,6 +1790,24 @@ def disable_deepgemm_ue8m0(monkeypatch):
1790
is_deep_gemm_e8m0_used.cache_clear()
1791
1792
1793
+@pytest.fixture
1794
+def gpu_memory_cleared():
1795
+ """Wait for prior tests to release GPU memory on gfx950."""
1796
+ if not current_platform.is_rocm():
1797
+ return
1798
+
1799
+ from tests.utils import wait_for_gpu_memory_to_clear
1800
+ from vllm.platforms.rocm import on_gfx950
1801
+
1802
+ if on_gfx950():
1803
+ wait_for_gpu_memory_to_clear(
1804
+ devices=[0],
1805
+ threshold_ratio=0.08,
1806
+ timeout_s=30,
1807
+ stable_duration_s=1,
1808
+ )
1809
+
1810
+
1811
def _should_clean_gpu_memory_between_tests() -> bool:
1812
# This must stay opt-in: a function-scoped fixture cannot distinguish
1813
# stale VRAM from allocations owned by longer-lived module/session fixtures.
tests/evals/gsm8k/configs/models-gfx950-small.txt
new
+2
@@ -0,0 +1,2 @@
1
+Qwen3-0.6B-FP8.yaml
2
+Qwen3-1.7B-MXFP4.yaml
tests/kernels/moe/test_block_int8.py
+31
-2
@@ -12,6 +12,9 @@ from tests.kernels.quant_utils import (
12
from vllm.config import VllmConfig, set_current_vllm_config
13
from vllm.model_executor.layers.activation import SiluAndMul
14
from vllm.model_executor.layers.fused_moe import fused_experts, fused_topk
15
+from vllm.model_executor.layers.quantization.utils.int8_utils import (
16
+ per_token_group_quant_int8,
17
+)
18
from vllm.platforms import current_platform
19
20
if current_platform.get_device_capability() < (7, 0):
@@ -64,7 +67,7 @@ def torch_w8a8_block_int8_moe(a, w1, w2, w1_s, w2_s, score, topk, block_shape):
67
topk_ids = topk_ids.view(-1)
68
69
_, block_k = block_shape[0], block_shape[1]
67
- a_q, a_s = native_per_token_group_quant_int8(a, block_k)
70
+ a_q, a_s = native_per_token_group_quant_int8(a, block_k, round_to_nearest=False)
71
for i in range(w1.shape[0]):
72
mask = topk_ids == i
73
if mask.sum():
@@ -72,7 +75,9 @@ def torch_w8a8_block_int8_moe(a, w1, w2, w1_s, w2_s, score, topk, block_shape):
75
a_q[mask], w1[i], a_s[mask], w1_s[i], block_shape, output_dtype=a.dtype
76
)
77
act_out = SiluAndMul().forward_native(inter_out)
75
- act_out_q, act_out_s = native_per_token_group_quant_int8(act_out, block_k)
78
+ act_out_q, act_out_s = native_per_token_group_quant_int8(
79
+ act_out, block_k, round_to_nearest=False
80
+ )
81
act_out = act_out.to(torch.float32)
82
out[mask] = native_w8a8_block_matmul(
83
act_out_q, w2[i], act_out_s, w2_s[i], block_shape, output_dtype=a.dtype
@@ -132,3 +137,27 @@ def test_w8a8_block_int8_fused_moe(M, N, K, E, topk, block_size, dtype, seed):
137
138
# Check results
139
torch.testing.assert_close(out, ref_out, atol=0.065, rtol=0.065)
140
+
141
+
142
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32])
143
+def test_block_int8_quantization_truncates_fractional_values(dtype):
144
+ """Block INT8 quantization truncates, unlike per-token round-to-nearest."""
145
+ device = current_platform.device_type
146
+ x = torch.tensor(
147
+ [[-127, -1.75, -0.75, 0.75, 1.75, 127] + [0] * 122],
148
+ dtype=dtype,
149
+ device=device,
150
+ )
151
+ expected = torch.tensor(
152
+ [[-127, -1, 0, 0, 1, 127] + [0] * 122], dtype=torch.int8, device=device
153
+ )
154
+
155
+ out_q, scale = per_token_group_quant_int8(x, group_size=128)
156
+ ref_q, ref_s = native_per_token_group_quant_int8(
157
+ x, group_size=128, round_to_nearest=False
158
+ )
159
+
160
+ torch.testing.assert_close(out_q, expected, atol=0, rtol=0)
161
+ torch.testing.assert_close(ref_q, expected, atol=0, rtol=0)
162
+ torch.testing.assert_close(scale, torch.ones_like(scale), atol=0, rtol=0)
163
+ torch.testing.assert_close(ref_s, scale, atol=0, rtol=0)
tests/kernels/quant_utils.py
+8
-4
@@ -181,12 +181,15 @@ def native_per_token_group_quant_fp8(
181
return x_q, x_s
182
183
184
-def native_per_token_group_quant_int8(x, group_size, eps=1e-10, dtype=torch.int8):
184
+def native_per_token_group_quant_int8(
185
+ x, group_size, eps=1e-10, dtype=torch.int8, *, round_to_nearest=True
186
+):
187
"""Function to perform per-token-group quantization on an input tensor
188
`x` using native torch.
189
190
It converts the tensor values into int8 values and returns the
191
quantized tensor along with the scaling factor used for quantization.
192
+ Per-token quantization rounds to nearest; block quantization truncates.
193
"""
194
assert x.shape[-1] % group_size == 0, (
195
"the last dimension of `x` must be divisible by `group_size`"
@@ -201,9 +204,10 @@ def native_per_token_group_quant_int8(x, group_size, eps=1e-10, dtype=torch.int8
204
# Use float32 for scale calculation for stability
205
amax = x_.abs().max(dim=-1, keepdim=True)[0].clamp(min=eps).to(torch.float32)
206
x_s = amax / int8_max
204
- x_q = (
205
- (x_.to(torch.float32) / x_s).round().clamp(min=int8_min, max=int8_max).to(dtype)
206
- ) # Round before clamping
207
+ x_q = x_.to(torch.float32) / x_s
208
+ if round_to_nearest:
209
+ x_q = x_q.round()
210
+ x_q = x_q.clamp(min=int8_min, max=int8_max).to(dtype)
211
x_q = x_q.reshape(x.shape)
212
x_s = x_s.reshape(x.shape[:-1] + (x.shape[-1] // group_size,))
213
tests/kernels/quantization/test_rocm_mxfp4.py
+3
-1
@@ -614,11 +614,13 @@ def test_aiter_fp4_gemm_skinny_shapes(M, N, K):
614
out = gemm_afp4wfp4(A_fp4, B_fp4, A_scale, B_scale)
615
616
assert out.shape == (M, N)
617
+ assert out.dtype == torch.bfloat16
618
assert not torch.any(torch.isnan(out))
619
620
+ # Accumulate in FP32, then match the GEMM's BF16 output rounding.
621
A_dq = quant_dequant_mxfp4(A)
622
B_dq = quant_dequant_mxfp4(B)
621
- ref = torch.matmul(A_dq.float(), B_dq.t().float())
623
+ ref = torch.matmul(A_dq.float(), B_dq.t().float()).to(torch.bfloat16).float()
624
625
_print_close_stats(
626
f"skinny_gemm M={M} N={N} K={K}",
tests/kernels/quantization/test_rocm_skinny_gemms.py
+2
-1
@@ -132,7 +132,8 @@ M_FACTORS_WVSPLITKRC = [128, 128 + 16, 256, 256 + 16, 640, 640 + 16]
132
NKM_FACTORS_WVSPLITKRC_LARGE_K = [
133
(128, 6144, 128),
134
(96, 8192, 128),
135
- (128, 12288, 128),
135
+ # Keep 24 K-shards (batched readback) within the DPX budget of 128 CUs.
136
+ (128, 12288, 64),
137
(32, 12288, 128),
138
]
139
tests/models/language/generation/test_hybrid.py
+1
@@ -73,6 +73,7 @@ def _set_conv_state_layout(monkeypatch, layout: str) -> None:
73
def test_models(
74
hf_runner,
75
vllm_runner,
76
+ gpu_memory_cleared,
77
example_prompts,
78
monkeypatch,
79
model: str,
tests/v1/kv_connector/unit/test_nixl_rocm_gpu_mem_diag.py
+21
-4
@@ -12,6 +12,7 @@ import gc
12
import pytest
13
import torch
14
15
+from tests.utils import wait_for_gpu_memory_to_clear
16
from vllm.platforms import current_platform
17
18
pytestmark = pytest.mark.skipif(
@@ -19,6 +20,8 @@ pytestmark = pytest.mark.skipif(
20
reason="ROCm platform required",
21
)
22
23
+MAX_LEAK_PCT = 10
24
+
25
26
def _mb(b: int) -> float:
27
return b / (1024 * 1024)
@@ -61,6 +64,18 @@ def _full_gpu_cleanup():
64
torch.accelerator.empty_cache()
65
66
67
+def _wait_for_driver_memory_release(baseline: dict, peak: dict) -> None:
68
+ # Process exit can precede ROCm's release of the backing allocation.
69
+ peak_delta_mb = max(0.0, peak["drv_used_mb"] - baseline["drv_used_mb"])
70
+ target_mb = baseline["drv_used_mb"] + peak_delta_mb * MAX_LEAK_PCT / 100
71
+ wait_for_gpu_memory_to_clear(
72
+ devices=[torch.accelerator.current_device_index()],
73
+ threshold_bytes=int(target_mb * 1024**2),
74
+ timeout_s=120,
75
+ poll_interval_s=1,
76
+ )
77
+
78
+
79
@pytest.mark.parametrize("model_name, sw_size", [("google/gemma-3-1b-it", 512)])
80
def test_gpu_memory_nixl_hma(model_name, sw_size):
81
"""Track GPU memory through NixlConnector create/infer/shutdown cycle."""
@@ -116,7 +131,7 @@ def test_gpu_memory_nixl_hma(model_name, sw_size):
131
132
# shutdown + cleanup
133
print("\n--- shutdown ---")
119
- llm.llm_engine.engine_core.shutdown()
134
+ llm.llm_engine.engine_core.shutdown(timeout=60)
135
_gpu_snapshot("3. after shutdown()", snap2["alloc_mb"])
136
137
del llm
@@ -126,6 +141,7 @@ def test_gpu_memory_nixl_hma(model_name, sw_size):
141
torch._dynamo.reset()
142
gc.collect()
143
torch.accelerator.empty_cache()
144
+ _wait_for_driver_memory_release(snap0, snap2)
145
snap_final = _gpu_snapshot("4. final", snap2["alloc_mb"])
146
147
# summary
@@ -160,7 +176,7 @@ def test_gpu_memory_nixl_hma(model_name, sw_size):
176
# Peak driver memory used above baseline
177
drv_peak = snap2["drv_used_mb"] - drv_base
178
leak_pct = (drv_leaked / drv_peak * 100) if drv_peak > 0 else 0
163
- max_leak_pct = 10
179
+ max_leak_pct = MAX_LEAK_PCT
180
assert leak_pct <= max_leak_pct, (
181
f"{drv_leaked:.0f} MB ({leak_pct:.1f}%) of driver-level GPU memory "
182
f"not freed after NixlConnector shutdown "
@@ -196,7 +212,7 @@ def test_gpu_memory_no_nixl_baseline(model_name):
212
llm.generate(["hi " * 500], SamplingParams(max_tokens=1))
213
snap_peak = _gpu_snapshot("after generate()", snap0["alloc_mb"])
214
199
- llm.llm_engine.engine_core.shutdown()
215
+ llm.llm_engine.engine_core.shutdown(timeout=60)
216
del llm
217
_full_gpu_cleanup()
218
cleanup_dist_env_and_memory()
@@ -204,6 +220,7 @@ def test_gpu_memory_no_nixl_baseline(model_name):
220
torch._dynamo.reset()
221
gc.collect()
222
torch.accelerator.empty_cache()
223
+ _wait_for_driver_memory_release(snap0, snap_peak)
224
snap_final = _gpu_snapshot("final", snap0["alloc_mb"])
225
226
drv_base = snap0["drv_used_mb"]
@@ -213,7 +230,7 @@ def test_gpu_memory_no_nixl_baseline(model_name):
230
print("=" * 90)
231
232
leak_pct = (drv_leaked / drv_peak * 100) if drv_peak > 0 else 0
216
- max_leak_pct = 10
233
+ max_leak_pct = MAX_LEAK_PCT
234
assert leak_pct <= max_leak_pct, (
235
f"{drv_leaked:.0f} MB ({leak_pct:.1f}%) of driver-level GPU memory "
236
f"not freed after baseline shutdown "
tests/v1/metrics/test_engine_logger_apis.py
+24
-12
@@ -7,6 +7,7 @@ import pytest
7
from tests.plugins.vllm_add_dummy_stat_logger.dummy_stat_logger.dummy_stat_logger import ( # noqa E501
8
DummyStatLogger,
9
)
10
+from tests.utils import wait_for_memory_to_settle
11
from vllm.v1.engine.async_llm import AsyncEngineArgs, AsyncLLM
12
from vllm.v1.metrics.ray_wrappers import RayPrometheusStatLogger
13
@@ -30,8 +31,15 @@ async def test_async_llm_replace_default_loggers(log_stats_enabled_engine_args):
31
engine = AsyncLLM.from_engine_args(
32
log_stats_enabled_engine_args, stat_loggers=[RayPrometheusStatLogger]
33
)
33
- assert isinstance(engine.logger_manager.stat_loggers[0], RayPrometheusStatLogger)
34
- engine.shutdown()
34
+ try:
35
+ assert isinstance(
36
+ engine.logger_manager.stat_loggers[0], RayPrometheusStatLogger
37
+ )
38
+ finally:
39
+ engine.shutdown()
40
+ wait_for_memory_to_settle(
41
+ threshold_ratio=1.0 - log_stats_enabled_engine_args.gpu_memory_utilization
42
+ )
43
44
45
@pytest.mark.asyncio
@@ -48,14 +56,18 @@ async def test_async_llm_add_to_default_loggers(log_stats_enabled_engine_args):
56
disabled_log_engine_args, stat_loggers=[DummyStatLogger]
57
)
58
51
- assert len(engine.logger_manager.stat_loggers) == 2
52
- assert len(engine.logger_manager.stat_loggers[0].per_engine_stat_loggers) == 1
53
- assert isinstance(
54
- engine.logger_manager.stat_loggers[0].per_engine_stat_loggers[0],
55
- DummyStatLogger,
56
- )
57
-
58
- # log_stats is still True, since custom stat loggers are used
59
- assert engine.log_stats
59
+ try:
60
+ assert len(engine.logger_manager.stat_loggers) == 2
61
+ assert len(engine.logger_manager.stat_loggers[0].per_engine_stat_loggers) == 1
62
+ assert isinstance(
63
+ engine.logger_manager.stat_loggers[0].per_engine_stat_loggers[0],
64
+ DummyStatLogger,
65
+ )
66
61
- engine.shutdown()
67
+ # log_stats is still True, since custom stat loggers are used
68
+ assert engine.log_stats
69
+ finally:
70
+ engine.shutdown()
71
+ wait_for_memory_to_settle(
72
+ threshold_ratio=1.0 - disabled_log_engine_args.gpu_memory_utilization
73
+ )
tests/v1/metrics/test_ray_metrics.py
+23
-14
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock
6
import pytest
7
import ray
8
9
+from tests.utils import wait_for_memory_to_settle
10
from vllm.config.model import ModelDType
11
from vllm.platforms import current_platform
12
from vllm.sampling_params import SamplingParams
@@ -46,31 +47,39 @@ def test_engine_log_metrics_ray(
47
) -> None:
48
"""Simple smoke test, verifying this can be used without exceptions.
49
Need to start a Ray cluster in order to verify outputs."""
50
+ engine_args = AsyncEngineArgs(
51
+ model=model, dtype=dtype, disable_log_stats=False, enforce_eager=True
52
+ )
53
54
@ray.remote(num_gpus=1)
55
class EngineTestActor:
56
async def run(self):
53
- engine_args = AsyncEngineArgs(
54
- model=model, dtype=dtype, disable_log_stats=False, enforce_eager=True
55
- )
56
-
57
engine = AsyncLLM.from_engine_args(
58
engine_args, stat_loggers=[RayPrometheusStatLogger]
59
)
60
61
- for i, prompt in enumerate(example_prompts):
62
- results = engine.generate(
63
- request_id=f"request-id-{i}",
64
- prompt=prompt,
65
- sampling_params=SamplingParams(max_tokens=max_tokens),
66
- )
61
+ try:
62
+ for i, prompt in enumerate(example_prompts):
63
+ results = engine.generate(
64
+ request_id=f"request-id-{i}",
65
+ prompt=prompt,
66
+ sampling_params=SamplingParams(max_tokens=max_tokens),
67
+ )
68
68
- async for _ in results:
69
- pass
69
+ async for _ in results:
70
+ pass
71
+ finally:
72
+ engine.shutdown()
73
74
# Create the actor and call the async method
72
- actor = EngineTestActor.remote() # type: ignore[attr-defined]
73
- ray.get(actor.run.remote())
75
+ try:
76
+ actor = EngineTestActor.remote() # type: ignore[attr-defined]
77
+ ray.get(actor.run.remote())
78
+ finally:
79
+ ray.shutdown()
80
+ wait_for_memory_to_settle(
81
+ threshold_ratio=1.0 - engine_args.gpu_memory_utilization
82
+ )
83
84
85
def test_sanitized_opentelemetry_name():