Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
048f2ae
styling
aaronscalene May 18, 2026
b39025b
fix
aaronscalene May 19, 2026
1b96c6f
fix
aaronscalene May 19, 2026
141bdaf
testing
aaronscalene May 19, 2026
9e7d9af
format stale comments
aaronscalene May 19, 2026
ba7f43f
comment
aaronscalene May 20, 2026
b0e8f85
lint
aaronscalene May 21, 2026
5b8979f
i
aaronscalene May 21, 2026
972ca0e
Merge branch 'master' into aaron/pg-public-api
aaronscalene May 21, 2026
88364be
g
aaronscalene May 22, 2026
734fcbf
Merge branch 'aaron/pg-public-api' of github.com:aaronscalene/ray int…
aaronscalene May 22, 2026
6d3a742
test
aaronscalene May 22, 2026
867b20b
remove tpdemo
aaronscalene May 22, 2026
016361c
fix
aaronscalene May 23, 2026
7c6a2b9
fix
aaronscalene May 23, 2026
17f8a35
fix
aaronscalene May 23, 2026
627688d
fix
aaronscalene May 23, 2026
0db8536
fix
aaronscalene May 26, 2026
e5afe1f
fix
aaronscalene May 26, 2026
27cba4f
fix
aaronscalene May 26, 2026
e49ae4f
Merge branch 'master' into aaron/pg-public-api
aaronscalene May 27, 2026
dc20d13
fix
aaronscalene May 30, 2026
1f675bb
lint
aaronscalene Jun 1, 2026
e60213e
m
aaronscalene Jun 8, 2026
dda9bab
delete
aaronscalene Jun 8, 2026
e8c530d
better comments
aaronscalene Jun 8, 2026
093469f
fix: addressed nit comments, added validation testing
aaronlinear Jun 25, 2026
2aeea5e
fix: added circular dependency comment
aaronlinear Jun 26, 2026
fb280df
Merge remote-tracking branch 'upstream/master' into aaron/pg-public-api
aaronlinear Jun 26, 2026
7a52b43
fix: add todo for future rename
aaronlinear Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions python/ray/_raylet.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,19 @@ cdef class Language:
JAVA = Language.from_native(LANGUAGE_JAVA)


cdef CPlacementStrategy prepare_c_strategy(c_string strategy) except *:
# Called by CoreWorker.create_placement_group(..., c_string strategy, ...).
# The Python placement_group wrapper validates `strategy` to be one of the
# strategies below beforehand.
if strategy == b"PACK":
return PLACEMENT_STRATEGY_PACK
elif strategy == b"SPREAD":
return PLACEMENT_STRATEGY_SPREAD
elif strategy == b"STRICT_PACK":
return PLACEMENT_STRATEGY_STRICT_PACK
else:
return PLACEMENT_STRATEGY_STRICT_SPREAD
Comment thread
cursor[bot] marked this conversation as resolved.

def raise_sys_exit_with_custom_error_message(
ray_terminate_msg: str,
exit_code: int = 0) -> None:
Expand Down Expand Up @@ -3767,23 +3780,18 @@ cdef class CoreWorker:
c_string strategy,
c_bool is_detached,
soft_target_node_id,
c_vector[unordered_map[c_string, c_string]] bundle_label_selector):
c_vector[unordered_map[c_string, c_string]] bundle_label_selector,
dict topology_strategy):
cdef:
CPlacementGroupID c_placement_group_id
CPlacementStrategy c_strategy
CNodeID c_soft_target_node_id = CNodeID.Nil()
unordered_map[c_string, CPlacementStrategy] c_topology_strategy

if strategy == b"PACK":
c_strategy = PLACEMENT_STRATEGY_PACK
elif strategy == b"SPREAD":
c_strategy = PLACEMENT_STRATEGY_SPREAD
elif strategy == b"STRICT_PACK":
c_strategy = PLACEMENT_STRATEGY_STRICT_PACK
else:
if strategy == b"STRICT_SPREAD":
c_strategy = PLACEMENT_STRATEGY_STRICT_SPREAD
else:
raise TypeError(strategy)
c_strategy = prepare_c_strategy(strategy)

