Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3259322
Fix pipeline_parallel_prefill short-prompt wedge + dashboard kv-backe…
Apr 8, 2026
edf7145
Temporarily bypass LLM warmup for debugging
Apr 8, 2026
70e4192
Add pipeline wrapper hang instrumentation
Apr 8, 2026
430462e
Handle closed worker info event stream
Apr 8, 2026
cc0c033
Allow warmup planning to tolerate ready peers
Apr 8, 2026
b91a99b
Make Gemma warmup more representative
Apr 8, 2026
4c6000f
Test Gemma 4 prompts without empty thought suffix
Apr 8, 2026
2e4fdf8
Trace warmup and first live request shapes
Apr 8, 2026
66392df
Use hello warmup prompt for MLX isolation
Apr 8, 2026
e252ccc
Restore sampler settings for warmup bisect
Apr 8, 2026
1b4d3ee
Restore warmup instructions for bisect
Apr 8, 2026
f55a421
Revert "Restore warmup instructions for bisect"
Apr 8, 2026
b206f87
Disable pipeline prefill mode for short warmup path
Apr 8, 2026
a7be93f
Retry warmup instructions after prefill fix
Apr 8, 2026
dab0b00
Retry longer warmup content after prefill fix
Apr 8, 2026
12e3cdd
Revert "Retry longer warmup content after prefill fix"
Apr 8, 2026
16d5a1c
Try neutral padded warmup content
Apr 8, 2026
1fd6824
Clean up stale runners on node timeout
Apr 8, 2026
bc0c7ae
Raise warmup output budget for bisect
Apr 8, 2026
3d54e87
Add runner lifecycle debug logging
Apr 8, 2026
a22effd
Make pipeline warmup prompt length configurable
Apr 8, 2026
4520e61
Make warmup instructions opt-in for debugging
Apr 8, 2026
f1df109
Force minimal warmup for pipeline models
Apr 8, 2026
097685e
Document pipeline warmup policy and debug env vars
Apr 8, 2026
6ae7078
Address high-severity PR review findings
Apr 8, 2026
431559c
Tighten warmup skip and add review regression tests
Apr 8, 2026
2302c41
Bound warmup decode to the first token
Apr 8, 2026
e7a1d59
Tighten warmup planner and debug helpers
Apr 9, 2026
9163d8d
Tighten env precedence for warmup and debug config
Apr 9, 2026
33f8c0f
Normalize invalid KV backend values in config
Apr 9, 2026
3312c29
Keep warmup cancel checks from becoming too frequent
Apr 9, 2026
2cb8e00
Deduplicate env precedence and KV backend validation
Apr 9, 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
28 changes: 21 additions & 7 deletions src/exo/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
EXO_IMAGE_TRANSPORT_DEBUG,
EXO_MAX_CHUNK_SIZE,
EXO_TRACING_CACHE_DIR,
preferred_env_value,
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
Expand Down Expand Up @@ -200,6 +201,10 @@
from exo.utils.disk_event_log import DiskEventLog
from exo.utils.power_sampler import PowerSampler
from exo.utils.task_group import TaskGroup
from exo.worker.engines.mlx.constants import (
DEFAULT_KV_CACHE_BACKEND,
VALID_KV_CACHE_BACKENDS,
)

if TYPE_CHECKING:
from exo.store.config import ExoConfig
Expand Down Expand Up @@ -2563,6 +2568,20 @@ async def complete_onboarding(self) -> JSONResponse:
# Config & Store endpoints
# ------------------------------------------------------------------

def _effective_kv_cache_backend(self) -> str:
"""Return the effective KV backend after SKULK/EXO env precedence is applied."""
configured_backend = preferred_env_value(
"SKULK_KV_CACHE_BACKEND",
"EXO_KV_CACHE_BACKEND",
"",
)
if not configured_backend:
return DEFAULT_KV_CACHE_BACKEND

if configured_backend not in VALID_KV_CACHE_BACKENDS:
return DEFAULT_KV_CACHE_BACKEND
return configured_backend

async def get_config(self) -> JSONResponse:
if not self._config_path.exists():
return JSONResponse(
Expand All @@ -2571,10 +2590,7 @@ async def get_config(self) -> JSONResponse:
"configPath": str(self._config_path),
"fileExists": False,
"effective": {
"kv_cache_backend": os.environ.get(
"SKULK_KV_CACHE_BACKEND",
os.environ.get("EXO_KV_CACHE_BACKEND", "default"),
),
"kv_cache_backend": self._effective_kv_cache_backend(),
},
}
)
Expand All @@ -2591,9 +2607,7 @@ async def get_config(self) -> JSONResponse:
"configPath": str(self._config_path),
"fileExists": True,
"effective": {
"kv_cache_backend": os.environ.get(
"EXO_KV_CACHE_BACKEND", "default"
),
"kv_cache_backend": self._effective_kv_cache_backend(),
"has_hf_token": has_hf_token or "HF_TOKEN" in os.environ,
},
}
Expand Down
93 changes: 93 additions & 0 deletions src/exo/api/tests/test_config_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# pyright: reportUnusedFunction=false, reportPrivateUsage=false
"""Tests for the GET /config API endpoint."""

