Compare commits

..
23 changed files with 128 additions and 655 deletions

No files matched your search

-1
View File
@@ -36,7 +36,6 @@ jobs:
with:
name: exo
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
pushFilter: '-docker-image.tar.gz$'
- name: Build Metal packages (macOS only)
if: runner.os == 'macOS'
+15
View File
@@ -287,6 +287,10 @@ def run_one_completion(
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": use_prefix_cache,
# Argmax sampling for deterministic, faster decode (matches
# mlx_lm.benchmark default). Avoids per-token softmax + categorical
# sample over the full vocab.
"temperature": 0.0,
}
if not stream:
@@ -727,6 +731,15 @@ def main() -> int:
)
runs.append(row)
all_rows.append(row)
# Per-repeat trial log so individual numbers are visible
# alongside the final averaged summary. Useful for
# spotting outliers and trial-to-trial variance.
_s = row.get("stats") or {}
logger.info(
f" repeat {r + 1}/{args.repeat}: "
f"prompt_tps={_s.get('prompt_tps', 0):.2f} "
f"gen_tps={_s.get('generation_tps', 0):.2f}"
)
else:
# Concurrent: fire N requests in parallel
# Pre-build prompt once, barrier ensures simultaneous dispatch
@@ -738,6 +751,8 @@ def main() -> int:
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": args.use_prefix_cache,
# Argmax sampling — matches mlx_lm.benchmark default
"temperature": 0.0,
}
barrier = threading.Barrier(concurrency)
batch_start = threading.Event()
+7 -26
View File
@@ -183,24 +183,7 @@ let
inherit venv;
editablePythonSet = pythonSet.overrideScope editableOverlay;
mkPythonScript = path: mkApp ''python ${path} "$@"'';
mkOutputs = name:
let package = mkApp ''exo "$@"'' name;
in {
${name} = package;
"${name}-docker-image" = pkgs.dockerTools.buildLayeredImage {
name = "${name}-docker-image";
config = {
Entrypoint = [ (lib.getExe package) ];
Env = [
"EXO_HOME=/var/lib/${name}"
];
};
extraCommands = ''
mkdir -p var/lib/${name}
'';
};
};
mkExo = mkApp ''exo "$@"'';
};
in
{
@@ -208,7 +191,7 @@ in
{ self', pkgs, unfreePkgs, lib, ... }:
let
inherit (pkgs.stdenv.hostPlatform) isLinux;
inherit (mkPythonSet { inherit self' pkgs lib; members.exo = [ "cpu" ]; }) editablePythonSet mkOutputs;
inherit (mkPythonSet { inherit self' pkgs lib; members = { exo = [ "cpu" ]; }; }) editablePythonSet mkExo;
# Virtual environment with dev dependencies for testing
testVenv = (mkPythonSet {
@@ -230,13 +213,10 @@ in
text = ''exec python ${path} "$@"'';
};
defaultOutputs = mkOutputs "exo";
cuda12Outputs = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_12) pkgs; members.exo=["cuda12"]; }).mkOutputs "exo-cuda-12";
cuda13Outputs = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_13) pkgs; members.exo=["cuda13"]; }).mkOutputs "exo-cuda-13";
in
{
packages = {
# for devShell
exo = mkExo "exo";
editableVenv = editablePythonSet.mkVirtualEnv "exo-dev-env" { exo = [ "dev" ]; };
# for running tests in ci
exo-test-env = testVenv;
@@ -245,9 +225,10 @@ in
exo-eval-tool-calls = mkBenchScript "exo-eval-tool-calls" (inputs.self + /bench/eval_tool_calls.py);
# used by ./tests/run_exo_on.sh
exo-get-all-models-on-cluster = mkSimplePythonScript "exo-get-all-models-on-cluster" (inputs.self + /tests/get_all_models_on_cluster.py);
} // defaultOutputs
// lib.optionalAttrs isLinux cuda12Outputs
// lib.optionalAttrs isLinux cuda13Outputs;
} // lib.optionalAttrs isLinux {
exo-cuda-12 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_12) pkgs; members = { exo = [ "cuda12" ]; }; }).mkExo "exo-cuda-12";
exo-cuda-13 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_13) pkgs; members = { exo = [ "cuda13" ]; }; }).mkExo "exo-cuda-13";
};
checks = {
lint = pkgs.runCommand "ruff-lint" { } ''
+13 -12
View File
@@ -133,10 +133,12 @@ from exo.shared.constants import (
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
from exo.shared.models import model_cards
from exo.shared.models.model_cards import (
ModelCard,
ModelId,
add_to_card_cache,
get_card,
get_model_cards,
)
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
from exo.shared.types.chunks import (
@@ -479,7 +481,6 @@ class API:
topology=self.state.topology,
current_instances=self.state.instances,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -543,7 +544,6 @@ class API:
current_instances=self.state.instances,
required_nodes=required_nodes,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
except ValueError as exc:
if (model_card.model_id, sharding, instance_meta, 0) not in seen:
@@ -1633,16 +1633,17 @@ class API:
async def ollama_tags(self) -> OllamaTagsResponse:
"""Returns list of models in Ollama tags format. We return the downloaded ones only."""
downloaded_model_ids: set[ModelId] = set()
def none_if_empty(value: str) -> str | None:
return value or None
downloaded_model_ids: set[str] = set()
for node_downloads in self.state.downloads.values():
for dl in node_downloads:
if isinstance(dl, DownloadCompleted):
downloaded_model_ids.add(dl.shard_metadata.model_card.model_id)
cards = [
c
for c in await model_cards.card_cache.list_all()
if c.model_id in downloaded_model_ids
c for c in await get_model_cards() if c.model_id in downloaded_model_ids
]
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -1655,8 +1656,8 @@ class API:
size=card.storage_size.in_bytes,
digest="sha256:000000000000",
details=OllamaModelDetails(
family=card.family or None,
quantization_level=card.quantization or None,
family=none_if_empty(card.family),
quantization_level=none_if_empty(card.quantization),
),
)
for card in cards
@@ -1719,7 +1720,7 @@ class API:
async def get_models(self, status: str | None = Query(default=None)) -> ModelList:
"""Returns list of available models, optionally filtered by being downloaded."""
cards = await model_cards.card_cache.list_all()
cards = await get_model_cards()
if status == "downloaded":
downloaded_model_ids: set[str] = set()
@@ -1770,7 +1771,7 @@ class API:
# Immediately update the local cache so the subsequent GET /models
# returns the new model without waiting for the event round-trip.
model_cards.card_cache.cc[card.model_id] = card
add_to_card_cache(card)
return ModelListModel(
id=card.model_id,
@@ -1786,7 +1787,7 @@ class API:
async def delete_custom_model(self, model_id: ModelId) -> JSONResponse:
"""Delete a user-added custom model card and sync deletion across the cluster."""
card = model_cards.card_cache.get(model_id)
card = get_card(model_id)
if card is None or not card.is_custom:
raise HTTPException(status_code=404, detail="Custom model card not found")
+2 -3
View File
@@ -16,8 +16,7 @@ from exo.download.download_utils import (
)
from exo.download.shard_downloader import ShardDownloader
from exo.shared.constants import EXO_DEFAULT_MODELS_DIR, EXO_MODELS_READ_ONLY_DIRS
from exo.shared.models import model_cards
from exo.shared.models.model_cards import ModelId
from exo.shared.models.model_cards import ModelId, get_model_cards
from exo.shared.types.commands import (
CancelDownload,
DeleteDownload,
@@ -423,7 +422,7 @@ class DownloadCoordinator:
)
# Scan read-only directories for pre-downloaded models
if EXO_MODELS_READ_ONLY_DIRS:
for card in await model_cards.card_cache.list_all():
for card in await get_model_cards():
mid = card.model_id
if mid in self.active_downloads:
continue
+2 -2
View File
@@ -11,11 +11,11 @@ from exo.download.download_utils import (
download_shard,
)
from exo.download.shard_downloader import ShardDownloader
from exo.shared.models import model_cards
from exo.shared.models.model_cards import (
ModelCard,
ModelId,
ModelTask,
get_model_cards,
)
from exo.shared.types.memory import Memory
from exo.shared.types.worker.shards import (
@@ -258,7 +258,7 @@ class ResumableShardDownloader(ShardDownloader):
tasks = [
create_task(download_with_semaphore(model_card))
for model_card in await model_cards.card_cache.list_all()
for model_card in await get_model_cards()
]
for task in asyncio.as_completed(tasks):
-1
View File
@@ -365,7 +365,6 @@ class Master:
self.state.node_memory,
self.state.node_network,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
+2 -13
View File
@@ -28,7 +28,7 @@ from exo.shared.types.events import (
TaskStatusUpdated,
)
from exo.shared.types.memory import Memory
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo, NodeRdmaCtlStatus
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo
from exo.shared.types.tasks import Task, TaskId, TaskStatus
from exo.shared.types.worker.downloads import (
DownloadCompleted,
@@ -105,7 +105,6 @@ def place_instance(
node_network: Mapping[NodeId, NodeNetworkInfo],
required_nodes: set[NodeId] | None = None,
download_status: Mapping[NodeId, Sequence[DownloadProgress]] | None = None,
node_rdma_ctl: Mapping[NodeId, NodeRdmaCtlStatus] | None = None,
) -> dict[InstanceId, Instance]:
cycles = topology.get_cycles()
candidate_cycles = list(filter(lambda it: len(it) >= command.min_nodes, cycles))
@@ -167,18 +166,8 @@ def place_instance(
smallest_cycles = get_smallest_cycles(cycles_with_sufficient_memory)
rdma_ctl_status = node_rdma_ctl or {}
def _all_rdma_ctl_enabled(cycle: Cycle) -> bool:
return all(
((status := rdma_ctl_status.get(node_id)) is not None and status.enabled)
for node_id in cycle
)
smallest_rdma_cycles = [
cycle
for cycle in smallest_cycles
if topology.is_rdma_cycle(cycle) and _all_rdma_ctl_enabled(cycle)
cycle for cycle in smallest_cycles if topology.is_rdma_cycle(cycle)
]
if command.instance_meta == InstanceMeta.MlxJaccl:
+2 -144
View File
@@ -21,11 +21,7 @@ from exo.shared.types.events import (
)
from exo.shared.types.memory import Memory
from exo.shared.types.multiaddr import Multiaddr
from exo.shared.types.profiling import (
NetworkInterfaceInfo,
NodeNetworkInfo,
NodeRdmaCtlStatus,
)
from exo.shared.types.profiling import NetworkInterfaceInfo, NodeNetworkInfo
from exo.shared.types.tasks import TaskId, TaskStatus, TextGeneration
from exo.shared.types.text_generation import (
InputMessage,
@@ -443,21 +439,8 @@ def test_tensor_rdma_backend_connectivity_matrix(
min_nodes=1,
)
node_rdma_ctl = {
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
node_c: NodeRdmaCtlStatus(enabled=True),
}
# act
placements = place_instance(
cic,
topology,
{},
node_memory,
node_network,
node_rdma_ctl=node_rdma_ctl,
)
placements = place_instance(cic, topology, {}, node_memory, node_network)
# assert
assert len(placements) == 1
@@ -499,131 +482,6 @@ def test_tensor_rdma_backend_connectivity_matrix(
assert len(ip_part.split(".")) == 4
def _build_three_node_rdma_topology() -> tuple[
Topology, NodeId, NodeId, NodeId, dict[NodeId, NodeNetworkInfo]
]:
topology = Topology()
node_a = NodeId()
node_b = NodeId()
node_c = NodeId()
ethernet_interface = NetworkInterfaceInfo(name="en0", ip_address="10.0.0.1")
ethernet_conn = SocketConnection(
sink_multiaddr=Multiaddr(address="/ip4/10.0.0.1/tcp/8000")
)
node_network = {
node_a: NodeNetworkInfo(interfaces=[ethernet_interface]),
node_b: NodeNetworkInfo(interfaces=[ethernet_interface]),
node_c: NodeNetworkInfo(interfaces=[ethernet_interface]),
}
for n in (node_a, node_b, node_c):
topology.add_node(n)
rdma_pairs = [
(node_a, node_b, 3),
(node_b, node_a, 3),
(node_b, node_c, 4),
(node_c, node_b, 4),
(node_a, node_c, 5),
(node_c, node_a, 5),
]
for src, sink, iface in rdma_pairs:
topology.add_connection(
Connection(source=src, sink=sink, edge=create_rdma_connection(iface))
)
socket_pairs = [
(node_a, node_b),
(node_b, node_c),
(node_c, node_a),
(node_a, node_c),
(node_b, node_a),
(node_c, node_b),
]
for src, sink in socket_pairs:
topology.add_connection(Connection(source=src, sink=sink, edge=ethernet_conn))
return topology, node_a, node_b, node_c, node_network
def test_place_mlx_jaccl_rejects_when_a_node_has_rdma_ctl_disabled(
model_card: ModelCard,
):
# arrange
model_card = model_card.model_copy(
update={"n_layers": 12, "storage_size": Memory.from_bytes(1500)}
)
topology, node_a, node_b, node_c, node_network = _build_three_node_rdma_topology()
node_memory = {
node_a: create_node_memory(500),
node_b: create_node_memory(500),
node_c: create_node_memory(500),
}
node_rdma_ctl = {
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
node_c: NodeRdmaCtlStatus(enabled=False),
}
cic = PlaceInstance(
sharding=Sharding.Tensor,
instance_meta=InstanceMeta.MlxJaccl,
command_id=CommandId(),
model_card=model_card,
min_nodes=3,
)
# act / assert
with pytest.raises(
ValueError, match="Requested RDMA \\(MlxJaccl\\) but no RDMA-connected cycles"
):
place_instance(
cic,
topology,
{},
node_memory,
node_network,
node_rdma_ctl=node_rdma_ctl,
)
def test_place_mlx_jaccl_rejects_when_node_rdma_ctl_missing(model_card: ModelCard):
"""A node with no observed rdma_ctl status must not participate in RDMA placement."""
# arrange
model_card = model_card.model_copy(
update={"n_layers": 12, "storage_size": Memory.from_bytes(1500)}
)
topology, node_a, node_b, node_c, node_network = _build_three_node_rdma_topology()
node_memory = {
node_a: create_node_memory(500),
node_b: create_node_memory(500),
node_c: create_node_memory(500),
}
# node_c has no rdma_ctl entry at all
node_rdma_ctl = {
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
}
cic = PlaceInstance(
sharding=Sharding.Tensor,
instance_meta=InstanceMeta.MlxJaccl,
command_id=CommandId(),
model_card=model_card,
min_nodes=3,
)
# act / assert
with pytest.raises(ValueError):
place_instance(
cic,
topology,
{},
node_memory,
node_network,
node_rdma_ctl=node_rdma_ctl,
)
def _make_task(
instance_id: InstanceId,
status: TaskStatus = TaskStatus.Running,
+3 -50
View File
@@ -4,8 +4,7 @@ from datetime import datetime
from loguru import logger
from exo.shared.models.model_cards import ModelCard
from exo.shared.types.common import ModelId, NodeId
from exo.shared.types.common import NodeId
from exo.shared.types.events import (
ChunkGenerated,
CustomModelCardAdded,
@@ -66,18 +65,6 @@ from exo.utils.info_gatherer.info_gatherer import (
)
def _is_rdma_ctl_enabled(
node_id: NodeId, node_rdma_ctl: Mapping[NodeId, NodeRdmaCtlStatus]
) -> bool:
"""A node is RDMA-capable only if rdma_ctl status has been observed as enabled.
Missing entries default to ``False`` — if we have not yet observed (or the node
cannot run) ``rdma_ctl``, it must not participate in an RDMA-backed instance.
"""
status = node_rdma_ctl.get(node_id)
return status is not None and status.enabled
def event_apply(event: Event, state: State) -> State:
"""Apply an event to state."""
match event:
@@ -88,12 +75,10 @@ def event_apply(event: Event, state: State) -> State:
| InputChunkReceived()
| TracesCollected()
| TracesMerged()
| CustomModelCardAdded()
| CustomModelCardDeleted()
): # Pass-through events that don't modify state
return state
case CustomModelCardAdded():
return apply_custom_model_card_added(event, state)
case CustomModelCardDeleted():
return apply_custom_model_card_deleted(event, state)
case InstanceCreated():
return apply_instance_created(event, state)
case InstanceDeleted():
@@ -412,9 +397,6 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
for nid in state.node_thunderbolt
for tb_ident in state.node_thunderbolt[nid].interfaces
}
source_is_rdma_enabled = _is_rdma_ctl_enabled(
event.node_id, state.node_rdma_ctl
)
as_rdma_conns = [
Connection(
source=event.node_id,
@@ -427,10 +409,6 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
for tb_conn in info.conns
if tb_conn.source_uuid in conn_map
if tb_conn.sink_uuid in conn_map
if source_is_rdma_enabled
and _is_rdma_ctl_enabled(
conn_map[tb_conn.sink_uuid][0], state.node_rdma_ctl
)
]
topology.replace_all_out_rdma_connections(event.node_id, as_rdma_conns)
case ThunderboltBridgeInfo():
@@ -454,12 +432,6 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
**state.node_rdma_ctl,
event.node_id: NodeRdmaCtlStatus(enabled=info.enabled),
}
# If RDMA just got disabled on this node, drop any RDMA edges touching it
# so placement / topology consumers cannot pick a disabled node for an
# RDMA-backed instance. (Edges will repopulate on the next
# MacThunderboltConnections poll once both endpoints are enabled again.)
if not info.enabled:
topology.remove_all_rdma_connections_touching(event.node_id)
return state.model_copy(update=update)
@@ -475,22 +447,3 @@ def apply_topology_edge_deleted(event: TopologyEdgeDeleted, state: State) -> Sta
topology.remove_connection(event.conn)
# TODO: Clean up removing the reverse connection
return state.model_copy(update={"topology": topology})
def apply_custom_model_card_added(event: CustomModelCardAdded, state: State) -> State:
new_cards: Mapping[ModelId, ModelCard] = {
**state.custom_model_cards,
event.model_card.model_id: event.model_card,
}
return state.model_copy(update={"custom_model_cards": new_cards})
def apply_custom_model_card_deleted(
event: CustomModelCardDeleted, state: State
) -> State:
new_cards: Mapping[ModelId, ModelCard] = {
model_id: card
for model_id, card in state.custom_model_cards.items()
if model_id != event.model_id
}
return state.model_copy(update={"custom_model_cards": new_cards})
+2 -2
View File
@@ -4,13 +4,13 @@ from pathlib import Path
from exo.utils.dashboard_path import find_dashboard, find_resources
_EXO_HOME_ENV = os.environ.get("EXO_HOME", "")
_EXO_HOME_ENV = os.environ.get("EXO_HOME", None)
def _get_xdg_dir(env_var: str, fallback: str) -> Path:
"""Get XDG directory, prioritising EXO_HOME environment variable if its set. On non-Linux platforms, default to ~/.exo."""
if _EXO_HOME_ENV != "":
if _EXO_HOME_ENV is not None:
return Path.home() / _EXO_HOME_ENV
if sys.platform != "linux":
+52 -54
View File
@@ -39,57 +39,7 @@ _BUILTIN_CARD_DIRS = [
Path(RESOURCES_DIR) / "image_model_cards",
]
class _CardCache:
def __init__(self):
self.cc: dict[ModelId, "ModelCard"] = {}
def get(self, model_id: ModelId) -> "ModelCard | None":
return self.cc.get(model_id)
async def save(self, card: "ModelCard"):
self.cc[card.model_id] = card
try:
await card.save_to_custom_dir()
except OSError as e:
logger.warning(f"failed to save custom model card ({e.strerror})")
async def pop(self, model_id: ModelId) -> "ModelCard | None":
"""Delete a user-added custom model card. Returns True if deleted."""
card_path = _custom_cards_dir / (ModelId(model_id).normalize() + ".toml")
try:
if await card_path.exists():
await card_path.unlink()
return self.cc.pop(model_id, None)
except OSError as e:
logger.warning(f"failed to delete custom model card ({e.strerror})")
async def list_all(self) -> list["ModelCard"]:
if len(self.cc) == 0:
await self.refresh()
if EXO_ENABLE_IMAGE_MODELS:
return list(self.cc.values())
return [c for c in self.cc.values() if not _is_image_card(c)]
async def _load_cards_from_dir(self, directory: Path, *, is_custom: bool) -> None:
"""Load all TOML model cards from a directory into the cache."""
async for toml_file in directory.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
if is_custom:
card = card.model_copy(update={"is_custom": True})
if self.get(card.model_id) is None:
self.cc[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
async def refresh(self) -> None:
for path in _BUILTIN_CARD_DIRS:
await self._load_cards_from_dir(path, is_custom=False)
await self._load_cards_from_dir(_custom_cards_dir, is_custom=True)
card_cache = _CardCache()
_card_cache: dict[ModelId, "ModelCard"] = {}
def detect_vision_from_config(model_id: ModelId) -> "VisionCardConfig | None":
@@ -109,10 +59,42 @@ def detect_vision_from_config(model_id: ModelId) -> "VisionCardConfig | None":
return None
async def _load_cards_from_dir(directory: Path, *, is_custom: bool) -> None:
"""Load all TOML model cards from a directory into the cache."""
async for toml_file in directory.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
if is_custom:
card = card.model_copy(update={"is_custom": True})
if card.model_id not in _card_cache:
_card_cache[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
async def _refresh_card_cache() -> None:
for path in _BUILTIN_CARD_DIRS:
await _load_cards_from_dir(path, is_custom=False)
await _load_cards_from_dir(_custom_cards_dir, is_custom=True)
def _is_image_card(card: "ModelCard") -> bool:
return any(t in (ModelTask.TextToImage, ModelTask.ImageToImage) for t in card.tasks)
def get_card(model_id: ModelId) -> "ModelCard | None":
"""Look up a single model card from the cache by ID."""
return _card_cache.get(model_id)
async def get_model_cards() -> list["ModelCard"]:
if len(_card_cache) == 0:
await _refresh_card_cache()
if EXO_ENABLE_IMAGE_MODELS:
return list(_card_cache.values())
return [c for c in _card_cache.values() if not _is_image_card(c)]
class ModelTask(str, Enum):
TextGeneration = "TextGeneration"
TextToImage = "TextToImage"
@@ -214,13 +196,14 @@ class ModelCard(FrozenModel):
# Is it okay that model card.load defaults to network access if the card doesn't exist? do we want to be more explicit here?
@staticmethod
async def load(model_id: ModelId) -> "ModelCard":
if card_cache.get(model_id) is None:
await card_cache.refresh()
if (mc := card_cache.get(model_id)) is not None:
if model_id not in _card_cache:
await _refresh_card_cache()
if (mc := _card_cache.get(model_id)) is not None:
return mc
mc = await ModelCard.fetch_from_hf(model_id)
await mc.save_to_custom_dir()
_card_cache[model_id] = mc
return mc
@staticmethod
@@ -250,6 +233,21 @@ class ModelCard(FrozenModel):
)
def add_to_card_cache(card: "ModelCard") -> None:
"""Add or update a model card in the in-memory cache."""
_card_cache[card.model_id] = card
async def delete_custom_card(model_id: ModelId) -> bool:
"""Delete a user-added custom model card. Returns True if deleted."""
card_path = _custom_cards_dir / (ModelId(model_id).normalize() + ".toml")
if await card_path.exists():
await card_path.unlink()
_card_cache.pop(model_id, None)
return True
return False
class ConfigData(BaseModel):
model_config = {"extra": "ignore"} # Allow unknown fields
@@ -1,44 +0,0 @@
from exo.shared.apply import apply
from exo.shared.models.model_cards import ModelCard, ModelTask
from exo.shared.types.common import ModelId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
IndexedEvent,
)
from exo.shared.types.memory import Memory
from exo.shared.types.state import State
def _model_card(model_id: ModelId) -> ModelCard:
return ModelCard(
model_id=model_id,
n_layers=1,
storage_size=Memory.from_bytes(1),
hidden_size=1,
supports_tensor=True,
tasks=[ModelTask.TextGeneration],
)
def test_custom_model_card_added_is_reduced_into_state() -> None:
card = _model_card(ModelId("custom/model"))
state = apply(
State(),
IndexedEvent(idx=0, event=CustomModelCardAdded(model_card=card)),
)
assert state.custom_model_cards == {card.model_id: card}
def test_custom_model_card_deleted_removes_card_from_state() -> None:
card = _model_card(ModelId("custom/model"))
state = State(custom_model_cards={card.model_id: card}, last_event_applied_idx=0)
state = apply(
state,
IndexedEvent(idx=1, event=CustomModelCardDeleted(model_id=card.model_id)),
)
assert state.custom_model_cards == {}
@@ -1,231 +0,0 @@
from datetime import datetime, timezone
from exo.shared.apply import apply_node_gathered_info
from exo.shared.topology import Topology
from exo.shared.types.common import NodeId
from exo.shared.types.events import NodeGatheredInfo
from exo.shared.types.profiling import (
NodeRdmaCtlStatus,
NodeThunderboltInfo,
)
from exo.shared.types.state import State
from exo.shared.types.thunderbolt import ThunderboltConnection, ThunderboltIdentifier
from exo.shared.types.topology import RDMAConnection
from exo.utils.info_gatherer.info_gatherer import (
MacThunderboltConnections,
RdmaCtlStatus,
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _make_state_with_thunderbolt_idents(
*node_ids_and_uuids: tuple[NodeId, str, str],
rdma_ctl: dict[NodeId, NodeRdmaCtlStatus] | None = None,
) -> State:
"""Build a State with Thunderbolt identifiers per node so the apply MacThunderboltConnections
case can resolve uuid -> (node, iface)."""
node_thunderbolt = {
nid: NodeThunderboltInfo(
interfaces=[ThunderboltIdentifier(rdma_interface=iface, domain_uuid=uuid)]
)
for nid, uuid, iface in node_ids_and_uuids
}
return State(
node_thunderbolt=node_thunderbolt,
node_rdma_ctl=rdma_ctl or {},
)
def _has_rdma_edge(topology: Topology, source: NodeId, sink: NodeId) -> bool:
return any(
isinstance(edge, RDMAConnection)
for edge in topology.get_all_connections_between(source, sink)
)
def test_mac_thunderbolt_connections_emits_rdma_when_both_endpoints_enabled():
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert _has_rdma_edge(new_state.topology, node_a, node_b)
def test_mac_thunderbolt_connections_skips_rdma_when_source_rdma_ctl_disabled():
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=False),
node_b: NodeRdmaCtlStatus(enabled=True),
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert not _has_rdma_edge(new_state.topology, node_a, node_b)
def test_mac_thunderbolt_connections_skips_rdma_when_sink_rdma_ctl_disabled():
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=False),
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert not _has_rdma_edge(new_state.topology, node_a, node_b)
def test_mac_thunderbolt_connections_skips_rdma_when_rdma_ctl_status_missing():
"""Missing rdma_ctl status defaults to not-enabled — node is RDMA-incapable."""
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
# node_b intentionally absent
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert not _has_rdma_edge(new_state.topology, node_a, node_b)
def test_rdma_ctl_status_disabled_purges_existing_rdma_edges():
"""When a node reports rdma_ctl disabled, all RDMA edges touching it must be removed."""
node_a = NodeId()
node_b = NodeId()
# Start with both nodes RDMA-enabled and existing RDMA edges in the topology.
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
},
)
state = apply_node_gathered_info(
NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
),
state,
)
state = apply_node_gathered_info(
NodeGatheredInfo(
node_id=node_b,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-b", sink_uuid="uuid-a")]
),
),
state,
)
assert _has_rdma_edge(state.topology, node_a, node_b)
assert _has_rdma_edge(state.topology, node_b, node_a)
# Now node_a flips to rdma_ctl disabled — both directions of RDMA edge must drop.
state = apply_node_gathered_info(
NodeGatheredInfo(
node_id=node_a, when=_now(), info=RdmaCtlStatus(enabled=False)
),
state,
)
assert not _has_rdma_edge(state.topology, node_a, node_b)
assert not _has_rdma_edge(state.topology, node_b, node_a)
assert state.node_rdma_ctl[node_a].enabled is False
def test_topology_remove_all_rdma_connections_touching_keeps_socket_edges():
"""Purging RDMA edges for a disabled node must not affect non-RDMA edges."""
from exo.shared.types.multiaddr import Multiaddr
from exo.shared.types.topology import Connection, SocketConnection
topology = Topology()
node_a = NodeId()
node_b = NodeId()
topology.add_node(node_a)
topology.add_node(node_b)
topology.add_connection(
Connection(
source=node_a,
sink=node_b,
edge=RDMAConnection(
source_rdma_iface="rdma_en1", sink_rdma_iface="rdma_en1"
),
)
)
socket_edge = SocketConnection(
sink_multiaddr=Multiaddr(address="/ip4/10.0.0.1/tcp/8000")
)
topology.add_connection(Connection(source=node_a, sink=node_b, edge=socket_edge))
topology.remove_all_rdma_connections_touching(node_a)
assert not _has_rdma_edge(topology, node_a, node_b)
# Socket edge survives.
assert any(
isinstance(edge, SocketConnection)
for edge in topology.get_all_connections_between(node_a, node_b)
)
-16
View File
@@ -169,22 +169,6 @@ class Topology:
for conn in new_connections:
self.add_connection(conn)
def remove_all_rdma_connections_touching(self, node_id: NodeId) -> None:
"""Remove every RDMA edge incident to ``node_id`` (incoming or outgoing)."""
if node_id not in self._vertex_indices:
return
rx_idx = self._vertex_indices[node_id]
rdma_edge_idxs = [
edge_idx
for edge_idx in (
*self._graph.out_edge_indices(rx_idx),
*self._graph.in_edge_indices(rx_idx),
)
if isinstance(self._graph.get_edge_data_by_index(edge_idx), RDMAConnection)
]
for edge_idx in rdma_edge_idxs:
self._graph.remove_edge_from_index(edge_idx)
def remove_connection(self, conn: Connection) -> None:
if (
conn.source not in self._vertex_indices
+1 -5
View File
@@ -5,9 +5,8 @@ from typing import Any, cast
from pydantic import ConfigDict, Field, field_serializer, field_validator
from pydantic.alias_generators import to_camel
from exo.shared.models.model_cards import ModelCard
from exo.shared.topology import Topology, TopologySnapshot
from exo.shared.types.common import ModelId, NodeId
from exo.shared.types.common import NodeId
from exo.shared.types.instance_link import InstanceLink, InstanceLinkId
from exo.shared.types.profiling import (
DiskUsage,
@@ -66,9 +65,6 @@ class State(FrozenModel):
instance_links: Mapping[InstanceLinkId, InstanceLink] = {}
prefill_server_ports: Mapping[RunnerId, int] = {}
# User-added model cards. Workers can reconcile their on-disk custom card cache
custom_model_cards: Mapping[ModelId, ModelCard] = {}
@field_serializer("topology", mode="plain")
def _encode_topology(self, value: Topology) -> TopologySnapshot:
return value.to_snapshot()
+2 -2
View File
@@ -135,9 +135,9 @@ class TextGenerationTaskParams(BaseModel, frozen=True):
prefill_endpoint: str | None = None
def with_card_sampling_defaults(self) -> "TextGenerationTaskParams":
from exo.shared.models import model_cards
from exo.shared.models.model_cards import get_card
card = model_cards.card_cache.get(self.model)
card = get_card(self.model)
if card is None:
return self
+1 -6
View File
@@ -143,7 +143,6 @@ class ImageEngine(Engine):
Generator[tuple[TaskId, Chunk | FinishedResponse | CancelledResponse]] | None
) = field(init=False, default=None)
queue: deque[ImageTask] = field(init=False, default_factory=deque)
_cancelled_tasks: set[TaskId] = field(init=False, default_factory=set)
def warmup(self) -> None:
image = warmup_image_generator(model=self.image_model)
@@ -169,11 +168,7 @@ class ImageEngine(Engine):
task = self.queue.popleft()
self.current_gen = self._run_image_task(task.task_id, task.task_params)
resp = next(self.current_gen, None)
return (
(resp,)
if resp is not None and _is_primary_output_node(self.shard_metadata)
else ()
)
return (resp,) if resp is not None else ()
def close(self) -> None:
with contextlib.suppress(NameError, AttributeError):
+1 -1
View File
@@ -599,7 +599,7 @@ def tensor_auto_parallel(
raise ValueError(f"Unsupported model type: {type(model)}")
model = yield from tensor_parallel_sharding_strategy.shard_model(model)
return patch_tensor_model(model)
return model # PATCH-DISABLED for A/B test
class TensorParallelShardingStrategy(ABC):
+9 -14
View File
@@ -10,7 +10,7 @@ from exo.api.types import ImageEditsTaskParams
from exo.download.download_utils import is_read_only_model_dir, resolve_existing_model
from exo.shared.apply import apply
from exo.shared.constants import EXO_MAX_INSTANCE_RETRIES
from exo.shared.models.model_cards import ModelId, card_cache
from exo.shared.models.model_cards import ModelId, add_to_card_cache, delete_custom_card
from exo.shared.types.chunks import InputImageChunk
from exo.shared.types.commands import (
DeleteInstance,
@@ -20,6 +20,8 @@ from exo.shared.types.commands import (
)
from exo.shared.types.common import CommandId, NodeId, SystemId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
IndexedEvent,
InputChunkReceived,
@@ -108,8 +110,6 @@ class Worker:
tg.start_soon(self.plan_step)
tg.start_soon(self._event_applier)
tg.start_soon(self._poll_connection_updates)
tg.start_soon(self._reconcile_custom_cards)
finally:
# Actual shutdown code - waits for all tasks to complete before executing.
logger.info("Stopping Worker")
@@ -151,6 +151,7 @@ class Worker:
self.input_chunk_buffer[cmd_id][event.chunk.chunk_index] = (
event.chunk
)
if (
len(self.input_chunk_buffer[cmd_id])
== self.input_chunk_counts[cmd_id]
@@ -171,18 +172,12 @@ class Worker:
)
] = img
async def _reconcile_custom_cards(self) -> None:
while True:
await anyio.sleep(1)
target = dict(self.state.custom_model_cards)
for model_id, card in target.items():
if card_cache.get(model_id) == card:
continue
await card_cache.save(card)
if isinstance(event, CustomModelCardAdded):
await event.model_card.save_to_custom_dir()
add_to_card_cache(event.model_card)
for card in await card_cache.list_all():
if card.model_id not in target:
await card_cache.pop(card.model_id)
if isinstance(event, CustomModelCardDeleted):
await delete_custom_card(event.model_id)
async def plan_step(self):
while True:
@@ -138,10 +138,8 @@ class SequentialGenerator(Engine):
def agree_on_tasks(self) -> None:
"""Agree between all ranks about the task ordering (some may have received in different order or not at all)."""
agreed, different = mx_all_gather_tasks(self._maybe_queue, self.group)
# Extend from `agreed` (sorted by task_id on all ranks) to guarantee every
# rank enqueues tasks in the same order, preventing TP collective deadlocks.
self._queue.extend(agreed)
self._maybe_queue = list(different)
self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in different]
def agree_on_cancellations(self) -> None:
"""Agree between all ranks about which tasks to cancel."""
@@ -199,14 +197,9 @@ class SequentialGenerator(Engine):
self._active = None
raise
return filter(
lambda chunk: (
not isinstance(chunk[1], GenerationChunk) or self.device_rank == 0
),
itertools.chain(
output,
map(lambda task: (task, CancelledResponse()), self._cancelled_tasks),
),
return itertools.chain(
output,
map(lambda task: (task, CancelledResponse()), self._cancelled_tasks),
)
def _start_next(self) -> None:
@@ -375,10 +368,8 @@ class BatchGenerator(Engine):
def agree_on_tasks(self) -> None:
"""Agree between all ranks about the task ordering (some may have received in different order or not at all)."""
agreed, different = mx_all_gather_tasks(self._maybe_queue, self.group)
# Extend from `agreed` (sorted by task_id on all ranks) to guarantee every
# rank enqueues tasks in the same order, preventing TP collective deadlocks.
self._queue.extend(agreed)
self._maybe_queue = list(different)
self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in different]
def agree_on_cancellations(self) -> None:
"""Agree between all ranks about which tasks to cancel."""
@@ -458,12 +449,7 @@ class BatchGenerator(Engine):
output.append((task.task_id, FinishedResponse()))
del self._active_tasks[uid]
return filter(
lambda chunk: (
not isinstance(chunk[1], GenerationChunk) or self.device_rank == 0
),
itertools.chain(output, self._apply_cancellations()),
)
return itertools.chain(output, self._apply_cancellations())
def _apply_cancellations(
self,
+2 -2
View File
@@ -390,5 +390,5 @@ class Runner:
chunk: Chunk,
command_id: CommandId,
):
assert isinstance(self.generator, Engine)
self.event_sender.send(ChunkGenerated(command_id=command_id, chunk=chunk))
if self.device_rank == 0:
self.event_sender.send(ChunkGenerated(command_id=command_id, chunk=chunk))
@@ -16,7 +16,7 @@ from exo.download.download_utils import (
fetch_file_list_with_cache,
resolve_model_dir,
)
from exo.shared.models.model_cards import ModelCard, ModelId, card_cache
from exo.shared.models.model_cards import ModelCard, ModelId, get_model_cards
from exo.worker.engines.mlx.utils_mlx import (
get_eos_token_ids_for_model,
load_tokenizer_for_model_id,
@@ -76,7 +76,7 @@ def get_test_models() -> list[ModelCard]:
"""Get a representative sample of models to test."""
# Pick one model from each family to test
families: dict[str, ModelCard] = {}
for card in asyncio.run(card_cache.list_all()):
for card in asyncio.run(get_model_cards()):
# Extract family name (e.g., "llama-3.1" from "llama-3.1-8b")
parts = card.model_id.short().split("-")
family = "-".join(parts[:2]) if len(parts) >= 2 else parts[0]
@@ -298,7 +298,7 @@ async def test_tokenizer_special_tokens(model_card: ModelCard) -> None:
async def test_kimi_tokenizer_specifically():
"""Test Kimi tokenizer with its specific patches and quirks."""
kimi_models = [
card for card in await card_cache.list_all() if "kimi" in card.model_id.lower()
card for card in await get_model_cards() if "kimi" in card.model_id.lower()
]
if not kimi_models:
@@ -350,7 +350,7 @@ async def test_glm_tokenizer_specifically():
glm_model_cards = [
card
for card in await card_cache.list_all()
for card in await get_model_cards()
if contains(card, "glm")
and not contains(card, "-5")
and not contains(card, "4.7")