[object Object]

← back to Exo

leaf placement

363c98a872105131d195fd5de54ba1dadbd6557c · 2025-10-15 12:47:26 +0100 · Evan Quiney

Co-authored-by: Alex Cheema <alexcheema123@gmail.com>

Files touched

Diff

commit 363c98a872105131d195fd5de54ba1dadbd6557c
Author: Evan Quiney <evanev7@gmail.com>
Date:   Wed Oct 15 12:47:26 2025 +0100

    leaf placement
    
    Co-authored-by: Alex Cheema <alexcheema123@gmail.com>
---
 src/exo/master/placement.py            | 11 +++--
 src/exo/master/tests/test_placement.py | 80 ++++++++++++++++++++++++++++++++++
 src/exo/shared/topology.py             |  3 ++
 3 files changed, 91 insertions(+), 3 deletions(-)

diff --git a/src/exo/master/placement.py b/src/exo/master/placement.py
index b5e402d9..669688c8 100644
--- a/src/exo/master/placement.py
+++ b/src/exo/master/placement.py
@@ -17,6 +17,7 @@ from exo.shared.types.commands import (
 from exo.shared.types.common import Host
 from exo.shared.types.events import Event, InstanceCreated, InstanceDeleted
 from exo.shared.types.memory import Memory
+from exo.shared.types.topology import NodeInfo
 from exo.shared.types.worker.common import InstanceId
 from exo.shared.types.worker.instances import Instance, InstanceStatus
 
@@ -36,7 +37,7 @@ def get_instance_placements_after_create(
     from loguru import logger
 
     logger.info("finding cycles:")
-    cycles = topology.get_cycles_tb()
+    cycles = topology.get_cycles()
     logger.info(f"{cycles=}")
     # we can also always just have a node on its own
     singleton_cycles = [[node] for node in all_nodes]
@@ -58,12 +59,16 @@ def get_instance_placements_after_create(
 
     if tb_only and smallest_tb_cycles == []:
         raise ValueError("No cycles found with sufficient memory")
-
     elif smallest_tb_cycles != []:
         smallest_cycles = smallest_tb_cycles
 
+    cycles_with_leaf_nodes: list[list[NodeInfo]] = [
+        cycle for cycle in smallest_cycles
+        if any(topology.node_is_leaf(node.node_id) for node in cycle)
+    ]
+
     selected_cycle = max(
-        smallest_cycles,
+        cycles_with_leaf_nodes if cycles_with_leaf_nodes != [] else smallest_cycles,
         key=lambda cycle: sum(
             (
                 node.node_profile.memory.ram_available
diff --git a/src/exo/master/tests/test_placement.py b/src/exo/master/tests/test_placement.py
index d210c9ff..cace7bad 100644
--- a/src/exo/master/tests/test_placement.py
+++ b/src/exo/master/tests/test_placement.py
@@ -234,3 +234,83 @@ def test_get_transition_events_delete_instance(instance: Instance):
     assert len(events) == 1
     assert isinstance(events[0], InstanceDeleted)
     assert events[0].instance_id == instance_id
+
+
+def test_placement_prioritizes_leaf_cycle_with_less_memory(
+    topology: Topology,
+    model_meta: ModelMetadata,
+    create_node: Callable[[int, NodeId | None], NodeInfo],
+    create_connection: Callable[[NodeId, NodeId], Connection],
+):
+    # Arrange two 3-node cycles. The A-B-C cycle has a leaf node (only one outgoing
+    # neighbor per node). The D-E-F cycle has extra outgoing edges making its nodes
+    # non-leaves. Ensure both cycles have sufficient total memory, with the A-B-C
+    # cycle having LESS total memory than D-E-F. The algorithm should still choose
+    # the cycle that contains a leaf node.
+
+    # Model requires more than any single node but fits within a 3-node cycle
+    model_meta.storage_size.in_bytes = 1500
+    model_meta.n_layers = 12
+
+    # Create node ids
+    node_id_a = NodeId()
+    node_id_b = NodeId()
+    node_id_c = NodeId()
+    node_id_d = NodeId()
+    node_id_e = NodeId()
+    node_id_f = NodeId()
+
+    # Extra sink nodes to make D/E/F non-leaf via additional outgoing edges
+    node_id_x = NodeId()
+    node_id_y = NodeId()
+    node_id_z = NodeId()
+
+    # A-B-C cycle total memory = 1600 (< D-E-F total)
+    topology.add_node(create_node(400, node_id_a))
+    topology.add_node(create_node(400, node_id_b))
+    topology.add_node(create_node(800, node_id_c))
+
+    # D-E-F cycle total memory = 1800 (> A-B-C total)
+    topology.add_node(create_node(600, node_id_d))
+    topology.add_node(create_node(600, node_id_e))
+    topology.add_node(create_node(600, node_id_f))
+
+    # Extra nodes with tiny memory so they can't form singleton placements
+    topology.add_node(create_node(10, node_id_x))
+    topology.add_node(create_node(10, node_id_y))
+    topology.add_node(create_node(10, node_id_z))
+
+    # Build directed cycles
+    topology.add_connection(create_connection(node_id_a, node_id_b))
+    topology.add_connection(create_connection(node_id_b, node_id_c))
+    topology.add_connection(create_connection(node_id_c, node_id_a))
+
+    topology.add_connection(create_connection(node_id_d, node_id_e))
+    topology.add_connection(create_connection(node_id_e, node_id_f))
+    topology.add_connection(create_connection(node_id_f, node_id_d))
+
+    # Add extra outgoing edges from D/E/F so none of them are leaves
+    topology.add_connection(create_connection(node_id_d, node_id_x))
+    topology.add_connection(create_connection(node_id_e, node_id_y))
+    topology.add_connection(create_connection(node_id_f, node_id_z))
+
+    create_instance_command = CreateInstance(
+        command_id=CommandId(),
+        model_meta=model_meta,
+    )
+
+    # Act
+    placements = get_instance_placements_after_create(create_instance_command, topology, {})
+
+    # Assert the chosen cycle is A-B-C (contains at least one leaf node), even though
+    # D-E-F has more total memory.
+    assert len(placements) == 1
+    instance_id = list(placements.keys())[0]
+    instance = placements[instance_id]
+
+    assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
+    expected_leaf_cycle_nodes = {node_id_a, node_id_b, node_id_c}
+    non_leaf_cycle_nodes = {node_id_d, node_id_e, node_id_f}
+
+    assert expected_leaf_cycle_nodes.issubset(assigned_nodes)
+    assert assigned_nodes.isdisjoint(non_leaf_cycle_nodes)
diff --git a/src/exo/shared/topology.py b/src/exo/shared/topology.py
index 5be5af86..9727ae99 100644
--- a/src/exo/shared/topology.py
+++ b/src/exo/shared/topology.py
@@ -49,6 +49,9 @@ class Topology:
         self._node_id_to_rx_id_map[node.node_id] = rx_id
         self._rx_id_to_node_id_map[rx_id] = node.node_id
 
+    def node_is_leaf(self, node_id: NodeId) -> bool:
+        return node_id in self._node_id_to_rx_id_map and len(self._graph.neighbors(self._node_id_to_rx_id_map[node_id])) == 1
+
     def contains_node(self, node_id: NodeId) -> bool:
         return node_id in self._node_id_to_rx_id_map
 

← f25689d9 fix a race condition  ·  back to Exo  ·  Update. 56f783b3 →