from pathlib import Path
from typing import Any

import pytest
from fastapi.testclient import TestClient

from exo.api.main import API
from exo.shared.types.common import NodeId
from exo.utils.channels import channel


def _build_api(node_id: str = "test-node") -> API:
"""Create a minimal API instance for config endpoint testing."""
command_sender, _ = channel()
download_sender, _ = channel()
_, event_receiver = channel()
_, election_receiver = channel()
return API(
NodeId(node_id),
port=52415,
event_receiver=event_receiver,
command_sender=command_sender,
download_command_sender=download_sender,
election_receiver=election_receiver,
enable_event_log=False,
mount_dashboard=False,
)


def test_get_config_reports_effective_kv_backend_when_file_exists(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "exo.yaml"
config_path.write_text(
"inference:\n kv_cache_backend: default\nhf_token: secret-token\n",
encoding="utf-8",
)
monkeypatch.setenv("SKULK_KV_CACHE_BACKEND", "optiq")

api = _build_api()
api._config_path = config_path # pyright: ignore[reportPrivateUsage]
client = TestClient(api.app)

response = client.get("/config")

assert response.status_code == 200
data: dict[str, Any] = response.json()
assert data["fileExists"] is True
assert data["configPath"] == str(config_path)
assert data["config"]["inference"]["kv_cache_backend"] == "default"
assert data["config"].get("hf_token") is None
assert data["effective"]["kv_cache_backend"] == "optiq"
assert data["effective"]["has_hf_token"] is True


def test_get_config_treats_blank_skulk_kv_backend_as_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "exo.yaml"
config_path.write_text("inference:\n kv_cache_backend: default\n", encoding="utf-8")
monkeypatch.setenv("SKULK_KV_CACHE_BACKEND", "")
monkeypatch.setenv("EXO_KV_CACHE_BACKEND", "optiq")

api = _build_api()
api._config_path = config_path # pyright: ignore[reportPrivateUsage]
client = TestClient(api.app)

response = client.get("/config")

assert response.status_code == 200
data: dict[str, Any] = response.json()
assert data["effective"]["kv_cache_backend"] == "default"


def test_get_config_treats_invalid_skulk_kv_backend_as_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "exo.yaml"
config_path.write_text("inference:\n kv_cache_backend: default\n", encoding="utf-8")
monkeypatch.setenv("SKULK_KV_CACHE_BACKEND", "typo-backend")

api = _build_api()
api._config_path = config_path # pyright: ignore[reportPrivateUsage]
client = TestClient(api.app)

response = client.get("/config")

assert response.status_code == 200
data: dict[str, Any] = response.json()
assert data["effective"]["kv_cache_backend"] == "default"
34 changes: 34 additions & 0 deletions src/exo/shared/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,39 @@ def apply_runner_status_updated(event: RunnerStatusUpdated, state: State) -> Sta
def apply_node_timed_out(event: NodeTimedOut, state: State) -> State:
topology = copy.deepcopy(state.topology)
topology.remove_node(event.node_id)
affected_instance_ids = {
instance_id
Comment thread
ttupper92618 marked this conversation as resolved.
for instance_id, instance in state.instances.items()
if event.node_id in instance.shard_assignments.node_to_runner
}
affected_runner_ids = {
runner_id
for instance_id in affected_instance_ids
for runner_id in state.instances[instance_id].shard_assignments.runner_to_shard
}
instances = {
instance_id: instance
for instance_id, instance in state.instances.items()
if instance_id not in affected_instance_ids
}
runners = {
runner_id: runner_status
for runner_id, runner_status in state.runners.items()
if runner_id not in affected_runner_ids
}
tasks = {
task_id: task
for task_id, task in state.tasks.items()
if task.instance_id not in affected_instance_ids
}
Comment thread
ttupper92618 marked this conversation as resolved.
last_seen = {
key: value for key, value in state.last_seen.items() if key != event.node_id
}
Comment thread
ttupper92618 marked this conversation as resolved.
node_identities = {
key: value
for key, value in state.node_identities.items()
if key != event.node_id
}
downloads = {
key: value for key, value in state.downloads.items() if key != event.node_id
}
Expand Down Expand Up @@ -257,9 +287,13 @@ def apply_node_timed_out(event: NodeTimedOut, state: State) -> State:
)
return state.model_copy(
update={
"instances": instances,
"runners": runners,
"tasks": tasks,
"downloads": downloads,
"topology": topology,
"last_seen": last_seen,
"node_identities": node_identities,
"node_memory": node_memory,
"node_disk": node_disk,
"node_system": node_system,
Expand Down
17 changes: 17 additions & 0 deletions src/exo/shared/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ def _env(skulk_key: str, exo_key: str, default: str | None = None) -> str | None
return os.environ.get(skulk_key, os.environ.get(exo_key, default))


def preferred_env_value(
skulk_key: str,
exo_key: str,
default: str | None = None,
) -> str | None:
"""Return the SKULK value when its key exists, else the legacy EXO value.

This variant preserves explicitly blank `SKULK_*` values instead of
treating them as absent, which is important for debug/config toggles where
a blank value should disable the feature instead of falling back to a
legacy `EXO_*` env var.
"""
if skulk_key in os.environ:
return os.environ[skulk_key]
return os.environ.get(exo_key, default)


_SKULK_HOME_ENV = _env("SKULK_HOME", "EXO_HOME")


Expand Down
138 changes: 138 additions & 0 deletions src/exo/shared/tests/test_apply/test_apply_node_timed_out.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from datetime import datetime

from exo.shared.apply import apply_node_timed_out
from exo.shared.models.model_cards import ModelCard, ModelId, ModelTask
from exo.shared.types.common import NodeId
from exo.shared.types.events import NodeTimedOut
from exo.shared.types.memory import Memory
from exo.shared.types.profiling import NodeIdentity
from exo.shared.types.state import State
from exo.shared.types.tasks import StartWarmup, TaskId, TaskStatus
from exo.shared.types.worker.instances import InstanceId, MlxRingInstance
from exo.shared.types.worker.runners import (
RunnerId,
RunnerIdle,
RunnerReady,
RunnerWarmingUp,
ShardAssignments,
)
from exo.shared.types.worker.shards import PipelineShardMetadata


def _make_pipeline_shard(model_id: ModelId, device_rank: int, world_size: int) -> PipelineShardMetadata:
return PipelineShardMetadata(
model_card=ModelCard(
model_id=model_id,
storage_size=Memory.from_mb(100000),
n_layers=32,
hidden_size=2048,
supports_tensor=False,
tasks=[ModelTask.TextGeneration],
),
device_rank=device_rank,
world_size=world_size,
start_layer=0,
end_layer=32,
n_layers=32,
)


def test_apply_node_timed_out_removes_affected_instances_runners_and_tasks() -> None:
node_a = NodeId("node-a")
node_b = NodeId("node-b")
node_c = NodeId("node-c")

affected_instance_id = InstanceId("affected-instance")
unaffected_instance_id = InstanceId("unaffected-instance")

affected_runner_a = RunnerId("affected-runner-a")
affected_runner_b = RunnerId("affected-runner-b")
unaffected_runner = RunnerId("unaffected-runner")

model_id = ModelId("mlx-community/gemma-4-26b-a4b-it-4bit")

affected_instance = MlxRingInstance(
instance_id=affected_instance_id,
shard_assignments=ShardAssignments(
model_id=model_id,
node_to_runner={
node_a: affected_runner_a,
node_b: affected_runner_b,
},
runner_to_shard={
affected_runner_a: _make_pipeline_shard(model_id, device_rank=0, world_size=2),
affected_runner_b: _make_pipeline_shard(model_id, device_rank=1, world_size=2),
},
),
hosts_by_node={},
ephemeral_port=50000,
)

unaffected_instance = MlxRingInstance(
instance_id=unaffected_instance_id,
shard_assignments=ShardAssignments(
model_id=model_id,
node_to_runner={node_c: unaffected_runner},
runner_to_shard={
unaffected_runner: _make_pipeline_shard(model_id, device_rank=0, world_size=1),
},
),
hosts_by_node={},
ephemeral_port=50001,
)

affected_task_id = TaskId("affected-task")
unaffected_task_id = TaskId("unaffected-task")
state = State(
instances={
affected_instance_id: affected_instance,
unaffected_instance_id: unaffected_instance,
},
runners={
affected_runner_a: RunnerWarmingUp(),
affected_runner_b: RunnerReady(),
unaffected_runner: RunnerIdle(),
},
tasks={
affected_task_id: StartWarmup(
task_id=affected_task_id,
instance_id=affected_instance_id,
task_status=TaskStatus.Pending,
),
unaffected_task_id: StartWarmup(
task_id=unaffected_task_id,
instance_id=unaffected_instance_id,
task_status=TaskStatus.Pending,
),
},
last_seen={
node_a: datetime.now(),
node_b: datetime.now(),
node_c: datetime.now(),
},
node_identities={
node_a: NodeIdentity(friendly_name="kite1"),
node_b: NodeIdentity(friendly_name="kite2"),
node_c: NodeIdentity(friendly_name="kite3"),
},
)

new_state = apply_node_timed_out(NodeTimedOut(node_id=node_a), state)

assert affected_instance_id not in new_state.instances
assert unaffected_instance_id in new_state.instances

assert affected_runner_a not in new_state.runners
assert affected_runner_b not in new_state.runners
assert unaffected_runner in new_state.runners

assert affected_task_id not in new_state.tasks
assert unaffected_task_id in new_state.tasks

assert node_a not in new_state.last_seen
assert node_b in new_state.last_seen
assert node_c in new_state.last_seen

assert node_a not in new_state.node_identities
assert node_b in new_state.node_identities
assert node_c in new_state.node_identities
Loading
Loading