for label, level_strategy in topology_strategy.items():
c_topology_strategy[label] = prepare_c_strategy(level_strategy)

if soft_target_node_id is not None:
c_soft_target_node_id = CNodeID.FromHex(soft_target_node_id)
Expand All @@ -3798,7 +3806,8 @@ cdef class CoreWorker:
bundles,
is_detached,
c_soft_target_node_id,
bundle_label_selector),
bundle_label_selector,
c_topology_strategy),
&c_placement_group_id))

return PlacementGroupID(c_placement_group_id.Binary())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ describe("PlacementGroupTable", () => {
const labelSelectorHeaders = screen.getAllByText("Label Selector");
expect(labelSelectorHeaders.length).toBeGreaterThan(0);

const labelDomainHeaders = screen.getAllByText("Label Domain");
expect(labelDomainHeaders.length).toBeGreaterThan(0);
const topologyHeaders = screen.getAllByText("Topology");
expect(topologyHeaders.length).toBeGreaterThan(0);

const schedulingDetailHeaders = screen.getAllByText("Scheduling Detail");
expect(schedulingDetailHeaders.length).toBeGreaterThan(0);
Expand Down Expand Up @@ -287,7 +287,7 @@ describe("PlacementGroupTable", () => {
// Check that null stats are handled gracefully
expect(screen.getByText("pg-123456789")).toBeInTheDocument();
const dashes = screen.getAllByText("-");
expect(dashes.length).toBeGreaterThan(0); // Null scheduling detail and empty label domain
expect(dashes.length).toBeGreaterThan(0); // Null scheduling detail and empty topology
});

it("renders placement groups with empty name", () => {
Expand All @@ -308,7 +308,7 @@ describe("PlacementGroupTable", () => {
// Check that empty names are handled gracefully
expect(screen.getByText("pg-123456789")).toBeInTheDocument();
const dashes = screen.getAllByText("-");
expect(dashes.length).toBeGreaterThan(0); // Empty name and empty label domain
expect(dashes.length).toBeGreaterThan(0); // Empty name and empty topology
});

it("renders state counter for placement groups", () => {
Expand Down Expand Up @@ -386,8 +386,8 @@ describe("PlacementGroupTable", () => {
const labelSelectorHeaders = screen.getAllByText("Label Selector");
expect(labelSelectorHeaders.length).toBeGreaterThan(0);

const labelDomainHeaders = screen.getAllByText("Label Domain");
expect(labelDomainHeaders.length).toBeGreaterThan(0);
const topologyHeaders = screen.getAllByText("Topology");
expect(topologyHeaders.length).toBeGreaterThan(0);

const schedulingDetailHeaders = screen.getAllByText("Scheduling Detail");
expect(schedulingDetailHeaders.length).toBeGreaterThan(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const PlacementGroupTable = ({
{ label: "State" },
{ label: "Reserved Resources" },
{ label: "Label Selector" },
{ label: "Label Domain" },
{ label: "Topology" },
{ label: "Scheduling Detail" },
];

Expand Down Expand Up @@ -194,8 +194,8 @@ const PlacementGroupTable = ({
state,
stats,
bundles,
label_domain_key,
label_domain_assignments,
topology_strategy,
topology_assignments,
}) => (
<TableRow key={placement_group_id}>
<TableCell align="center">
Expand All @@ -215,16 +215,15 @@ const PlacementGroupTable = ({
<LabelSelector bundles={bundles} />
</TableCell>
<TableCell align="center">
{label_domain_key ? (
{topology_strategy ? (
<Box sx={{ textAlign: "left" }}>
<div>
<b>key:</b> {label_domain_key}
<b>strategy:</b> {JSON.stringify(topology_strategy)}
</div>
<div>
<b>assignment:</b>{" "}
{label_domain_assignments &&
Object.keys(label_domain_assignments).length > 0
? JSON.stringify(label_domain_assignments)
Comment thread
aaronlinear marked this conversation as resolved.
{topology_assignments
? JSON.stringify(topology_assignments)
: "-"}
</div>
</Box>
Expand Down
10 changes: 8 additions & 2 deletions python/ray/dashboard/client/src/type/placementGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ export type PlacementGroup = {
[key: string]: number | string;
} | null;
bundles: Bundle[];
label_domain_key?: string | null;
label_domain_assignments?: {
// Topology strategy: map from label key (e.g. "ray.io/gpu-domain") to
// corresponding placement strategy.
topology_strategy?: {
[key: string]: string;
} | null;
// Topology assignments: map from topology label key to the value the
// scheduler selected for this PG (e.g. {"ray.io/gpu-domain": "rack-1"}).
topology_assignments?: {
[key: string]: string;
} | null;
};
1 change: 1 addition & 0 deletions python/ray/includes/common.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ cdef extern from "ray/core_worker/common.h" nogil:
c_bool is_detached,
CNodeID soft_target_node_id,
const c_vector[unordered_map[c_string, c_string]] &bundle_label_selector,
const unordered_map[c_string, CPlacementStrategy] &topology_strategy,
)

cdef cppclass CObjectLocation "ray::core::ObjectLocation":
Expand Down
2 changes: 2 additions & 0 deletions python/ray/includes/common.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ RAY_NODE_ACCELERATOR_TYPE_KEY = kLabelKeyNodeAcceleratorType.decode()
RAY_NODE_MARKET_TYPE_KEY = kLabelKeyNodeMarketType.decode()
RAY_NODE_REGION_KEY = kLabelKeyNodeRegion.decode()
RAY_NODE_ZONE_KEY = kLabelKeyNodeZone.decode()
# Keep this in sync with NODE_ID_LABEL_KEY in ray.util.placement_group. That
# module cannot import this exported value because it forms a circular dependency
RAY_NODE_ID_KEY = kLabelKeyNodeID.decode()
RAY_NODE_GROUP_KEY = kLabelKeyNodeGroup.decode()

Expand Down
1 change: 1 addition & 0 deletions python/ray/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ py_test_module_list(
"test_minimal_install.py",
"test_path_utils.py",
"test_runtime_env_ray_minimal.py",
"test_topology_strategy.py",
],
tags = [
"exclusive",
Expand Down
146 changes: 0 additions & 146 deletions python/ray/tests/test_bundle_label_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,152 +297,6 @@ def test_spread_strategy_bundle_label_selector(ray_start_cluster):
ray.util.remove_placement_group(fail_pg)


def test_gpu_domain_scheduling_reschedule_on_node_failure(ray_start_cluster):
"""
Spins up 6 nodes in a single GPU domain (rack-1). Schedules 4 bundles,
then kills 2 nodes containing bundles. Verifies that the PG reschedules
the bundles onto remaining nodes that share the same GPU domain.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)

rack_labels = {
"ray.io/gpu-domain": "rack-1",
"ray.io/accelerator-type": "GB300",
}

rack_nodes = []
for _ in range(6):
rack_nodes.append(cluster.add_node(num_cpus=1, labels=rack_labels))
for _ in range(2):
cluster.add_node(num_cpus=1)

bundles = [{"CPU": 1}] * 4
label_selector = [{"ray.io/accelerator-type": "GB300"}] * 4

pg = placement_group(
bundles=bundles,
bundle_label_selector=label_selector,
)
ray.get(pg.ready(), timeout=30)
assert placement_group_table(pg)["state"] == "CREATED"

cluster.remove_node(rack_nodes[0])
cluster.remove_node(rack_nodes[1])

ray.get(pg.ready(), timeout=30)
assert placement_group_table(pg)["state"] == "CREATED"

# Verify that the rescheduled bundles are on nodes with the rack-1 gpu-domain label.
node_id_to_labels = {node["NodeID"]: node.get("Labels", {}) for node in ray.nodes()}
for node_id in placement_group_table(pg)["bundles_to_node_id"].values():
labels = node_id_to_labels[node_id]
assert labels.get("ray.io/gpu-domain") == "rack-1"


def test_gpu_domain_scheduling_infeasible_after_node_kill(ray_start_cluster):
"""
Spins up 2 nodes in a single GPU domain (rack-1). Schedules 2 bundles,
then kills one node containing a bundle. The surviving node already contains a bundle so
the orphaned bundle can't be rescheduled on it, and is left infeasible.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)

rack_labels = {
"ray.io/gpu-domain": "rack-1",
"ray.io/accelerator-type": "GB300",
}

cluster.add_node(num_cpus=1, labels=rack_labels)
node_b = cluster.add_node(num_cpus=1, labels=rack_labels)
cluster.add_node(num_cpus=1)

bundles = [{"CPU": 1}] * 2
label_selector = [{"ray.io/accelerator-type": "GB300"}] * 2

pg = placement_group(
bundles=bundles,
bundle_label_selector=label_selector,
)
ray.get(pg.ready(), timeout=10)
assert placement_group_table(pg)["state"] == "CREATED"

cluster.remove_node(node_b)

with pytest.raises(ray.exceptions.GetTimeoutError):
ray.get(pg.ready(), timeout=5)

state = placement_group_table(pg)["state"]
assert state == "RESCHEDULING", f"Expected RESCHEDULING, got {state}"


def test_gpu_domain_scheduling_rescheduling_on_gpu_domain_failure(ray_start_cluster):
"""Verify the PG reschedules onto a new GPU domain after total domain failure.

Creates a placement group on rack 1. Then removes one rack 1 node so the PG cannot
be fully placed on that rack anymore, confirming the PG enters RESCHEDULING. While
partial rack 1 remains, the PG stays infeasible because label locality pins it to
the original domain. Once all rack 1 nodes are removed (total failure), the gpu
domain assignment is cleared and the PG reschedules onto rack 2.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=0)
ray.init(address=cluster.address)

rack1_labels = {
"ray.io/gpu-domain": "rack-1",
"ray.io/accelerator-type": "GB300",
}

rack2_labels = {
"ray.io/gpu-domain": "rack-2",
"ray.io/accelerator-type": "GB300",
}

rack1_nodes = []
for _ in range(4):
rack1_nodes.append(cluster.add_node(num_cpus=1, labels=rack1_labels))

def assert_pg_nodes_label_value(cluster_nodes, pg, label, value):
node_id_to_labels = {node["NodeID"]: node["Labels"] for node in cluster_nodes}
for node_id in placement_group_table(pg)["bundles_to_node_id"].values():
labels = node_id_to_labels[node_id]
assert labels.get(label) == value

bundles = [{"CPU": 1}] * 4
label_selector = [{"ray.io/accelerator-type": "GB300"}] * 4

pg = placement_group(
bundles=bundles,
bundle_label_selector=label_selector,
)
ray.get(pg.ready(), timeout=30)
assert placement_group_table(pg)["state"] == "CREATED"

assert_pg_nodes_label_value(ray.nodes(), pg, "ray.io/gpu-domain", "rack-1")

for _ in range(4):
cluster.add_node(num_cpus=1, labels=rack2_labels)

cluster.remove_node(rack1_nodes[0])
with pytest.raises(ray.exceptions.GetTimeoutError):
ray.get(pg.ready(), timeout=5)

assert placement_group_table(pg)["state"] == "RESCHEDULING"

for node in rack1_nodes[1:]:
cluster.remove_node(node)

ray.get(pg.ready(), timeout=30)
assert placement_group_table(pg)["state"] == "CREATED"

# Verify that the PG is now on rack 2 after all rack 1 nodes are removed
assert_pg_nodes_label_value(ray.nodes(), pg, "ray.io/gpu-domain", "rack-2")


if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
Expand Down
Loading
Loading