Files
ray-project--ray/python/ray/tests/test_tpu.py
T
2026-07-13 13:17:40 +08:00

972 lines
33 KiB
Python

import sys
from unittest.mock import MagicMock, patch
import pytest
import ray
from ray._private.accelerators import TPUAcceleratorManager, tpu
from ray.util.tpu import SlicePlacementGroup
def test_get_current_pod_name_smoke():
with patch(
"ray._private.accelerators.tpu.TPUAcceleratorManager.get_current_node_tpu_name",
return_value="my-tpu",
):
name = ray.util.tpu.get_current_pod_name()
assert name == "my-tpu"
def test_empty_get_current_pod_name_returns_none():
with patch(
"ray._private.accelerators.tpu.TPUAcceleratorManager.get_current_node_tpu_name",
return_value="",
):
name = ray.util.tpu.get_current_pod_name()
assert name is None
@pytest.mark.parametrize(
"test_case",
[
# (number_chips_per_host, parsed accl_type, expected_worker_count)
(4, "v2-4", 1),
(4, "v3-32", 4),
(4, "v4-8", 1),
(4, "v4-16", 2),
(8, "v5litepod-4", 1),
(8, "v5litepod-8", 1),
(8, "v5litepod-16", 2),
(8, "v5litepod-32", 4),
(4, "v5p-4", 1),
(4, "v5p-8", 1),
(4, "v5p-16", 2),
(4, "v6e-4", 1),
(8, "v6e-8", 1),
(4, "v6e-8", 2),
(8, "v6e-16", 2),
(4, "v7x-8", 1),
(4, "v7x-16", 2),
],
)
@patch("glob.glob")
def test_worker_count(mock_glob, test_case):
num_devices, accelerator_type, expected_worker_count = test_case
mock_glob.return_value = ["/dev/accel" + str(x) for x in range(num_devices)]
TPUAcceleratorManager.get_current_node_num_accelerators.cache_clear()
with patch(
"ray._private.accelerators.tpu.TPUAcceleratorManager."
"get_current_node_tpu_pod_type",
return_value=accelerator_type,
):
worker_count = ray.util.tpu.get_current_pod_worker_count()
assert worker_count == expected_worker_count
@patch("glob.glob")
def test_num_tpu_chips(mock_glob):
mock_glob.return_value = [
"/dev/accel0",
"/dev/accel1",
"/dev/accel2",
"/dev/accel3",
]
TPUAcceleratorManager.get_current_node_num_accelerators.cache_clear()
num_tpu_chips = ray.util.tpu.get_num_tpu_chips_on_node()
assert num_tpu_chips == 4
@pytest.mark.parametrize(
"test_case",
[
# (accelerator_type, accelerator_topology, expected_result)
("v2-16", "4x4", True),
("v2-256", "16x16", True),
("v2-4", "2x2", False),
("v3-16", "4x4", True),
("v3-1024", "32x32", True),
("v3-4", "4x16", False),
("v4-4", "2x2x1", True),
("v4-32", "2x4x4", True),
("v4-2048", "8x8x16", True),
("v4-4", "16x16x16", False),
("v5p-128", "4x4x4", True),
("v5p-4096", "16x16x16", True),
("v5p-12288", "16x16x24", True),
("v5p-4", "24x24x24", False),
("v5litepod-16", "2x8", True),
("v5litepod-256", "16x16", True),
("v5litepod-4", "2x2", True),
("v6e-8", "2x4", True),
("v6e-16", "4x4", True),
("v6e-64", "8x8", True),
("v6e-4", "4x16", False),
("tpu7x-16", "2x2x2", True),
("tpu7x-64", "2x4x4", True),
("v7x-8", "4x4", False),
],
)
@patch("glob.glob")
def test_is_valid_tpu_accelerator_topology(_mock_glob, test_case):
"""Test valid TPU accelerator topologies."""
accelerator_type, accelerator_topology, expected_result = test_case
actual_result = TPUAcceleratorManager.is_valid_tpu_accelerator_topology(
accelerator_type, accelerator_topology
)
assert actual_result == expected_result
def test_get_current_node_labels_env_only(monkeypatch):
# Simulate GKE TPU environment variables
monkeypatch.setenv("TPU_NAME", "tpu-worker-group-2")
monkeypatch.setenv("TPU_WORKER_ID", "0")
monkeypatch.setenv("TPU_ACCELERATOR_TYPE", "v6e-16")
monkeypatch.setenv("TPU_TOPOLOGY", "4x4")
tpu_labels = TPUAcceleratorManager.get_current_node_accelerator_labels()
assert tpu_labels["ray.io/tpu-slice-name"] == "tpu-worker-group-2"
assert tpu_labels["ray.io/tpu-worker-id"] == "0"
assert tpu_labels["ray.io/tpu-topology"] == "4x4"
assert tpu_labels["ray.io/tpu-pod-type"] == "v6e-16"
def test_get_current_node_tpu_topology_from_metadata():
tpu_env_string = "TPU_ACCELERATOR:v6e.\nTOPOLOGY: '2x2x4'\nTPU_HOST_BOUNDS:0,1,1,2"
with patch(
"ray._private.accelerators.tpu._get_tpu_metadata", return_value=tpu_env_string
):
topology = TPUAcceleratorManager.get_current_node_tpu_topology()
assert topology == "2x2x4"
@pytest.mark.parametrize(
"topology, accelerator_type, expected_pod_type, should_raise",
[
("2x4", "TPU-V6E", "v6e-8", False),
("2x2x2", "TPU-V4", "v4-16", False),
("4x8", "TPU-V3", "v3-64", False),
("2x2x1", "TPU-V5P", "v5p-8", False),
("4x4", "TPU-V5P", "v5p-32", False),
("8x16", "TPU-V6E", "v6e-128", False),
("", "TPU-V3", None, False),
("4x", "TPU-V3", None, True),
("2x2x2", "TPU-V7X", "v7x-16", False),
],
)
def test_infer_tpu_pod_type_from_topology(
topology, accelerator_type, expected_pod_type, should_raise
):
if should_raise:
with pytest.raises(ValueError):
tpu.infer_tpu_pod_type_from_topology(topology, accelerator_type)
else:
actual_result = tpu.infer_tpu_pod_type_from_topology(topology, accelerator_type)
assert actual_result == expected_pod_type
@pytest.fixture
def ray_start_cpu():
address_info = ray.init(num_cpus=1)
yield address_info
ray.shutdown()
@pytest.fixture
def ray_tpu_cluster(ray_start_cluster):
"""
Simulates a Ray cluster with two multi-host TPU v4-16 slices.
"""
pod_type = "v4-16"
topology = "2x2x2"
cluster = ray_start_cluster
slice_0_env_common = {
"TPU_NAME": "test-slice-0",
"TPU_ACCELERATOR_TYPE": pod_type,
"TPU_TOPOLOGY": topology,
}
slice_0_head_labels = {
"ray.io/tpu-slice-name": "test-slice-0",
"ray.io/tpu-worker-id": "0",
"ray.io/tpu-pod-type": pod_type,
"ray.io/tpu-topology": topology,
}
slice_0_worker_labels = {
"ray.io/tpu-slice-name": "test-slice-0",
"ray.io/tpu-worker-id": "1",
"ray.io/tpu-pod-type": pod_type,
"ray.io/tpu-topology": topology,
}
cluster.add_node(
num_cpus=2,
resources={"TPU": 4, f"TPU-{pod_type}-head": 1},
env_vars={**slice_0_env_common, "TPU_WORKER_ID": "0"},
labels=slice_0_head_labels,
)
cluster.add_node(
num_cpus=2,
resources={"TPU": 4},
env_vars={**slice_0_env_common, "TPU_WORKER_ID": "1"},
labels=slice_0_worker_labels,
)
slice_1_env_common = {
"TPU_NAME": "test-slice-1",
"TPU_ACCELERATOR_TYPE": pod_type,
"TPU_TOPOLOGY": topology,
}
slice_1_head_labels = {
"ray.io/tpu-slice-name": "test-slice-1",
"ray.io/tpu-worker-id": "0",
"ray.io/tpu-pod-type": pod_type,
"ray.io/tpu-topology": topology,
}
slice_1_worker_labels = {
"ray.io/tpu-slice-name": "test-slice-1",
"ray.io/tpu-worker-id": "1",
"ray.io/tpu-pod-type": pod_type,
"ray.io/tpu-topology": topology,
}
cluster.add_node(
num_cpus=2,
resources={"TPU": 4, f"TPU-{pod_type}-head": 1},
env_vars={**slice_1_env_common, "TPU_WORKER_ID": "0"},
labels=slice_1_head_labels,
)
cluster.add_node(
num_cpus=2,
resources={"TPU": 4},
env_vars={**slice_1_env_common, "TPU_WORKER_ID": "1"},
labels=slice_1_worker_labels,
)
ray.init(address=cluster.address)
yield cluster
ray.shutdown()
@pytest.fixture
def ray_v6e_tpu_cluster(ray_start_cluster):
"""
Simulates a Ray cluster with two v6e-8 slices (2x4 topology).
"""
pod_type = "v6e-8"
topology = "2x4"
cluster = ray_start_cluster
for i in range(2):
env_common = {
"TPU_NAME": f"test-v6e-slice-{i}",
"TPU_ACCELERATOR_TYPE": pod_type,
"TPU_TOPOLOGY": topology,
}
head_labels = {
"ray.io/tpu-slice-name": f"test-v6e-slice-{i}",
"ray.io/tpu-worker-id": "0",
"ray.io/tpu-pod-type": pod_type,
"ray.io/tpu-topology": topology,
}
# A single-host v6e-8 has 8 chips on one node
cluster.add_node(
num_cpus=4,
resources={"TPU": 8, f"TPU-{pod_type}-head": 1},
env_vars={**env_common, "TPU_WORKER_ID": "0"},
labels=head_labels,
)
ray.init(address=cluster.address)
yield cluster
ray.shutdown()
def test_fetch_tpu_slice_name_from_pg(ray_tpu_cluster):
"""Tests that the slice name can be fetched from a PG."""
tpu_head_pg = ray.util.placement_group(bundles=[{"TPU-v4-16-head": 1}])
ray.get(tpu_head_pg.ready())
expected_unique_slice_names = {"test-slice-0", "test-slice-1"}
slice_name = tpu.fetch_tpu_slice_name_from_pg(tpu_head_pg)
assert slice_name in expected_unique_slice_names
ray.util.remove_placement_group(tpu_head_pg)
def test_reserve_tpu_slice(ray_tpu_cluster):
"""Tests that a TPU slice can be successfully reserved."""
reserved_name_0, hg_pg_0 = tpu.reserve_tpu_slice(
topology="2x2x2", accelerator_type="TPU-V4"
)
reserved_name_1, hg_pg_1 = tpu.reserve_tpu_slice(
topology="2x2x2", accelerator_type="TPU-V4"
)
# Ensure the placement groups reserving the TPU slice using the head worker are valid.
assert hg_pg_0 is not None, "Expected placement group for slice 0, got None"
assert hg_pg_1 is not None, "Expected placement group for slice 1, got None"
assert (
reserved_name_0 != reserved_name_1
), f"Expected to reserve two different slices, but got the same name: {reserved_name_0}"
expected_unique_slice_names = {"test-slice-0", "test-slice-1"}
actual_reserved_names = {reserved_name_0, reserved_name_1}
assert actual_reserved_names == expected_unique_slice_names, (
f"Got unexpected slice names. Expected {expected_unique_slice_names}, "
f"but got {actual_reserved_names}"
)
def test_slice_placement_group(ray_tpu_cluster):
"""Test that single TPU slice can be successfully reserved."""
slice_placement_group = ray.util.tpu.slice_placement_group(
topology="2x2x2",
accelerator_version="v4",
)
assert slice_placement_group.chips_per_host == 4
assert slice_placement_group.num_hosts == 2
assert slice_placement_group.placement_group.bundle_count == 2
assert slice_placement_group.placement_group.bundle_specs == [
{"TPU": 4, "CPU": 1.0},
{"TPU": 4, "CPU": 1.0},
]
def test_multi_slice_placement_group(ray_tpu_cluster):
"""Test that multiple whole TPU slices can be successfully reserved"""
multi_slice_placement_group = ray.util.tpu.slice_placement_group(
topology="2x2x2",
accelerator_version="v4",
num_slices=2,
)
assert multi_slice_placement_group.placement_group.bundle_count == 4
assert multi_slice_placement_group.num_hosts == 4
assert multi_slice_placement_group.placement_group.bundle_specs == [
{"TPU": 4, "CPU": 1.0}, # slice 1, host 1
{"TPU": 4, "CPU": 1.0}, # slice 1, host 2
{"TPU": 4, "CPU": 1.0}, # slice 2, host 1
{"TPU": 4, "CPU": 1.0}, # slice 2, host 2
]
@patch("ray.util.tpu.placement_group")
@patch("ray.util.tpu.remove_placement_group")
@patch("ray.util.tpu.reserve_tpu_slice")
def test_slice_placement_group_partial_failure_cleanup(
mock_reserve, mock_remove_pg, mock_create_pg
):
"""
Verifies that if a multi-slice request fails halfway through,
the TPU head placement groups are cleaned up to prevent leaks.
"""
fake_head_pg_1 = MagicMock(name="head_pg_1")
mock_reserve.side_effect = [("slice_1", fake_head_pg_1), None]
with pytest.raises(RuntimeError, match="Failed to reserve TPU slice"):
SlicePlacementGroup(topology="2x2x2", accelerator_version="v4", num_slices=2)
# Validate that 2 TPU util attempted to reserve two slices, failed, and
# correctly cleaned up the hanging TPU head placement groups.
assert mock_reserve.call_count == 2
mock_remove_pg.assert_called_once_with(fake_head_pg_1)
mock_create_pg.assert_not_called()
@pytest.mark.parametrize(
"accelerator_type, expected_version",
[
# type with "TPU-" prefix
("TPU-V4", "v4"),
("TPU-v4", "v4"),
("TPU-V6E", "v6e"),
("TPU-v5p", "v5p"),
("TPU-V7X", "v7x"),
# Only the TPU version - no parsing necessary.
("v4", "v4"),
("v3", "v3"),
("v6e", "v6e"),
("v5litepod", "v5litepod"),
("v7x", "v7x"),
],
)
def test_get_tpu_version_valid(accelerator_type, expected_version):
assert ray.util.tpu.get_tpu_version_from_type(accelerator_type) == expected_version
@pytest.mark.parametrize(
"invalid_type",
[
"A100", # GPU type
"random-invalid-type", # Random string
"TPU-invalid", # TPU prefix
"", # Empty string
],
)
def test_get_tpu_version_invalid(invalid_type):
with pytest.raises(ValueError, match="Invalid accelerator_type"):
ray.util.tpu.get_tpu_version_from_type(invalid_type)
@pytest.mark.parametrize(
"topology, accelerator_type, num_workers, resources_per_worker, expected_slices",
[
# "2x2x1" has 4 chips, for 4 workers with TPU: 1 each we expect num_slices=1.
("2x2x1", "TPU-V4", 4, {"TPU": 1}, 1),
# "2x2x1" has 4 chips, for 8 workers with TPU: 1 each we expect num_slices=2.
("2x2x1", "v4", 8, {"TPU": 1}, 2),
# "2x2x2" has 8 chips and 2 hosts, defaulting to 1 TPU worker per host
# and requesting 4 workers, we expect num_slices=2.
("2x2x2", "TPU-V4", 4, None, 2),
# "2x2x4" has 16 chips and 4 hosts, defaulting to 1 TPU worker per host
# and requesting 4 workers, we expect num_slices=1.
("2x2x4", "TPU-V4", 4, None, 1),
# 0 workers requested -> fallback to 1 slice.
("2x2x1", "v4", 0, None, 1),
# Invalid topology -> fallback to 1 slice.
("", "v4", 4, {"TPU": 1}, 1),
("2x2x1", "", 4, {"TPU": 1}, 1),
],
)
def test_get_tpu_num_slices_for_workers(
topology, accelerator_type, num_workers, resources_per_worker, expected_slices
):
num_slices = ray.util.tpu.get_tpu_num_slices_for_workers(
topology=topology,
accelerator_type=accelerator_type,
num_workers=num_workers,
resources_per_worker=resources_per_worker,
)
assert num_slices == expected_slices
def _make_mock_tpu_node(
alive, pod_type, slice_name, worker_id, tpu_chips=4, node_id=None
):
"""Helper to mock a Ray Node dictionary returned by ray.nodes()."""
if node_id is None:
node_id = f"node_{slice_name}_{worker_id}"
return {
"NodeID": node_id,
"Alive": alive,
"Labels": {
"ray.io/tpu-pod-type": pod_type,
"ray.io/tpu-slice-name": slice_name,
"ray.io/tpu-worker-id": str(worker_id),
},
"Resources": {"TPU": tpu_chips},
}
@pytest.mark.parametrize(
"topology, accelerator_type, mock_nodes, mock_avail_resources, expected_ready",
[
# 1 fully intact and available v4 slice (2 physical hosts).
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 0, node_id="A"),
_make_mock_tpu_node(True, "v4-16", "slice-1", 1, node_id="B"),
],
{
"A": {"TPU": 4},
"B": {"TPU": 4},
},
1,
),
# 1 fully intact slice, but one node is using 2 TPUs (unavailable) -> 0 ready slices.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 0, node_id="A"),
_make_mock_tpu_node(True, "v4-16", "slice-1", 1, node_id="B"),
],
{
"A": {"TPU": 2},
"B": {"TPU": 4},
},
0,
),
# Fractured slice (missing a physical host) -> 0 ready slices.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 0, node_id="A"),
# Worker 1 is missing
],
{
"A": {"TPU": 4},
},
0,
),
# Correct number of hosts, but missing the head node (rank 0) -> 0 ready slices.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 1, node_id="A"),
_make_mock_tpu_node(True, "v4-16", "slice-1", 2, node_id="B"),
],
{
"A": {"TPU": 4},
"B": {"TPU": 4},
},
0,
),
# Fractured slice (one physical host is dead) -> 0 ready slices.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 0, node_id="A"),
_make_mock_tpu_node(False, "v4-16", "slice-1", 1, node_id="B"),
],
{
"A": {"TPU": 4},
},
0,
),
# 2 slices total: one intact & available, one fractured -> 1 ready slice.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-A", 0, node_id="A0"),
_make_mock_tpu_node(True, "v4-16", "slice-A", 1, node_id="A1"),
_make_mock_tpu_node(True, "v4-16", "slice-B", 0, node_id="B0"),
# slice-B worker 1 is missing
],
{
"A0": {"TPU": 4},
"A1": {"TPU": 4},
"B0": {"TPU": 4},
},
1,
),
# 1 fully intact and available v6e 2x4 slice (single-host).
(
"2x4",
"v6e",
[
_make_mock_tpu_node(
True, "v6e-8", "slice-1", 0, tpu_chips=8, node_id="A"
),
],
{
"A": {"TPU": 8},
},
1,
),
# 1 fully intact and available v6e 2x4 slice (2 physical hosts).
(
"2x4",
"v6e",
[
_make_mock_tpu_node(
True, "v6e-8", "slice-1", 0, tpu_chips=4, node_id="A"
),
_make_mock_tpu_node(
True, "v6e-8", "slice-1", 1, tpu_chips=4, node_id="B"
),
],
{
"A": {"TPU": 4},
"B": {"TPU": 4},
},
1,
),
# 2 fully intact v6e slices.
(
"4x4",
"v6e",
[
_make_mock_tpu_node(True, "v6e-16", "slice-1", 0, node_id="S1_0"),
_make_mock_tpu_node(True, "v6e-16", "slice-1", 1, node_id="S1_1"),
_make_mock_tpu_node(True, "v6e-16", "slice-1", 2, node_id="S1_2"),
_make_mock_tpu_node(True, "v6e-16", "slice-1", 3, node_id="S1_3"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 0, node_id="S2_0"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 1, node_id="S2_1"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 2, node_id="S2_2"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 3, node_id="S2_3"),
],
{
"S1_0": {"TPU": 4},
"S1_1": {"TPU": 4},
"S1_2": {"TPU": 4},
"S1_3": {"TPU": 4},
"S2_0": {"TPU": 4},
"S2_1": {"TPU": 4},
"S2_2": {"TPU": 4},
"S2_3": {"TPU": 4},
},
2,
),
],
)
@patch("ray.is_initialized", return_value=True)
@patch("ray._private.state.available_resources_per_node")
@patch("ray.nodes")
def test_get_num_ready_tpu_slices_calculation(
mock_nodes_call,
mock_avail_resources_call,
mock_is_initialized,
topology,
accelerator_type,
mock_nodes,
mock_avail_resources,
expected_ready,
):
"""Test that the TPU slice readiness utility correctly calculates the number of ready
slices in different mocked scenarios, including idle resource verification."""
mock_nodes_call.return_value = mock_nodes
mock_avail_resources_call.return_value = mock_avail_resources
actual_ready = ray.util.tpu.get_num_ready_tpu_slices(
topology=topology,
accelerator_type=accelerator_type,
)
assert actual_ready == expected_ready
@pytest.mark.parametrize(
"topology, accelerator_type, mock_nodes, expected_intact",
[
# 1 fully intact v4 slice (2 physical hosts).
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 0, node_id="A"),
_make_mock_tpu_node(True, "v4-16", "slice-1", 1, node_id="B"),
],
1,
),
# Fractured slice (missing a physical host) -> 0 intact slices.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 0, node_id="A"),
],
0,
),
# Missing head node (rank 0) -> 0 intact slices.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 1, node_id="A"),
_make_mock_tpu_node(True, "v4-16", "slice-1", 2, node_id="B"),
],
0,
),
# One physical host is dead -> 0 intact slices.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-1", 0, node_id="A"),
_make_mock_tpu_node(False, "v4-16", "slice-1", 1, node_id="B"),
],
0,
),
# 2 slices: one intact, one fractured -> 1 intact slice.
(
"2x2x2",
"v4",
[
_make_mock_tpu_node(True, "v4-16", "slice-A", 0, node_id="A0"),
_make_mock_tpu_node(True, "v4-16", "slice-A", 1, node_id="A1"),
_make_mock_tpu_node(True, "v4-16", "slice-B", 0, node_id="B0"),
],
1,
),
# 2 fully intact v6e slices.
(
"4x4",
"v6e",
[
_make_mock_tpu_node(True, "v6e-16", "slice-1", 0, node_id="S1_0"),
_make_mock_tpu_node(True, "v6e-16", "slice-1", 1, node_id="S1_1"),
_make_mock_tpu_node(True, "v6e-16", "slice-1", 2, node_id="S1_2"),
_make_mock_tpu_node(True, "v6e-16", "slice-1", 3, node_id="S1_3"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 0, node_id="S2_0"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 1, node_id="S2_1"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 2, node_id="S2_2"),
_make_mock_tpu_node(True, "v6e-16", "slice-2", 3, node_id="S2_3"),
],
2,
),
],
)
@patch("ray.is_initialized", return_value=True)
@patch("ray.nodes")
def test_get_num_tpu_slices_calculation(
mock_nodes_call,
mock_is_initialized,
topology,
accelerator_type,
mock_nodes,
expected_intact,
):
"""Test that the intact TPU slice utility counts slices based purely on
physical integrity (all hosts alive, correct chip count) without checking
whether they are idle."""
mock_nodes_call.return_value = mock_nodes
actual_intact = ray.util.tpu.get_num_tpu_slices(
topology=topology,
accelerator_type=accelerator_type,
)
assert actual_intact == expected_intact
@patch("ray.is_initialized", return_value=False)
def test_get_num_tpu_slices_uninitialized(mock_is_initialized):
"""Test that the utility gracefully handles an uninitialized Ray context."""
assert ray.util.tpu.get_num_tpu_slices("2x2x2", "v4") == 0
def test_get_num_ready_tpu_slices(ray_tpu_cluster):
"""
Tests the get_num_ready_tpu_slices utility against a real Ray cluster.
The ray_tpu_cluster fixture provisions two v4-16 slices (2x2x2 topology).
"""
ready_slices = ray.util.tpu.get_num_ready_tpu_slices(
topology="2x2x2", accelerator_type="v4"
)
assert ready_slices == 2
@patch("ray.is_initialized", return_value=False)
def test_get_num_ready_tpu_slices_uninitialized(mock_is_initialized):
"""Test that the utility gracefully handles an uninitialized Ray context."""
assert ray.util.tpu.get_num_ready_tpu_slices("2x2x2", "v4") == 0
@pytest.mark.parametrize(
"node_dict, expected_slice_name",
[
(_make_mock_tpu_node(True, "v4-16", "slice-1", 0), "slice-1"),
(_make_mock_tpu_node(True, "v6e-8", "slice-A", 1), "slice-A"),
(_make_mock_tpu_node(True, "v4-16", "", 0), ""),
({"Alive": True, "Labels": {}}, None), # Missing TPU slice name
({"Alive": True}, None), # Missing Node labels dict
],
)
def test_get_tpu_slice_name_from_node(node_dict, expected_slice_name):
"""Tests that the utility correctly extracts the TPU slice name from a node dictionary."""
assert ray.util.tpu.get_tpu_slice_name_from_node(node_dict) == expected_slice_name
@patch("ray.is_initialized", return_value=True)
@patch("ray.nodes")
def test_get_tpu_nodes_for_slice(mock_nodes_call, mock_is_initialized):
"""Tests that the utility correctly filters alive nodes for a specific slice."""
mock_nodes = [
_make_mock_tpu_node(True, "v4-16", "slice-A", 0),
_make_mock_tpu_node(True, "v4-16", "slice-A", 1),
_make_mock_tpu_node(False, "v4-16", "slice-A", 2), # Dead node
_make_mock_tpu_node(True, "v4-16", "slice-B", 0), # Wrong slice
]
mock_nodes_call.return_value = mock_nodes
# Call ray.nodes() to fetch from GCS
nodes_a = ray.util.tpu.get_tpu_nodes_for_slice("slice-A")
assert len(nodes_a) == 2
assert nodes_a[0]["Labels"][ray._raylet.RAY_NODE_TPU_WORKER_ID_KEY] == "0"
assert nodes_a[1]["Labels"][ray._raylet.RAY_NODE_TPU_WORKER_ID_KEY] == "1"
assert mock_nodes_call.call_count == 1
# Pass cached nodes directly
nodes_b = ray.util.tpu.get_tpu_nodes_for_slice("slice-B", nodes=mock_nodes)
assert len(nodes_b) == 1
assert nodes_b[0]["Labels"][ray._raylet.RAY_NODE_TPU_SLICE_NAME_KEY] == "slice-B"
assert mock_nodes_call.call_count == 1 # Call count remains 1
# Use non-existent slice
nodes_c = ray.util.tpu.get_tpu_nodes_for_slice("slice-C", nodes=mock_nodes)
assert len(nodes_c) == 0
@patch("ray.is_initialized", return_value=False)
def test_get_tpu_nodes_for_slice_uninitialized(mock_is_initialized):
"""Test that the utility gracefully handles an uninitialized Ray context."""
assert ray.util.tpu.get_tpu_nodes_for_slice("slice-A") == []
def test_get_tpu_worker_resources_chips_per_vm_override():
"""Test that chips_per_vm correctly overrides the default resource calculations."""
# Default behavior: v6e 2x4 defaults to a single 8-chip host
num_workers, resources = ray.util.tpu.get_tpu_worker_resources(
topology="2x4", accelerator_type="v6e"
)
assert num_workers == 1
assert resources["TPU"] == 8
# Override behavior: v6e 2x4 forced to 4 chips per VM (2 hosts)
num_workers_override, resources_override = ray.util.tpu.get_tpu_worker_resources(
topology="2x4", accelerator_type="v6e", chips_per_vm=4
)
assert num_workers_override == 2
assert resources_override["TPU"] == 4
def test_slice_placement_group_chips_per_vm_override(ray_v6e_tpu_cluster):
"""Test that SlicePlacementGroup respects chips_per_vm for host calculation."""
# Default behavior (1 VM with 8 chips)
default_pg = SlicePlacementGroup(topology="2x4", accelerator_version="v6e")
assert default_pg.chips_per_host == 8
assert default_pg.num_hosts == 1
assert default_pg.num_bundles == 1
assert default_pg.bundle_resources["TPU"] == 8
# User-specified override behavior (2 VMs with 4 chips each)
override_pg = SlicePlacementGroup(
topology="2x4", accelerator_version="v6e", chips_per_vm=4
)
assert override_pg.chips_per_host == 4
assert override_pg.num_hosts == 2
assert override_pg.num_bundles == 2
assert override_pg.bundle_resources["TPU"] == 4
def test_user_bundle_label_selector_merged(ray_tpu_cluster):
"""Verifies that user-passed bundle_label_selector is merged with dynamic TPU labels."""
user_selectors = [{"env": "prod"}, {"env": "test"}]
# 2x2x2 v4 = 2 hosts = 2 bundles
slice_pg = SlicePlacementGroup(
topology="2x2x2", accelerator_version="v4", bundle_label_selector=user_selectors
)
assert len(slice_pg._bundle_label_selector) == 2
# Verify slice 0
assert slice_pg._bundle_label_selector[0]["env"] == "prod"
assert ray._raylet.RAY_NODE_TPU_SLICE_NAME_KEY in slice_pg._bundle_label_selector[0]
# Verify slice 1
assert slice_pg._bundle_label_selector[1]["env"] == "test"
assert ray._raylet.RAY_NODE_TPU_SLICE_NAME_KEY in slice_pg._bundle_label_selector[1]
def test_user_bundle_label_selector_collision_dynamic_wins(ray_v6e_tpu_cluster):
"""Verifies that dynamic TPU labels take precedence on collision."""
user_selectors = [{ray._raylet.RAY_NODE_TPU_SLICE_NAME_KEY: "user-requested-slice"}]
# v6e-8 is single host (1 bundle)
slice_pg = SlicePlacementGroup(
topology="2x4", accelerator_version="v6e", bundle_label_selector=user_selectors
)
assert len(slice_pg._bundle_label_selector) == 1
# The dynamic value should win (it generates test-v6e-slice-N)
actual_val = slice_pg._bundle_label_selector[0][
ray._raylet.RAY_NODE_TPU_SLICE_NAME_KEY
]
assert actual_val != "user-requested-slice"
assert "test-v6e-slice-" in actual_val
def test_user_bundle_label_selector_length_mismatch_raises():
"""Verifies that providing wrong length of selector list raises ValueError."""
user_selectors = [{"env": "prod"}] # Only 1 provided but 2x2x2 v4 has 2 hosts
with pytest.raises(ValueError, match="bundle_label_selector length"):
SlicePlacementGroup(
topology="2x2x2",
accelerator_version="v4",
bundle_label_selector=user_selectors,
)
def test_release_head_pgs_idempotent(ray_tpu_cluster):
"""Verifies that release_head_pgs() is idempotent."""
slice_pg = SlicePlacementGroup(topology="2x2x2", accelerator_version="v4")
assert len(slice_pg.head_placement_groups) == 1
slice_pg.release_head_pgs()
assert len(slice_pg.head_placement_groups) == 0
# Call again, should not raise
slice_pg.release_head_pgs()
assert len(slice_pg.head_placement_groups) == 0
def test_shutdown_idempotent(ray_tpu_cluster):
"""Verifies that shutdown() is idempotent."""
slice_pg = SlicePlacementGroup(topology="2x2x2", accelerator_version="v4")
slice_pg.shutdown()
assert slice_pg.placement_group is None
assert len(slice_pg.head_placement_groups) == 0
# Call again, should not raise
slice_pg.shutdown()
def test_shutdown_safe_after_construction_failure():
"""Verifies that shutdown() is safe to call on a partially-constructed instance."""
with patch(
"ray.util.tpu.SlicePlacementGroup._reserve_slice",
side_effect=RuntimeError("Test failure"),
):
with pytest.raises(RuntimeError, match="Test failure"):
SlicePlacementGroup(topology="2x2x2", accelerator_version="v4")
# If the above didn't crash or leak resources, we are good.
# We can also manually construct a partial instance and call shutdown.
partial_pg = SlicePlacementGroup.__new__(SlicePlacementGroup)
partial_pg._head_pgs = []
partial_pg._placement_group = None
# Should not raise even though it's missing attributes
partial_pg.shutdown()
def test_release_head_pgs_after_ready_then_shutdown(ray_tpu_cluster):
"""Validates Slice PG lifecycle: wait until ready, release head PGs, then shutdown."""
slice_pg = SlicePlacementGroup(topology="2x2x2", accelerator_version="v4")
# Wait for ready
ray.get(slice_pg.placement_group.ready())
slice_pg.release_head_pgs()
assert len(slice_pg.head_placement_groups) == 0
slice_pg.shutdown()
assert slice_pg.placement_group is None
def test_chips_per_vm_zero_raises_value_error():
"""Verifies that passing chips_per_vm=0 explicitly raises a ValueError instead of silently using the topology default."""
with pytest.raises(ValueError):
SlicePlacementGroup(
topology="2x2x2",
accelerator_version="v4",
chips_per_vm=0,
)
# Also verify when custom resources already include a "TPU" key
with pytest.raises(ValueError):
SlicePlacementGroup(
topology="2x2x2",
accelerator_version="v4",
resources_per_bundle={"TPU": 4},
chips_per_vm=0,
)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))