Compare commits

...

30 Commits

Author SHA1 Message Date
Ryuichi Leo Takashige 9b03b561d3 Tighten up timings a bit more 2026-03-27 23:46:00 +00:00
Ryuichi Leo Takashige e9d911ccfc Add both stats 2026-03-27 21:23:37 +00:00
Ryuichi Leo Takashige 5bf5645b20 Send at the same time to improve exo bench concurrency accuracy 2026-03-27 21:15:37 +00:00
Evan Quiney 1e51dc89b0 chore: bump exo-version with release version (#1807)
our pyproject.toml version was 0.3.68 - update to .69 in line with
release!!
2026-03-27 11:47:13 +00:00
Alex Cheema 5327bdde84 Fix custom model add requiring two attempts + enlarge sidebar buttons (#1805)
## Motivation

Adding a custom model from the Hub tab shows "Added" toast but the model
doesn't appear in the All tab. You have to add it a second time for it
to work. Also, the "All" button in the model picker sidebar is too small
to read comfortably.

## Changes

**Race condition fix (`src/exo/api/main.py`):**
- Call `add_to_card_cache(card)` directly in `add_custom_model()` after
sending the `ForwarderCommand`, before the API response returns

**Sidebar sizing
(`dashboard/src/lib/components/FamilySidebar.svelte`):**
- Increased sidebar min-width from 72/64px to 80/72px
- Increased "All" icon from `w-5 h-5` to `w-6 h-6`
- Increased all sidebar labels from 9px to 11px

## Why It Works

`POST /models/add` sends a `ForwarderCommand(AddCustomModelCard)` and
returns immediately. The frontend then calls `GET /models` which reads
from `_card_cache`. But the cache was only updated by the worker event
handler after the event round-trips through the master — a race the
frontend almost always loses. By updating the cache directly in the API
handler, `GET /models` immediately reflects the new model. The worker's
later `add_to_card_cache` call is idempotent (dict key assignment).

## Test Plan

### Manual Testing
<!-- Hardware: any Mac -->
- Open model picker → Hub tab → add a custom model → verify it appears
in All tab on the first attempt
- Verify sidebar "All" button and other labels are visually larger and
readable

### Automated Testing
- `uv run basedpyright` passes with 0 errors
- `uv run ruff check` passes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:57:00 -07:00
ciaranbor 15f1b61f4c Rework model storage directory management (for external storage) (#1765)
## Motivation

Replace confusing EXO_MODELS_DIR/EXO_MODELS_PATH with clearer
multi-directory support, enabling automatic download spillover across
volumes.

## Changes

- EXO_MODELS_DIRS: colon-separated writable dirs (default always
prepended, first with enough space wins)
- EXO_MODELS_READ_ONLY_DIRS: colon-separated read-only dirs (protected
from deletion)
- select_download_dir(): picks writable dir by free space
- resolve_existing_model(): unified lookup across all dirs
- is_read_only_model_dir(): path-based read-only detection instead of
hardcoded flag
- Updated coordinator, worker, model cards, tests

## Why It Works

Default dir always included so zero-config behavior is unchanged. Disk
space checked at download time for automatic spillover. Read-only status
derived from path, not hardcoded.

## Test Plan

### Manual Testing

- No env vars set → identical behavior
- EXO_MODELS_DIRS=/Volumes/SSD/models → downloads to external storage
- EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs → models found, deletion blocked

### Automated Testing

- 4 new tests in test_xdg_paths.py (prepend, default-only, overlap,
empty read-only)
- Existing tests updated to patch new constants
2026-03-26 17:46:46 +00:00
Michael Harrigan 9034300163 [Fix] Node hang on reelection (#1801)
## Motivation

During master reelection, `_elect_loop` called `worker.shutdown()` (fire
& forget) then immediately created and started a new Worker.

This caused the old runner subprocess's Metal/GPU teardown to race with
the new worker's startup, resulting in `IOConnectUnmapMemory failed:
kr=0xe00002bc` errors and a full node hang requiring `^C`. Same issue
existed for `DownloadCoordinator`.

## Changes

- Added `anyio.Event`-based `_stopped` signal to `Worker` and
`DownloadCoordinator`, set at the end of their `run()` finally blocks
- Added `wait_stopped()` async method to both classes
- Updated `_elect_loop` to `await wait_stopped()` after calling
`shutdown()` on the old Worker and DownloadCoordinator before creating
replacements

## Why It Works

The old Worker's task group contains the RunnerSupervisor tasks, whose
finally blocks join the runner subprocess (with 5s timeout + SIGTERM +
SIGKILL escalation). By awaiting `wait_stopped()`, we guarantee the old
runner process has fully exited — including GPU memory cleanup — before
a new Worker can start and potentially access the GPU. This eliminates
the race without changing the shutdown mechanics themselves.

## Test Plan

### Manual Testing
Hardware: M4 Pro Mac Mini 24GB + M3 Ultra Mac Studio 96GB, connected via
Thunderbolt

**Repro steps:**
1. Start exo on two nodes with a model sharded across both (e.g.
`Josiefied-Qwen3-14B-abliterated-v3-4bit`)
2. Wait for "runner ready" on both
3. `kill -9` the master node
4. Observe the surviving node's re-election behavior

**Before fix (original crash):**
```
[ 11:02:39.0896AM ] Runner supervisor shutting down
[ 11:02:39.0905AM ] bye from the runner
[ 11:02:39.1052AM ] Stopping Worker
IOConnectUnmapMemory failed: kr=0xe00002bc
IOConnectUnmapMemory failed: kr=0xe00002bc
IOConnectUnmapMemory failed: kr=0xe00002bc
IOConnectUnmapMemory failed: kr=0xe00002bc
^C[ 11:03:45 ] ← hung for over a minute, required manual kill
```

**After fix (clean re-election):**
```
[ 12:15:22.4703PM ] runner loaded
[ 12:15:24.1672PM ] runner ready
[ 12:15:33.5393PM ] Waiting for other campaign to finish
[ 12:15:36.5409PM ] Node elected Master
[ 12:15:36.5413PM ] Unpausing API
```
No `IOConnectUnmapMemory` errors, no hang, no `^C` needed.

### Automated Testing
- No existing tests cover the `_elect_loop` re-election path; this is an
integration-level flow requiring a live router/election/worker stack
- All existing tests pass (307/308, 1 pre-existing Rust binding failure)
- basedpyright: 0 errors, ruff: all checks passed

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-03-26 17:28:47 +00:00
rltakashige 1d1dfaa1f3 Don't download original/ and metal/ folders from HF (#1800)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-03-26 13:36:07 +00:00
Evan Quiney 7625213df0 fix: enable macmon if preflight fails (#1799)
missed in #1747, issue #1798.

### the issue

we didn't set the memory poll rate after failling the macmon preflight,
only after failing the followups - as we never ran macmon if preflight
failed, we never hit the followup errors etc.

### testing

requires testing on an m5 pro, but the core issue is solved.
2026-03-26 11:35:22 +00:00
Alex Cheema f318f9ea14 Fix macOS build bundling wrong macmon binary (#1797)
## Motivation

PR #1747 fixed macmon support for M5 Pro/Max by pinning the
`swiftraccoon/macmon` fork in `flake.nix`. This works when running from
source (via Nix) but the distributed macOS `.app` build was still broken
on M5 Pro/Max because it was bundling the wrong macmon.

The error on M5 Pro/Max:
```
macmon preflight failed with return code -6: thread 'main' panicked at src/sources.rs:394:41
```

## Changes

- Removed `macmon` from `brew install` in `build-app.yml` — this was
installing the upstream `vladkens/macmon` which doesn't support M5
Pro/Max
- Added a new step that resolves the pinned macmon fork from the Nix dev
shell (same `swiftraccoon/macmon` at rev `9154d23` already defined in
`flake.nix`) and adds it to `$GITHUB_PATH`
- Added a safety `brew uninstall macmon` to ensure no Homebrew macmon
can shadow the pinned version

## Why It Works

PyInstaller bundles macmon via `shutil.which("macmon")`. Previously this
found the Homebrew (upstream) binary. Now it finds the Nix-overlayed
fork that has M5 Pro/Max support, because `$GITHUB_PATH` prepends the
Nix store path before the PyInstaller step runs.

## Test Plan

### Manual Testing
<!-- Hardware: M5 Pro -->
- Trigger a macOS build and verify the bundled macmon is the pinned fork
- Run the built `.app` on M5 Pro/Max and confirm macmon preflight
succeeds

### Automated Testing
- Existing CI build workflow will validate that the macmon binary is
found and bundled correctly

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:47:48 +00:00
ciaranbor 30fd5aa1cc Prefer higher % downloaded nodes for API placement previews (#1795)
Follow up to https://github.com/exo-explore/exo/pull/1767

Same thing for placement previews through API
2026-03-25 17:26:31 +00:00
ciaranbor 6de14cfedb Support image generation cancellation (#1774)
## Motivation

Support cancelling image generation, similar to existing support for
cancelling text generation

## Changes

- Dashboard (app.svelte.ts): Wire up AbortController for both
generateImage and editImage API calls. On abort, show "Cancelled"
instead of an error. Clean up the controller in finally.
- Pipeline runner (pipeline/runner.py): Introduce a cancel_checker
callback and NaN-sentinel cancellation protocol for distributed
diffusion:
  - _check_cancellation() - only rank 0 polls the cancel callback
- _send() - replaces data with NaN sentinels when cancelling, so
downstream ranks detect cancellation via _recv_and_check()
  - _recv() / _recv_like() wrappers that eval and check for NaN sentinel
  - After cancellation, drains any pending ring recv to prevent deadlock
  - Skips partial image yields and final decode when cancelled
- Image runner (runner/image_models/runner.py): Deduplicate the
ImageGeneration and ImageEdits match arms into a shared
_run_image_task() method. Thread a cancel_checker closure (backed by the
existing cancel_receiver + cancelled_tasks set) into generate_image().
- Plumbing (distributed_model.py, generate.py): Pass cancel_checker
through the call chain.

## Why It Works

- Rank 0 is the only node that knows about task-level cancellation. When
it detects cancellation, it sends NaN tensors instead of real data.
Higher-order ranks detect the NaN sentinel on recv, set their own
_cancelling flag, and propagate NaN forward
- A drain step after the loop prevents the deadlock case where the last
rank already sent patches that the first would never consume.
- For single-node mode, the loop simply breaks immediately on
cancellation.

## Test Plan

### Automated Testing

New tests in src/exo/worker/tests/unittests/test_image
2026-03-25 16:56:04 +00:00
vskiwi fc1ae90111 fix: DeepSeek V3.2 warmup crash and tool calling + add catalog cards (#1769)
## Summary

DeepSeek V3.2 (`DeepseekV32ForCausalLM`) is already supported by exo's
inference engine (architecture whitelisted in `model_cards.py`, DSML
encoding added in #1548), but **doesn't work out of the box** due to two
bugs:

### Bug 1: `warmup_inference` passes empty model ID

`warmup_inference()` in `generate.py` accepts `model_id: ModelId` as a
parameter but creates `TextGenerationTaskParams(model=ModelId(""), ...)`
instead of using it. Since `_needs_dsml_encoding()` checks
`"deepseek-v3.2" in task_params.model.lower()`, the empty string never
matches → falls back to `tokenizer.apply_chat_template()` →
**ValueError** because V3.2 has no Jinja chat template.

**Fix:** `model=ModelId("")` → `model=model_id` (one line).

### Bug 2: `_needs_dsml_encoding` limited to tool calling

`_needs_dsml_encoding()` returns `True` only when `task_params.tools` is
present or tool messages exist in `chat_template_messages`. For warmup
and regular chat requests without tools → `return False` → Jinja
fallback → **ValueError**.

Unlike V3.1 (which has a `.jinja` chat template file that transformers
picks up automatically), V3.2 **has no Jinja template at all** — it uses
Python-based DSML encoding for all message types.

**Fix:** For V3.2, always return `True` — DSML encoding handles all
message types.

### Catalog cards

Added inference model cards for:
- `mlx-community/DeepSeek-V3.2-8bit`
- `mlx-community/DeepSeek-V3.2-4bit`

Parameters taken from model `config.json` on HuggingFace, storage sizes
from HF API. Capabilities include `thinking_toggle` (related: #1456).

## Notes

- The model ID string matching approach (`"deepseek-v3.2" in
model.lower()`) is acknowledged tech debt — see #1371 for the planned
architecture-based approach.

## Test plan

- [x] Start exo with DeepSeek V3.2 model → warmup should complete
without crash
- [x] Send a regular chat message (no tools) → should get a response
- [x] Send a chat message with tools → should work as before
- [x] V3.2 cards should appear in the dashboard model catalog

---------

Co-authored-by: user <user@m1.note>
Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>
Co-authored-by: Evan <evanev7@gmail.com>
2026-03-25 16:20:35 +00:00
rltakashige 565ed41c13 Fix occasional warmup bugs by using mlx_generate (#1794)
## Motivation

Warmup occasionally had issues; e.g. #1748 and #1793 because we were
using a standard stream_generate, all of which are issues that are
resolved in the wrapper function mlx_generate.
2026-03-25 15:53:54 +00:00
DeepZima 2da740c387 Feat/static peer discovery (#1690)
**Enabling peers to be discovered in environments where mDNS is
unavailable (SSH sessions, headless servers, Docker).**

## Motivation
Exo discovers peers exclusively via mDNS, which works great on a local
network but breaks once you move beyond a single L2 broadcast domain:

- SSH sessions on macOS — TCC blocks mDNS multicast from non-GUI
sessions (#1488)
- Headless servers/rack machines — #1682 ("DGX Spark does not find other
nodes")
- Docker Compose — mDNS is often unavailable across container networks;
e.g. #1462 (E2E test framework) needs an alternative

Related works: 
#1488 (working implementation made by @AlexCheema and closed because SSH
had a GUI workaround),
#1023 (Headscale WAN then closed due to merge conflicts), 
#1656 (discovery cleanup, open). 

This PR introduces an optional bootstrap mechanism for peer discovery
while leaving the existing mDNS behavior unchanged.

## Changes
Adds two new CLI flags:

- `--bootstrap-peers` (env: `EXO_BOOTSTRAP_PEERS`) — comma-separated
libp2p multiaddrs to dial on startup and retry periodically
- `--libp2p-port` — fixed TCP port for libp2p to listen on (default:
OS-assigned). Required when bootstrap peers, so other nodes know which
port to dial.

8 files: 
- `rust/networking/src/discovery.rs`: Store bootstrap addrs, dial in
existing retry loop
- `rust/networking/src/swarm.rs`: Thread `bootstrap_peers` parameter to
`Behaviour`
- `rust/networking/examples/chatroom.rs`: Updated call site for new
create_swarm signature
- `rust/networking/tests/bootstrap_peers.rs`: Integration tests
- `rust/exo_pyo3_bindings/src/networking.rs`: Accept optional
`bootstrap_peers` in PyO3 constructor
- `rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi` : Update type stub 
- `src/exo/routing/router.py`: Pass peers to `NetworkingHandle` 
- `src/exo/main.py` : `--bootstrap-peers` CLI arg +
`EXO_BOOTSTRAP_PEERS` env var

## Why It Works

Bootstrap peers are dialed in the existing retry loop — the same path
taken by peers when mDNS-discovered. The swarm handles connection, Noise
handshake, and gossipsub mesh joining from there.

PeerId is intentionally not required in the multiaddr, the Noise
handshake discovers it.

Docker Compose example:

```yaml
services:
  exo-1:
    environment:
      EXO_BOOTSTRAP_PEERS: "/ip4/exo-2/tcp/30000"
  exo-2:
    environment:
      EXO_BOOTSTRAP_PEERS: "/ip4/exo-1/tcp/30000"
```

## Test Plan

### Manual Testing
<details>
<summary>Docker Compose config</summary>

```
services:
  exo-node1:
    build:
      context: .
      dockerfile: Dockerfile.bootstrap-test
    container_name: exo-bootstrap-node1
    hostname: exo-node1
    command: ["-q", "--libp2p-port", "30000", "--bootstrap-peers", "/ip4/172.30.20.3/tcp/30000"]
    environment:
      - EXO_LIBP2P_NAMESPACE=bootstrap-test
    ports:
      - "52415:52415"
    networks:
      bootstrap-net:
        ipv4_address: 172.30.20.2
    deploy:
      resources:
        limits:
          memory: 4g

  exo-node2:
    build:
      context: .
      dockerfile: Dockerfile.bootstrap-test
    container_name: exo-bootstrap-node2
    hostname: exo-node2
    command: ["-q", "--libp2p-port", "30000", "--bootstrap-peers", "/ip4/172.30.20.2/tcp/30000"]
    environment:
      - EXO_LIBP2P_NAMESPACE=bootstrap-test
    ports:
      - "52416:52415"
    networks:
      bootstrap-net:
        ipv4_address: 172.30.20.3
    deploy:
      resources:
        limits:
          memory: 4g

networks:
  bootstrap-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.30.20.0/24
```
</details> 

Two containers on a bridge network (`172.30.20.0/24`), fixed IPs,
`--libp2p-port 30000`, cross-referencing `--bootstrap-peers`.

Both nodes found each other and established a connection then ran the
election protocol.

### Automated Testing

4 Rust integration tests in `rust/networking/tests/bootstrap_peers.rs`
(`cargo test -p networking`):

| Test | What it verifies | Result |
|------|-----------------|--------|
| `two_nodes_connect_via_bootstrap_peers` | Node B discovers Node A via
bootstrap addr (real TCP connection) | PASS |
| `create_swarm_with_empty_bootstrap_peers` | Backward compatibility —
no bootstrap peers works | PASS |
| `create_swarm_ignores_invalid_bootstrap_addrs` | Invalid multiaddrs
silently filtered | PASS |
| `create_swarm_with_fixed_port` | `listen_port` parameter works | PASS
|

All 4 pass. The connection test takes ~6s

---------

Signed-off-by: DeepZima <deepzima@outlook.com>
Co-authored-by: Evan <evanev7@gmail.com>
2026-03-25 10:55:12 +00:00
rltakashige 7117d748ec Update dependencies including mlx 0.31.2 (#1789)
Update mlx fork to 0.31.2 and mflux to 0.17.2
2026-03-25 06:03:19 +00:00
Alex Cheema 178c617bbb Rename Nemotron to NVIDIA in model picker with logo (#1790)
## Motivation

The Nemotron model family in the model picker sidebar was displaying as
"Nemotron" with a generic checkmark icon. Since these models are
NVIDIA's Nemotron models, the category should be branded as "NVIDIA"
with the official NVIDIA logo, consistent with how other families are
branded (e.g., "llama" → "Meta", "gpt-oss" → "OpenAI").

## Changes

- **FamilySidebar.svelte**: Added `nemotron: "NVIDIA"` to the
`familyNames` mapping so the sidebar displays "NVIDIA" instead of
"Nemotron"
- **FamilyLogos.svelte**: Added the NVIDIA "eye" logo as an inline SVG
for the `nemotron` family, matching the `viewBox="0 0 24 24"` /
`fill="currentColor"` pattern used by all other brand logos
- **ModelPickerModal.svelte**: Added `"nemotron"` to the `familyOrder`
array so NVIDIA appears in a consistent position in the sidebar

## Why It Works

The model picker derives categories from the `family` field in TOML
model cards. Nemotron models already have `family = "nemotron"`, but the
three UI components (display name, logo, sort order) lacked explicit
entries for it, causing fallback behavior (auto-capitalized name,
checkmark icon, alphabetical sorting). Adding explicit entries for all
three aligns NVIDIA with the existing brand pattern.

## Test Plan

### Manual Testing
<!-- Hardware: N/A - dashboard UI change only -->
- Built dashboard successfully (`npm run build`)
- Verified the NVIDIA logo renders in the sidebar alongside existing
brand logos

### Automated Testing
- No test changes needed — this is a purely cosmetic dashboard change

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:56:59 +00:00
rltakashige 7277c90389 Fix enable thinking (#1786)
## Motivation

Minor regression from #1746 

## Why It Works

Pass thinking properly

## Test Plan

### Manual Testing
Works, it thinks
2026-03-24 16:26:25 -07:00
Evan Quiney 7ee88c1f05 override macmon in flake (#1747)
updates macmon to an upstream fork that fixes m5 max issues.
might see if the upstream version gets merged before we release.

---------

Co-authored-by: Alex Cheema <alexcheema123@gmail.com>
2026-03-24 17:30:19 +00:00
Evan Quiney 509533d49e send error finish reason on failing to parse a tool call (#1785)
a simplification of #1757 which is now stale
2026-03-24 17:21:39 +00:00
rltakashige b6240a97e8 Prevent Qwen3.5 looping by using mlx lm fork (#1784)
## Motivation

Move back to an MLX LM to improve the Qwen 3.5 experience. 

## Test Plan

### Manual Testing
Seems to loop less from testing, no speed regressions.
2026-03-24 17:16:41 +00:00
rltakashige 6cdfbb7e8b Add HF_ENDPOINT in the app settings (#1783)
## Motivation
Some users (primarily in China) are unable to access HuggingFace.co. HF
supports the HF_ENDPOINT env variable, and we also support it, but there
is no way to easily do that from the app currently.

## Test Plan

### Manual Testing

hf-mirror works
empty field works
google.com endpoint fails
2026-03-24 17:05:49 +00:00
Evan Quiney fac6832e5f fix warmup consistency for slow machines (#1748)
a fix from pr #1643 which is now stale - should make prefill more
consistent on very slow machines

## testing
qwen-3.5-35b-a3b loads normally
gpt-oss-120b-mxfp4-q8 loads normally
2026-03-24 16:45:55 +00:00
rltakashige 7df3774ca2 Improve batch performance and stats reporting (#1777)
## Motivation

Batch generation reports incorrect statistics, as mlx lm never clears
the original stats, meaning they get polluted over time.
The dashboard also seems considerably slower than bench statistics.
We also have a large discrepancy between B=1 batch generating and
mlx_generate.
Extracting logprobs is massively expensive, causing up to a 25% slowdown
compared to pure batching.
```
[ 12:02:01.1240AM | INFO    ] step overhead: 3.49ms (next=12.49ms total=15.99ms)
[ 12:02:02.1600AM | INFO    ] step overhead: 3.23ms (next=13.01ms total=16.24ms)
[ 12:02:03.2228AM | INFO    ] step overhead: 3.28ms (next=13.38ms total=16.66ms)
[ 12:02:04.2798AM | INFO    ] step overhead: 3.25ms (next=12.84ms total=16.10ms)
[ 12:02:05.3152AM | INFO    ] step overhead: 3.18ms (next=12.61ms total=15.79ms)
[ 12:02:06.3522AM | INFO    ] step overhead: 3.41ms (next=12.83ms total=16.25ms)
[ 12:02:07.3987AM | INFO    ] step overhead: 3.38ms (next=13.14ms total=16.52ms)
[ 12:02:08.4537AM | INFO    ] step overhead: 1.84ms (next=19.44ms total=21.28ms)
```

## Changes

1. Report stats ourselves instead of using mlx lm's stats for batch
generation (they use perf_counter anyway).
2. Adjust exo bench to match
3. Improve logprobs extraction speed by 10x, improving tps for dashboard
& any requests for logprobs
4. Use an SSE comment to align the speed to the real numbers at the end
of generation
5. Patch mlx for several optimizations given our assumptions and use
cases (e.g. use vllm style RoPE).
6. Switch MLX LM version to latest main, including support for Nemotron
Super and some Qwen3.5 fixes.

## Why It Works
1. Exo bench no longer reports polluted stats
2. Exo bench now handles the reported per-request stats rather than the
aggregate stats
3. The decode speed now jumps back to a real number at the end of the
generation
4. Large batch speedup for rotating KV cache models + 1:1 matching cache
with vllm

## Test Plan

### Manual Testing
Needs testing on OpenCode and CC
Needs eval testing

### Automated Testing
Only going to show the performance optimization difference after the
accurate reporting:

**GPT OSS 20B MXFP4 Q8 (large change)**
Before:
<img width="2466" height="1534" alt="image"
src="https://github.com/user-attachments/assets/88b50637-fca2-4db4-9413-b9eee6e2057e"
/>
<img width="2410" height="1240" alt="image"
src="https://github.com/user-attachments/assets/21e5c76a-2f5f-44d2-8953-121b3ebdbd68"
/>


After:
<img width="2476" height="1472" alt="image"
src="https://github.com/user-attachments/assets/fec5cfbd-fff8-430a-b12e-a329410107a2"
/>
<img width="2454" height="1236" alt="image"
src="https://github.com/user-attachments/assets/0400344b-a4a6-42c0-a9dd-4ee91ade714a"
/>



**Qwen 3.5 35B A3B 8bit (No change)**
Before:
<img width="2414" height="1396" alt="image"
src="https://github.com/user-attachments/assets/e75f0b38-df5d-49fd-ab90-bc1667d981b3"
/>


After:
<img width="2346" height="1234" alt="image"
src="https://github.com/user-attachments/assets/eabfb59c-851f-4d88-b927-e1e699a75cc6"
/>


**Llama 3.2 1B Instruct 4bit (small change)**
Before:
<img width="2516" height="1220" alt="image"
src="https://github.com/user-attachments/assets/c2873655-acff-4536-8263-fb8aea33db80"
/>

After:
<img width="2566" height="1370" alt="image"
src="https://github.com/user-attachments/assets/15f95c75-1c2f-4474-85a2-88c4d0a32543"
/>
2026-03-24 14:03:03 +00:00
ciaranbor 248919c2a8 Fix first start in offline mode crash (#1782)
## Motivation

Running exo in offline mode on a machine where a model has never been
downloaded causes a crash

## Changes

- `download_shard` now catches `FileNotFoundError` from
`fetch_file_list_with_cache` and returns a `not_started` progress
instead of propagating the exception

## Why It Works

A status query should never crash its caller. By returning
`not_started`, the coordinator's existing offline guard (`if
self.offline:` at line 198) is reached and emits a graceful
`DownloadFailed` event. This also eliminates the warning spam on startup
where the status iterator catches the same exception for every
predefined model.

## Test Plan

### Manual Testing
- Start exo with `--offline` on a machine with no cached models, request
a model via the API — should get a graceful failure instead of a crash
2026-03-24 13:58:23 +00:00
ciaranbor 49951e1b1a Sync custom model cards across nodes (#1768)
## Motivation

Custom model cards were only saved locally on the node that handled the
API request.

## Changes

- Added AddCustomModelCard and DeleteCustomModelCard commands
- Added CustomModelCardAdded and CustomModelCardDeleted events
- Added custom_model_cards field to cluster State
- Master handles new commands by emitting corresponding events
- Workers persist model cards to disk and update the in-memory cache on
event receipt
- Separated fetch_from_hf (pure fetch) from disk persistence (now
handled by event-sourcing layer)
- Exposed add_to_card_cache() helper for the worker to update the cache

## Why It Works

- Follows the existing event-sourcing pattern: API → Command → Master →
Event → all Workers
- Every node applies the same events, so custom model cards are
consistent across the cluster

## Test Plan

### Manual Testing

Add/delete a custom model via the API on one node, verify it
appears/disappears on all nodes

### Automated Testing

Existing tests cover apply() logic; new event types follow the same
discriminated-union pattern
2026-03-24 12:51:36 +00:00
ciaranbor e06e70a835 Prefer higher model download % for placement (#1767)
## Motivation

When placing a model instance across the cluster, the master previously
only considered available RAM. This meant it could pick a node that
hasn't downloaded the model yet, even when another node already has it
(or is further along in downloading it).

## Changes

- Added download_status parameter to place_instance() in placement.py
- Added _get_node_download_fraction() to compute 0.0–1.0 download
progress per node/model
- Added _cycle_download_score() to sum download fractions across a
cycle's nodes
- Cycle selection now uses a (download_score, available_ram) tuple key —
download progress is the primary sort, RAM is the tiebreaker
- Passed self.state.downloads into place_instance() from master/main.py

## Why It Works

Python's tuple comparison gives download progress strict priority over
RAM, so a node with the model already downloaded will always be
preferred over one with more free RAM but no download.

## Test Plan

### Automated Testing

3 new tests cover: completed download preferred, higher partial progress
preferred, failed download not preferred over no-download node
2026-03-24 12:11:56 +00:00
vskiwi e9fdd8d4af improve logging: add dates to verbose stderr, match file log level to verbosity (#1772)
## Summary

- **Add ISO date to verbose stderr format**: when running with `-v`, the
stderr timestamp changes from `HH:mm:ss.SSS` to `YYYY-MM-DD
HH:mm:ss.SSS`, matching the file log format. This makes it possible to
correlate entries across days when stderr is captured by launchd,
systemd, Docker, or file redirection.
- **File log respects verbosity**: `exo.log` now uses DEBUG level when
`-v` is passed, so the persistent log has the same detail as stderr.
Previously it was always INFO regardless of verbosity.
- **Startup banner with PID**: adds a visual separator and process ID to
the startup message, making it easy to identify session boundaries in
long-running logs (e.g. `grep "Starting EXO"`).

The non-verbose (default) stderr format is unchanged — end-user terminal
experience is not affected.

## Motivation

When exo runs as a service (launchd, systemd, Docker), stderr is
typically captured to a file. Without calendar dates in the timestamp,
it is impossible to tell which day a log entry belongs to. This caused
misidentification of log entries during a multi-day RDMA debugging
session on a 4-node cluster.

The file log (`exo.log`) already had dates and rotation, but only
captured INFO level — missing the DEBUG output needed for postmortem
analysis.

## Test plan

- [x] `basedpyright` — 0 errors, 0 warnings, 0 notes
- [x] `ruff check` — all checks passed
- [x] `pytest` — 249 passed, 1 skipped

Co-authored-by: user <user@m1.note>
2026-03-23 16:06:26 +00:00
Evan Quiney 07598a3af1 teeny refactor (#1753)
api.py keeps growing. it's not tied to the master, so should have it's
own top level folder.

## testing
ci, pytest
2026-03-19 15:57:50 +00:00
ciaranbor 63f57fc193 Ciaran/dashboard download bug (#1755)
## Motivation

Download progress in the dashboard was broken: mainly treating all
download statuses as ongoing

## Changes

- Backend (apply.py): Deduplicate download progress events by model_id
instead of full shard_metadata, preventing duplicate entries per node
- Dashboard (+page.svelte): Extract shared collectDownloadStatus()
helper that both getModelDownloadStatus and getInstanceDownloadStatus
use, eliminating ~100 lines of duplicated logic. Adds proper handling
for
DownloadCompleted/DownloadFailed events, uses a Map to deduplicate
per-node entries, and introduces a typed NodeDownloadStatus with
explicit status states (downloading/completed/partial/pending)
- ModelCard: Replace single aggregate progress bar with per-node
download bars, each color-coded by status. Instance preview now scopes
download status to participating nodes only

## Why It Works

- Deduplicating by model_id in apply.py ensures each node has exactly
one download entry per model
- The perNodeMap in the frontend keeps only the latest event per node,
preventing duplicate bars
- Handling DownloadCompleted allows the UI to show finished downloads
instead of dropping them
- Scoping instance previews to assigned nodes avoids showing irrelevant
download progress
2026-03-19 14:47:45 +00:00
114 changed files with 4079 additions and 1650 deletions
+9 -1
View File
@@ -159,7 +159,7 @@ jobs:
fi
- name: Install Homebrew packages
run: brew install just awscli macmon
run: brew install just awscli
- name: Install UV
uses: astral-sh/setup-uv@v6
@@ -243,6 +243,14 @@ jobs:
# Build the bundle
# ============================================================
- name: Add pinned macmon to PATH
run: |
MACMON_DIR=$(nix develop --command sh -c 'dirname $(which macmon)')
echo "Using macmon from: $MACMON_DIR"
echo "$MACMON_DIR" >> $GITHUB_PATH
# Remove any Homebrew macmon so PyInstaller can't accidentally pick it up
brew uninstall macmon 2>/dev/null || true
- name: Build PyInstaller bundle
run: uv run pyinstaller packaging/pyinstaller/exo.spec
+1 -1
View File
@@ -2396,7 +2396,7 @@ def degrees(a: array, /, *, stream: Stream | Device | None = ...) -> array:
array: The angles in degrees.
"""
def depends(inputs: array | Sequence[array], dependencies: array | Sequence[array]):
def depends[T](inputs: T, dependencies: array | Sequence[array]) -> T:
"""
Insert dependencies between arrays in the graph. The outputs are
identical to ``inputs`` but with dependencies on ``dependencies``.
+2 -6
View File
@@ -1,9 +1,5 @@
"""
This type stub file was generated by pyright.
"""
from layers import *
from utils import *
from .layers import *
from .utils import *
from . import init as init
from . import losses as losses
+16 -20
View File
@@ -1,20 +1,16 @@
"""
This type stub file was generated by pyright.
"""
from activations import *
from base import *
from containers import *
from convolution import *
from convolution_transpose import *
from distributed import *
from dropout import *
from embedding import *
from linear import *
from normalization import *
from pooling import *
from positional_encoding import *
from quantized import *
from recurrent import *
from transformer import *
from upsample import *
from .activations import *
from .base import *
from .containers import *
from .convolution import *
from .convolution_transpose import *
from .distributed import *
from .dropout import *
from .embedding import *
from .linear import *
from .normalization import *
from .pooling import *
from .positional_encoding import *
from .quantized import *
from .recurrent import *
from .transformer import *
from .upsample import *
+1 -1
View File
@@ -53,7 +53,7 @@ class Module(dict):
mx.eval(model.parameters())
"""
__call__: Callable
def __call__(self, *args: Any, **kwargs: Any) -> mx.array: ...
def __init__(self) -> None:
"""Should be called by the subclasses of ``Module``."""
+10 -5
View File
@@ -30,7 +30,7 @@ def str2bool(string): # -> bool:
def setup_arg_parser(): # -> ArgumentParser:
"""Set up and return the argument parser."""
generation_stream = ...
generation_stream: mx.Stream
@contextlib.contextmanager
def wired_limit(
@@ -266,12 +266,12 @@ def _merge_caches(caches: Any) -> List[Any]: ...
class Batch:
uids: List[int]
y: mx.array
logprobs: mx.array
logprobs: List[mx.array] | mx.array
max_tokens: List[int]
num_tokens: List[int]
cache: List[Any]
samplers: List[Any]
logits_processors: List[Any]
samplers: List[Callable[[mx.array], mx.array] | None]
logits_processors: List[List[Callable[[mx.array, mx.array], mx.array]]]
tokens: List[mx.array]
def __len__(self) -> int: ...
def filter(self, keep_idx: List[int]) -> None: ...
@@ -279,13 +279,18 @@ class Batch:
def extract_cache(self, idx: int) -> List[Any]: ...
class BatchGenerator:
model: Any
model: nn.Module
sampler: Callable[[mx.array], mx.array]
stop_tokens: set[int]
max_kv_size: Optional[int]
prefill_step_size: int
completion_batch_size: int
prefill_batch_size: int
unprocessed_prompts: List[Any]
active_batch: Optional[Batch]
prompt_progress_callback: Callable[[List[Tuple[int, int, int]]], None]
_stats: BatchStats
_next_count: int
@dataclass
class Response:
+25 -31
View File
@@ -88,8 +88,8 @@ def create_attention_mask(
) -> array | Literal["causal"] | None: ...
class _BaseCache(Cache):
keys: mx.array
values: mx.array
keys: mx.array | None
values: mx.array | None
offset: int
@property
def state(self) -> tuple[mx.array | None, mx.array | None]: ...
@@ -268,29 +268,14 @@ class CacheList(_BaseCache):
"""
class BatchKVCache(_BaseCache):
step = ...
def __init__(self, left_padding: List[int]) -> None:
"""
The BatchKV cache expects inputs to be left-padded.
E.g. the following prompts:
[1, 3, 5]
[7]
[2, 6, 8, 9]
Should be padded like so:
[0, 1, 3, 5]
[0, 0, 0, 7]
[2, 6, 8, 9]
And ``left_padding`` specifies the amount of padding for each.
In this case, ``left_padding = [1, 3, 0]``.
"""
def update_and_fetch(self, keys, values): # -> tuple[array | Any, array | Any]:
...
step: int
keys: array | None
values: array | None
offset: array
left_padding: array
_idx: int
def __init__(self, left_padding: List[int]) -> None: ...
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
@property
def state(
self,
@@ -316,12 +301,21 @@ class BatchKVCache(_BaseCache):
"""
class BatchRotatingKVCache(_BaseCache):
step = ...
def __init__(self, max_size, left_padding: List[int]) -> None: ...
def update_and_fetch(
self, keys, values
): # -> tuple[array | Any, array | Any] | tuple[array | Any, array | Any | None]:
...
step: int
keys: array | None
values: array | None
offset: array
left_padding: array
max_size: int
_idx: int
_offset: int
rotated: bool
_lengths: array | None
def __init__(self, max_size: int, left_padding: List[int]) -> None: ...
def _trim(self, trim_size: int, v: array, append: array | None = ...) -> array: ...
def _update_in_place(self, keys: array, values: array) -> tuple[array, array]: ...
def _update_concat(self, keys: array, values: array) -> tuple[array, array]: ...
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
@property
def state(
self,
@@ -0,0 +1,35 @@
from typing import Optional
import mlx.core as mx
def compute_g(A_log: mx.array, a: mx.array, dt_bias: mx.array) -> mx.array: ...
def gated_delta_update(
q: mx.array,
k: mx.array,
v: mx.array,
a: mx.array,
b: mx.array,
A_log: mx.array,
dt_bias: mx.array,
state: Optional[mx.array] = ...,
mask: Optional[mx.array] = ...,
use_kernel: bool = ...,
) -> tuple[mx.array, mx.array]: ...
def gated_delta_ops(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: Optional[mx.array] = ...,
mask: Optional[mx.array] = ...,
) -> tuple[mx.array, mx.array]: ...
def gated_delta_kernel(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: mx.array,
mask: Optional[mx.array] = ...,
) -> tuple[mx.array, mx.array]: ...
+51
View File
@@ -0,0 +1,51 @@
from typing import Any, Optional
import mlx.nn as nn
class YarnRoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
scaling_factor: float = ...,
original_max_position_embeddings: int = ...,
beta_fast: float = ...,
beta_slow: float = ...,
mscale: float = ...,
mscale_all_dim: float = ...,
) -> None: ...
class Llama3RoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
scaling_factor: float = ...,
original_max_position_embeddings: int = ...,
low_freq_factor: float = ...,
high_freq_factor: float = ...,
) -> None: ...
class SuScaledRoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
short_factor: Any = ...,
long_factor: Any = ...,
original_max_position_embeddings: int = ...,
) -> None: ...
def initialize_rope(
dims: int,
base: float = ...,
traditional: bool = ...,
scaling_config: Optional[dict[str, Any]] = ...,
max_position_embeddings: Optional[int] = ...,
) -> nn.Module: ...
+11 -2
View File
@@ -11,9 +11,18 @@ To run EXO from source:
```bash
brew install uv
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
```bash
brew install macmon
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup toolchain install nightly
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
Use the pinned fork revision used by this repo instead of Homebrew `macmon`.
```bash
cargo install --git https://github.com/swiftraccoon/macmon \
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
macmon \
--force
```
```bash
+20 -6
View File
@@ -95,11 +95,10 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
- [uv](https://github.com/astral-sh/uv) (for Python dependency management)
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
- [node](https://github.com/nodejs/node) (for building the dashboard)
```bash
brew install uv macmon node
brew install uv node
```
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
@@ -107,6 +106,17 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup toolchain install nightly
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
Install the pinned fork revision used by this repo instead of Homebrew `macmon`.
Homebrew `macmon 0.6.1` still crashes on Apple M5.
```bash
cargo install --git https://github.com/swiftraccoon/macmon \
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
macmon \
--force
```
Clone the repo, build the dashboard, and run exo:
@@ -285,8 +295,9 @@ exo supports several environment variables for configuration:
| Variable | Description | Default |
|----------|-------------|---------|
| `EXO_MODELS_PATH` | Colon-separated paths to search for pre-downloaded models (e.g., on NFS mounts or shared storage) | None |
| `EXO_MODELS_DIR` | Directory where exo downloads and stores models | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_DEFAULT_MODELS_DIR` | Default directory for model downloads and caches. Always first in the writable dirs list. | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_MODELS_DIRS` | Colon-separated additional writable directories for model downloads. Checked in order after the default; first with enough free space is used. | None |
| `EXO_MODELS_READ_ONLY_DIRS` | Colon-separated read-only directories to search for pre-downloaded models (e.g., NFS mounts, shared storage). Models here cannot be deleted. | None |
| `EXO_OFFLINE` | Run without internet connection (uses only local models) | `false` |
| `EXO_ENABLE_IMAGE_MODELS` | Enable image model support | `false` |
| `EXO_LIBP2P_NAMESPACE` | Custom namespace for cluster isolation | None |
@@ -296,8 +307,11 @@ exo supports several environment variables for configuration:
**Example usage:**
```bash
# Use pre-downloaded models from NFS mount
EXO_MODELS_PATH=/mnt/nfs/models:/opt/ai-models uv run exo
# Use pre-downloaded models from NFS mount (read-only)
EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs/models:/opt/ai-models uv run exo
# Download models to an external SSD (falls back to default dir if full)
EXO_MODELS_DIRS=/Volumes/ExternalSSD/exo-models uv run exo
# Run in offline mode
EXO_OFFLINE=true uv run exo
+12
View File
@@ -4,6 +4,7 @@ import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
private let hfTokenKey = "EXOHFToken"
private let hfEndpointKey = "EXOHFEndpoint"
private let enableImageModelsKey = "EXOEnableImageModels"
private let offlineModeKey = "EXOOfflineMode"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
@@ -53,6 +54,14 @@ final class ExoProcessController: ObservableObject {
UserDefaults.standard.set(hfToken, forKey: hfTokenKey)
}
}
@Published var hfEndpoint: String = {
return UserDefaults.standard.string(forKey: hfEndpointKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(hfEndpoint, forKey: hfEndpointKey)
}
}
@Published var enableImageModels: Bool = {
return UserDefaults.standard.bool(forKey: enableImageModelsKey)
}()
@@ -273,6 +282,9 @@ final class ExoProcessController: ObservableObject {
if !hfToken.isEmpty {
environment["HF_TOKEN"] = hfToken
}
if !hfEndpoint.isEmpty {
environment["HF_ENDPOINT"] = hfEndpoint
}
if enableImageModels {
environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
}
+15
View File
@@ -12,6 +12,7 @@ struct SettingsView: View {
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@State private var pendingHFEndpoint: String = ""
@State private var pendingEnableImageModels = false
@State private var pendingOfflineMode = false
@State private var needsRestart = false
@@ -42,6 +43,7 @@ struct SettingsView: View {
.onAppear {
pendingNamespace = controller.customNamespace
pendingHFToken = controller.hfToken
pendingHFEndpoint = controller.hfEndpoint
pendingEnableImageModels = controller.enableImageModels
pendingOfflineMode = controller.offlineMode
needsRestart = false
@@ -74,6 +76,17 @@ struct SettingsView: View {
.foregroundColor(.secondary)
}
Section {
LabeledContent("HuggingFace Endpoint") {
TextField("default", text: $pendingHFEndpoint)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
Text("Defaults to huggingface.co. Use a mirror (e.g. hf-mirror.com) for China.")
.font(.caption)
.foregroundColor(.secondary)
}
Section {
Toggle("Offline Mode", isOn: $pendingOfflineMode)
Text("Skip internet checks and use only locally available models.")
@@ -454,6 +467,7 @@ struct SettingsView: View {
private var hasGeneralChanges: Bool {
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
|| pendingHFEndpoint != controller.hfEndpoint
|| pendingOfflineMode != controller.offlineMode
}
@@ -464,6 +478,7 @@ struct SettingsView: View {
private func applyGeneralSettings() {
controller.customNamespace = pendingNamespace
controller.hfToken = pendingHFToken
controller.hfEndpoint = pendingHFEndpoint
controller.offlineMode = pendingOfflineMode
restartIfRunning()
}
+39 -14
View File
@@ -22,6 +22,7 @@ import contextlib
import itertools
import json
import sys
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -443,19 +444,44 @@ def main() -> int:
all_rows.append(row)
else:
# Concurrent: fire N requests in parallel
# Each thread gets its own ExoClient (separate HTTP connection)
# Pre-build prompt once, barrier ensures simultaneous dispatch
content, actual_pp = prompt_sizer.build(pp)
pre_built_payload: dict[str, Any] = {
"model": full_model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"max_tokens": tg,
}
barrier = threading.Barrier(concurrency)
batch_start = threading.Event()
batch_t0: float = 0.0
batch_results: list[tuple[dict[str, Any], int]] = []
batch_errors = 0
def _run_concurrent(
idx: int, *, _pp: int = pp, _tg: int = tg
idx: int,
) -> tuple[dict[str, Any], int]:
nonlocal batch_t0
c = ExoClient(
args.host, args.port, timeout_s=args.timeout
)
return run_one_completion(
c, full_model_id, _pp, _tg, prompt_sizer
)
if barrier.wait() == 0:
batch_t0 = time.perf_counter()
batch_start.set()
else:
batch_start.wait()
t0 = batch_t0
out = c.post_bench_chat_completions(pre_built_payload)
elapsed = time.perf_counter() - t0
stats = out.get("generation_stats")
choices = out.get("choices") or [{}]
message = choices[0].get("message", {}) if choices else {}
text = message.get("content") or ""
return {
"elapsed_s": elapsed,
"output_text_preview": text[:200],
"stats": stats,
}, actual_pp
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = {
@@ -468,6 +494,7 @@ def main() -> int:
except Exception as e:
logger.error(f"Concurrent request failed: {e}")
batch_errors += 1
batch_wall_s = max(x["elapsed_s"] for x, _ in batch_results) if batch_results else time.perf_counter() - batch_t0
for idx, (row, actual_pp_tokens) in enumerate(
batch_results
@@ -501,23 +528,21 @@ def main() -> int:
for x, _ in batch_results
if x["stats"]["generation_tps"] > 0
]
agg_gen_tps = (
mean(valid_gen_tps) if valid_gen_tps else 0.0
)
gen_tps = agg_gen_tps / concurrency
per_req_tps = max(valid_gen_tps) if valid_gen_tps else 0.0
agg_gen_tps = per_req_tps * concurrency
logger.info(
f"[concurrent {concurrency}x] "
f"agg_gen_tps={agg_gen_tps:.2f} "
f"gen_tps={gen_tps:.2f} "
f"per_req_tps={per_req_tps:.2f} "
f"wall_s={batch_wall_s:.2f} "
f"errors={batch_errors}"
)
if runs:
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
gen_tps = mean(
x["stats"]["generation_tps"] / x["concurrency"]
for x in runs
)
valid_gen = [x["stats"]["generation_tps"] for x in runs if x["stats"]["generation_tps"] > 0]
per_req_tps = max(valid_gen) if valid_gen else 0.0
gen_tps = per_req_tps * concurrency
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
peak = mean(
+1 -1
View File
@@ -377,7 +377,7 @@ def run_planning_phase(
f"have {avail // (1024**3)}GB. Use --danger-delete-downloads to free space."
)
# Delete from smallest to largest (skip read-only models from EXO_MODELS_PATH)
# Delete from smallest to largest (skip read-only models)
completed = [
(
unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][
@@ -88,6 +88,12 @@
d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z"
/>
</svg>
{:else if family === "nemotron"}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"
/>
</svg>
{:else}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -31,6 +31,7 @@
kimi: "Kimi",
flux: "FLUX",
"qwen-image": "Qwen Img",
nemotron: "NVIDIA",
};
function getFamilyName(family: string): string {
@@ -41,31 +42,20 @@
</script>
<div
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[72px] sm:min-w-[64px] overflow-y-auto scrollbar-hide"
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[80px] sm:min-w-[72px] overflow-y-auto scrollbar-hide"
>
<!-- All models (no filter) -->
<button
type="button"
onclick={() => onSelect(null)}
class="group flex flex-col items-center justify-center p-2 sm:p-2 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
class="group flex items-center justify-center px-3 py-2.5 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
null
? 'bg-exo-yellow/20 border-l-2 border-exo-yellow'
: 'hover:bg-white/5 border-l-2 border-transparent'}"
title="All models"
>
<svg
class="w-5 h-5 {selectedFamily === null
? 'text-exo-yellow'
: 'text-white/50 group-hover:text-white/70'}"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"
/>
</svg>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === null
class="text-[12px] font-mono font-medium {selectedFamily === null
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}">All</span
>
@@ -89,7 +79,7 @@
: "text-white/50 group-hover:text-amber-400/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'favorites'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'favorites'
? 'text-amber-400'
: 'text-white/40 group-hover:text-white/60'}">Faves</span
>
@@ -114,7 +104,7 @@
: "text-white/50 group-hover:text-white/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'recents'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'recents'
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}">Recent</span
>
@@ -138,7 +128,7 @@
: "text-white/50 group-hover:text-orange-400/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'huggingface'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'huggingface'
? 'text-orange-400'
: 'text-white/40 group-hover:text-white/60'}">Hub</span
>
@@ -164,7 +154,7 @@
: "text-white/50 group-hover:text-white/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
class="text-[11px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
family
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}"
+52 -30
View File
@@ -16,7 +16,9 @@
perNode?: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
status: "completed" | "partial" | "pending" | "downloading";
percentage: number;
progress: DownloadProgress | null;
}>;
} | null;
nodes?: Record<string, NodeInfo>;
@@ -145,10 +147,7 @@
return `${s}s`;
}
const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
const progress = $derived(downloadStatus?.progress);
const percentage = $derived(progress?.percentage ?? 0);
let expandedNodes = $state<Set<string>>(new Set());
const perNode = $derived(downloadStatus?.perNode ?? []);
function toggleNodeDetails(nodeId: string): void {
const next = new Set(expandedNodes);
@@ -587,23 +586,49 @@
</span>
</div>
<!-- Download Status -->
{#if isDownloading && progress}
<!-- Download Status (per-node) -->
{#if perNode.length > 0}
<div class="mb-2 space-y-1">
<div class="flex items-center justify-between text-xs font-mono">
<span class="text-blue-400 tracking-wider uppercase">Downloading</span
>
<span class="text-white/60"
>{percentage.toFixed(1)}% &middot; {formatSpeed(progress.speed)}
&middot; {formatEta(progress.etaMs)}</span
>
</div>
<div class="h-1 bg-exo-medium-gray/30 rounded overflow-hidden">
<div
class="h-full bg-blue-500/70 transition-all duration-300"
style="width: {percentage}%"
></div>
<div
class="text-[10px] font-mono text-white/20 tracking-widest uppercase"
>
Download progress
</div>
{#each perNode as node}
<div class="flex items-center gap-2 text-xs font-mono">
<span class="text-white/40 w-20 truncate" title={node.nodeId}
>{node.nodeName}</span
>
<div
class="flex-1 h-1 bg-exo-medium-gray/30 rounded overflow-hidden"
>
<div
class="h-full transition-all duration-300 {node.status ===
'downloading'
? 'bg-blue-500/70'
: node.status === 'completed'
? 'bg-exo-yellow/40'
: 'bg-white/20'}"
style="width: {node.percentage}%"
></div>
</div>
<span
class="text-right {node.status === 'completed'
? 'text-exo-yellow/60'
: node.status === 'downloading'
? 'text-blue-400/60'
: 'text-white/30'}"
>
{#if node.status === "downloading" && node.progress}
{Math.round(node.percentage)}% {formatSpeed(
node.progress.speed,
)}
{:else}
{node.percentage > 0 ? `${Math.round(node.percentage)}%` : "0%"}
{/if}
</span>
</div>
{/each}
</div>
{/if}
@@ -662,15 +687,7 @@
{@const allConnections =
isDebugMode && usedNodes.length > 1
? (() => {
const conns: Array<{
ip: string;
iface: string | null;
from: string;
to: string;
midX: number;
midY: number;
arrow: string;
}> = [];
const conns: Array = [];
for (let i = 0; i < usedNodes.length; i++) {
for (let j = i + 1; j < usedNodes.length; j++) {
const n1 = usedNodes[i];
@@ -682,7 +699,12 @@
const toPos = nodePositions[c.to];
const arrow =
fromPos && toPos ? getArrow(fromPos, toPos) : "→";
conns.push({ ...c, midX, midY, arrow });
conns.push({
...c,
midX,
midY,
arrow,
});
}
}
}
@@ -73,7 +73,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-10"
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-20"
transition:fly={{ y: -10, duration: 200, easing: cubicOut }}
onclick={(e) => e.stopPropagation()}
role="dialog"
@@ -459,6 +459,7 @@
"llama",
"flux",
"qwen-image",
"nemotron",
];
return Array.from(families).sort((a, b) => {
const aIdx = familyOrder.indexOf(a);
+76 -19
View File
@@ -1793,6 +1793,14 @@ class AppStore {
this.persistConversation(targetConversationId);
}
},
{
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
}
},
},
);
// Final update
@@ -1990,6 +1998,14 @@ class AppStore {
this.persistConversation(targetConversationId);
}
},
{
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
}
},
},
);
// Final cleanup of the message (if conversation still exists)
@@ -2397,7 +2413,7 @@ class AppStore {
let streamedContent = "";
let streamedThinking = "";
let serverTpsReceived = false;
interface ChatCompletionChunk {
choices?: Array<{
delta?: { content?: string; reasoning_content?: string };
@@ -2462,7 +2478,6 @@ class AppStore {
tokenCount += 1;
this.totalTokens = tokenCount;
// Update real-time TPS during streaming
if (firstTokenTime !== null && tokenCount > 1) {
const elapsed = performance.now() - firstTokenTime;
this.tps = (tokenCount / elapsed) * 1000;
@@ -2513,16 +2528,24 @@ class AppStore {
startedAt: this.prefillProgress?.startedAt ?? performance.now(),
};
},
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
serverTpsReceived = true;
}
},
},
);
// Clear prefill progress after stream ends
this.prefillProgress = null;
// Calculate final TPS
if (firstTokenTime !== null && tokenCount > 1) {
// Use server-side TPS if available, otherwise fall back to client-side
if (!serverTpsReceived && firstTokenTime !== null && tokenCount > 1) {
const totalGenerationTime = performance.now() - firstTokenTime;
this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
this.tps = (tokenCount / totalGenerationTime) * 1000;
}
// Final cleanup of the message (if conversation still exists)
@@ -2627,6 +2650,9 @@ class AppStore {
this.syncActiveMessagesIfNeeded(targetConversationId);
this.saveConversationsToStorage();
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2681,6 +2707,7 @@ class AppStore {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: abortController.signal,
});
if (!response.ok) {
@@ -2820,14 +2847,27 @@ class AppStore {
);
}
} catch (error) {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
if (abortController.signal.aborted) {
this.updateConversationMessage(
targetConversationId,
assistantMessage.id,
(msg) => {
msg.content = "Cancelled";
msg.attachments = [];
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
} else {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
}
} finally {
this.currentAbortController = null;
this.isLoading = false;
this.saveConversationsToStorage();
}
@@ -2891,6 +2931,9 @@ class AppStore {
// Clear editing state
this.editingImage = null;
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2952,6 +2995,7 @@ class AppStore {
const apiResponse = await fetch("/v1/images/edits", {
method: "POST",
body: formData,
signal: abortController.signal,
});
if (!apiResponse.ok) {
@@ -3052,14 +3096,27 @@ class AppStore {
);
}
} catch (error) {
console.error("Error editing image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to edit image",
);
if (abortController.signal.aborted) {
this.updateConversationMessage(
targetConversationId,
assistantMessage.id,
(msg) => {
msg.content = "Cancelled";
msg.attachments = [];
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
} else {
console.error("Error editing image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to edit image",
);
}
} finally {
this.currentAbortController = null;
this.isLoading = false;
this.saveConversationsToStorage();
}
+198 -247
View File
@@ -42,6 +42,7 @@
setSelectedChatModel,
selectedChatModel,
sendMessage,
thinkingEnabled,
generateImage,
editImage,
editingImage,
@@ -852,7 +853,7 @@
) {
const model = selectedChatModel();
if (!model) {
sendMessage(content, files, null);
sendMessage(content, files, thinkingEnabled());
return;
}
@@ -880,7 +881,7 @@
}
// Default: text chat
sendMessage(content, files, null);
sendMessage(content, files, thinkingEnabled());
}
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
@@ -1535,34 +1536,44 @@
}
// Helper to get download status for a model (checks all downloads for matching model ID)
function getModelDownloadStatus(modelId: string): {
type NodeDownloadStatus = {
nodeId: string;
nodeName: string;
status: "completed" | "partial" | "pending" | "downloading";
percentage: number;
progress: DownloadProgress | null;
};
// Shared helper: collect per-node download status for a model across a set of nodes.
// Handles deduplication, entry parsing, and aggregation in one place.
function collectDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}>;
perNode: NodeDownloadStatus[];
failedError: string | null;
} {
const empty = {
isDownloading: false,
progress: null,
perNode: [] as NodeDownloadStatus[],
failedError: null,
};
if (!downloadsData || Object.keys(downloadsData).length === 0) {
return { isDownloading: false, progress: null, perNode: [] };
return empty;
}
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
const perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}> = [];
// Deduplicate by nodeId — a node can have multiple entries for the same model
// (e.g. PipelineShardMetadata + TensorShardMetadata). Keep the last entry,
// which is the most recently applied event.
const perNodeMap = new Map<string, NodeDownloadStatus>();
// Check all nodes for downloads matching this model
const nodeIdSet = nodeIds ? new Set(nodeIds) : null;
for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
if (nodeIdSet && !nodeIdSet.has(nodeId)) continue;
if (!Array.isArray(nodeDownloads)) continue;
for (const downloadWrapped of nodeDownloads) {
@@ -1575,29 +1586,45 @@
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending"
)
continue;
if (!downloadPayload) continue;
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (!downloadModelId || downloadModelId !== modelId) continue;
// Match if the model ID contains or equals the requested model
// (handles cases like "mlx-community/Meta-Llama..." matching)
if (
!downloadModelId ||
!downloadModelId.includes(modelId.split("/").pop() || modelId)
) {
// Try exact match or partial match
if (downloadModelId !== modelId) continue;
// DownloadFailed — return with any data collected so far
if (downloadKind === "DownloadFailed") {
return {
isDownloading: false,
progress: null,
perNode: Array.from(perNodeMap.values()),
failedError:
(downloadPayload.errorMessage as string) ||
(downloadPayload.error_message as string) ||
"Download failed",
};
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending" &&
downloadKind !== "DownloadCompleted"
)
continue;
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
if (downloadKind === "DownloadCompleted") {
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "completed",
percentage: 100,
progress: null,
});
continue;
}
// For DownloadPending with partial bytes (paused/resumed downloads),
// synthesize a progress object from the top-level downloaded/total fields
let progress: DownloadProgress | null;
if (downloadKind === "DownloadPending") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
@@ -1610,44 +1637,67 @@
downloadPayload.totalBytes,
);
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
isDownloading = true;
progress = {
totalBytes: pendingTotal,
downloadedBytes: pendingDownloaded,
speed: 0,
etaMs: 0,
percentage:
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
completedFiles: 0,
totalFiles: 0,
files: [],
};
} else {
isDownloading = true;
progress = parseDownloadProgress(downloadPayload);
const pct =
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0;
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: pendingDownloaded > 0 ? "partial" : "pending",
percentage: pct,
progress: null,
});
continue;
}
if (progress) {
// Sum all values across nodes - each node downloads independently
totalBytes += progress.totalBytes;
downloadedBytes += progress.downloadedBytes;
totalSpeed += progress.speed;
completedFiles += progress.completedFiles;
totalFiles += progress.totalFiles;
allFiles.push(...progress.files);
// DownloadOngoing
const progress = parseDownloadProgress(downloadPayload);
if (
!progress ||
(progress.downloadedBytes <= 0 && progress.totalBytes <= 0)
)
continue;
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
perNode.push({ nodeId, nodeName, progress });
}
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "downloading",
percentage: progress.percentage,
progress,
});
}
}
// Aggregate from deduplicated per-node entries
const perNode = Array.from(perNodeMap.values());
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
for (const node of perNode) {
if (node.status === "downloading" && node.progress) {
isDownloading = true;
totalBytes += node.progress.totalBytes;
downloadedBytes += node.progress.downloadedBytes;
totalSpeed += node.progress.speed;
completedFiles += node.progress.completedFiles;
totalFiles += node.progress.totalFiles;
allFiles.push(...node.progress.files);
}
}
if (!isDownloading) {
return { isDownloading: false, progress: null, perNode: [] };
return {
isDownloading: false,
progress: null,
perNode,
failedError: null,
};
}
// ETA = total remaining bytes / total speed across all nodes
const remainingBytes = totalBytes - downloadedBytes;
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
@@ -1664,9 +1714,21 @@
files: allFiles,
},
perNode,
failedError: null,
};
}
function getModelDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: NodeDownloadStatus[];
} {
return collectDownloadStatus(modelId, nodeIds);
}
// Helper to get download status for an instance
function getInstanceDownloadStatus(
instanceId: string,
@@ -1677,26 +1739,9 @@
errorMessage: string | null;
progress: DownloadProgress | null;
statusText: string;
perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}>;
perNode: NodeDownloadStatus[];
} {
if (!downloadsData || Object.keys(downloadsData).length === 0) {
// No download data yet — defer to runner status instead of assuming RUNNING
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: false,
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: [],
};
}
// Unwrap the instance
// Unwrap the instance to get shard assignments
const [instanceTag, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") {
return {
@@ -1716,132 +1761,9 @@
modelId?: string;
};
};
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const instanceModelId = inst.shardAssignments?.modelId;
// Build reverse mapping: runnerId -> nodeId
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
const perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}> = [];
// Check downloads for nodes that are part of this instance
for (const runnerId of Object.keys(runnerToShard)) {
const nodeId = runnerToNode[runnerId];
if (!nodeId) continue;
const nodeDownloads = downloadsData[nodeId];
if (!Array.isArray(nodeDownloads)) continue;
for (const downloadWrapped of nodeDownloads) {
if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
const keys = Object.keys(downloadWrapped as Record<string, unknown>);
if (keys.length !== 1) continue;
const downloadKind = keys[0];
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
// Handle DownloadFailed - return immediately with error info
if (downloadKind === "DownloadFailed") {
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (
instanceModelId &&
downloadModelId &&
downloadModelId === instanceModelId
) {
return {
isDownloading: false,
isFailed: true,
errorMessage:
(downloadPayload.errorMessage as string) || "Download failed",
progress: null,
statusText: "FAILED",
perNode: [],
};
}
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending"
)
continue;
if (!downloadPayload) continue;
// Check if this download is for this instance's model
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (
instanceModelId &&
downloadModelId &&
downloadModelId === instanceModelId
) {
// For DownloadPending with partial bytes, synthesize progress
let progress: DownloadProgress | null;
if (downloadKind === "DownloadPending") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
downloadPayload.downloaded_bytes ??
downloadPayload.downloadedBytes,
);
const pendingTotal = getBytes(
downloadPayload.total ??
downloadPayload.total_bytes ??
downloadPayload.totalBytes,
);
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
isDownloading = true;
progress = {
totalBytes: pendingTotal,
downloadedBytes: pendingDownloaded,
speed: 0,
etaMs: 0,
percentage:
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
completedFiles: 0,
totalFiles: 0,
files: [],
};
} else {
isDownloading = true;
progress = parseDownloadProgress(downloadPayload);
}
if (progress) {
// Sum all values across nodes - each node downloads independently
totalBytes += progress.totalBytes;
downloadedBytes += progress.downloadedBytes;
totalSpeed += progress.speed;
completedFiles += progress.completedFiles;
totalFiles += progress.totalFiles;
allFiles.push(...progress.files);
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
perNode.push({ nodeId, nodeName, progress });
}
}
}
}
if (!isDownloading) {
// Check runner status for other states
if (!instanceModelId) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
@@ -1853,26 +1775,49 @@
};
}
// ETA = total remaining bytes / total speed across all nodes
const remainingBytes = totalBytes - downloadedBytes;
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
// Get node IDs assigned to this instance
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
const instanceNodeIds = Object.keys(runnerToShard)
.map((runnerId) => runnerToNode[runnerId])
.filter(Boolean);
const result = collectDownloadStatus(instanceModelId, instanceNodeIds);
if (result.failedError) {
return {
isDownloading: false,
isFailed: true,
errorMessage: result.failedError,
progress: null,
statusText: "FAILED",
perNode: [],
};
}
if (!result.isDownloading) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: statusInfo.statusText === "FAILED",
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: result.perNode,
};
}
return {
isDownloading: true,
isFailed: false,
errorMessage: null,
progress: {
totalBytes,
downloadedBytes,
speed: totalSpeed,
etaMs,
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
completedFiles,
totalFiles,
files: allFiles,
},
progress: result.progress,
statusText: "DOWNLOADING",
perNode,
perNode: result.perNode,
};
}
@@ -4630,7 +4575,7 @@
type="button"
onclick={() => {
completeOnboarding();
sendMessage(chip);
sendMessage(chip, undefined, thinkingEnabled());
}}
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
>
@@ -5369,10 +5314,10 @@
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode as nodeProg}
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.progress.percentage),
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
@@ -5428,15 +5373,17 @@
>
<span
>{formatBytes(
nodeProg.progress.downloadedBytes,
nodeProg.progress?.downloadedBytes ??
0,
)} / {formatBytes(
nodeProg.progress.totalBytes,
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(nodeProg.progress.speed)}
ETA {formatEta(
nodeProg.progress.etaMs,
>{formatSpeed(
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
@@ -5444,14 +5391,14 @@
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress.files.length === 0}
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress.files as f}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
@@ -5927,12 +5874,15 @@
)}
{@const allPreviews = filteredPreviews()}
{#if selectedModel && allPreviews.length > 0}
{@const downloadStatus = getModelDownloadStatus(
selectedModel.id,
)}
{@const tags = modelTags()[selectedModel.id] || []}
<div class="space-y-3">
{#each allPreviews as apiPreview, i}
{@const downloadStatus = getModelDownloadStatus(
selectedModel.id,
apiPreview.memory_delta_by_node
? Object.keys(apiPreview.memory_delta_by_node)
: undefined,
)}
<div
role="group"
onmouseenter={() => {
@@ -6120,7 +6070,7 @@
onclick={() => {
chatLaunchState = "idle";
selectedChatCategory = null;
sendMessage(prompt);
sendMessage(prompt, undefined, thinkingEnabled());
}}
class="text-left px-3 py-2.5 text-xs text-exo-light-gray hover:text-white font-mono rounded-lg border border-exo-medium-gray/30 hover:border-exo-yellow/30 bg-exo-dark-gray/30 hover:bg-exo-dark-gray/60 transition-all duration-200 cursor-pointer"
>
@@ -6503,10 +6453,10 @@
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode as nodeProg}
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.progress.percentage),
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
@@ -6565,16 +6515,17 @@
>
<span
>{formatBytes(
nodeProg.progress.downloadedBytes,
nodeProg.progress
?.downloadedBytes ?? 0,
)} / {formatBytes(
nodeProg.progress.totalBytes,
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(
nodeProg.progress.speed,
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress.etaMs,
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
@@ -6582,14 +6533,14 @@
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress.files.length === 0}
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress.files as f}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
+12 -1
View File
@@ -72,7 +72,7 @@
];
perSystem =
{ config, self', inputs', pkgs, lib, system, ... }:
{ config, self', pkgs, lib, system, ... }:
let
# Use pinned nixpkgs for swift-format (swift is broken on x86_64-linux in newer nixpkgs)
pkgsSwift = import inputs.nixpkgs-swift { inherit system; };
@@ -84,6 +84,17 @@
config.allowUnfreePredicate = pkg: (pkg.pname or "") == "metal-toolchain";
overlays = [
(import ./nix/apple-sdk-overlay.nix)
(final: prev: {
macmon = prev.macmon.overrideAttrs (_: {
version = "git";
src = final.fetchFromGitHub {
owner = "swiftraccoon";
repo = "macmon";
rev = "9154d234f763fbeffdcb4135d0bbbaf80609699b";
hash = "sha256-CwhilKNbs5XL9/tF5DMwyPBlE/hpmjGNTuxQ36sM50M=";
};
});
})
];
};
treefmt = {
+7
View File
@@ -1,5 +1,8 @@
export NIX_CONFIG := "extra-experimental-features = nix-command flakes"
default: lint fmt
all: lint fmt check
fmt:
treefmt || nix fmt
@@ -31,6 +34,10 @@ build-dashboard:
package:
uv run pyinstaller packaging/pyinstaller/exo.spec
build-app: package
xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
@echo "\nBuild complete. Run with:\n open {{justfile_directory()}}/app/EXO/build/Build/Products/Debug/EXO.app"
clean:
rm -rf **/__pycache__
rm -rf target/
+3 -2
View File
@@ -71,7 +71,9 @@ MACMON_PATH = shutil.which("macmon")
if MACMON_PATH is None:
raise SystemExit(
"macmon binary not found in PATH. "
"Install it via: brew install macmon"
"Install the pinned fork used by exo via: "
"cargo install --git https://github.com/swiftraccoon/macmon "
"--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b macmon --force"
)
BINARIES: list[tuple[str, str]] = [
@@ -120,4 +122,3 @@ coll = COLLECT(
upx_exclude=[],
name="exo",
)
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "exo"
version = "0.3.68"
version = "0.3.69"
description = "Exo"
readme = "README.md"
requires-python = ">=3.13"
@@ -25,7 +25,7 @@ dependencies = [
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
"tomlkit>=0.14.0",
"mflux==0.16.9",
"mflux==0.17.2",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
@@ -61,7 +61,7 @@ members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources]
exo_pyo3_bindings = { workspace = true }
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/eval-left-padding-in-batched-rotation" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-deepseek-v32-indexer" }
# Uncomment to use local mlx/mlx-lm development versions:
# mlx = { path = "/Users/Shared/mlx", editable=true }
# mlx-lm = { path = "/Users/Shared/mlx-lm", editable=true }
@@ -0,0 +1,13 @@
model_id = "mlx-community/DeepSeek-V3.2-4bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 378086226621
@@ -0,0 +1,13 @@
model_id = "mlx-community/DeepSeek-V3.2-8bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 755957120916
+1 -1
View File
@@ -42,7 +42,7 @@ class MessageTooLargeError(builtins.Exception):
@typing.final
class NetworkingHandle:
def __new__(cls, identity: Keypair) -> NetworkingHandle: ...
def __new__(cls, identity: Keypair, bootstrap_peers: typing.Sequence[builtins.str], listen_port: builtins.int) -> NetworkingHandle: ...
async def gossipsub_subscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Subscribe to a `GossipSub` topic.
+9 -2
View File
@@ -180,7 +180,12 @@ impl PyNetworkingHandle {
// ---- Lifecycle management methods ----
#[new]
fn py_new(identity: Bound<'_, PyKeypair>) -> PyResult<Self> {
#[pyo3(signature = (identity, bootstrap_peers, listen_port))]
fn py_new(
identity: Bound<'_, PyKeypair>,
bootstrap_peers: Vec<String>,
listen_port: u16,
) -> PyResult<Self> {
// create communication channels
let (to_swarm, from_client) = mpsc::channel(MPSC_CHANNEL_SIZE);
@@ -189,7 +194,9 @@ impl PyNetworkingHandle {
// create networking swarm (within tokio context!! or it crashes)
let _guard = pyo3_async_runtimes::tokio::get_runtime().enter();
let swarm = create_swarm(identity, from_client).pyerr()?.into_stream();
let swarm = create_swarm(identity, from_client, bootstrap_peers, listen_port)
.pyerr()?
.into_stream();
Ok(Self {
swarm: Arc::new(Mutex::new(swarm)),
+8 -3
View File
@@ -16,9 +16,14 @@ async fn main() {
let (to_swarm, from_client) = mpsc::channel(20);
// Configure swarm
let mut swarm = swarm::create_swarm(identity::Keypair::generate_ed25519(), from_client)
.expect("Swarm creation failed")
.into_stream();
let mut swarm = swarm::create_swarm(
identity::Keypair::generate_ed25519(),
from_client,
vec![],
0,
)
.expect("Swarm creation failed")
.into_stream();
// Create a Gossipsub topic & subscribe
let (tx, rx) = oneshot::channel();
+9 -1
View File
@@ -104,6 +104,7 @@ pub struct Behaviour {
// state-tracking for managed behaviors & mDNS-discovered peers
managed: managed::Behaviour,
mdns_discovered: HashMap<PeerId, BTreeSet<Multiaddr>>,
bootstrap_peers: Vec<Multiaddr>,
retry_delay: Delay, // retry interval
@@ -112,10 +113,11 @@ pub struct Behaviour {
}
impl Behaviour {
pub fn new(keypair: &identity::Keypair) -> io::Result<Self> {
pub fn new(keypair: &identity::Keypair, bootstrap_peers: Vec<Multiaddr>) -> io::Result<Self> {
Ok(Self {
managed: managed::Behaviour::new(keypair)?,
mdns_discovered: HashMap::new(),
bootstrap_peers,
retry_delay: Delay::new(RETRY_CONNECT_INTERVAL),
pending_events: WakerDeque::new(),
})
@@ -368,6 +370,12 @@ impl NetworkBehaviour for Behaviour {
self.dial(p, ma)
}
}
// dial bootstrap peers (for environments where mDNS is unavailable)
for addr in &self.bootstrap_peers {
self.pending_events.push_back(ToSwarm::Dial {
opts: DialOpts::unknown_peer_id().address(addr.clone()).build(),
})
}
self.retry_delay.reset(RETRY_CONNECT_INTERVAL) // reset timeout
}
+19 -6
View File
@@ -142,19 +142,29 @@ fn filter_swarm_event(event: SwarmEvent<BehaviourEvent>) -> Option<FromSwarm> {
}
}
/// Create and configure a swarm which listens to all ports on OS
/// Create and configure a swarm.
///
/// - `listen_port`: TCP port to listen on. `0` lets the OS assign one.
/// - `bootstrap_peers`: multiaddrs to dial for environments without mDNS.
pub fn create_swarm(
keypair: identity::Keypair,
from_client: mpsc::Receiver<ToSwarm>,
bootstrap_peers: Vec<String>,
listen_port: u16,
) -> alias::AnyResult<Swarm> {
let parsed_bootstrap_peers: Vec<libp2p::Multiaddr> = bootstrap_peers
.iter()
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
let mut swarm = SwarmBuilder::with_existing_identity(keypair)
.with_tokio()
.with_other_transport(tcp_transport)?
.with_behaviour(Behaviour::new)?
.with_behaviour(|keypair| Behaviour::new(keypair, parsed_bootstrap_peers))?
.build();
// Listen on all interfaces and whatever port the OS assigns
swarm.listen_on("/ip4/0.0.0.0/tcp/0".parse()?)?;
swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{listen_port}").parse()?)?;
Ok(Swarm { swarm, from_client })
}
@@ -246,9 +256,12 @@ mod behaviour {
}
impl Behaviour {
pub fn new(keypair: &identity::Keypair) -> alias::AnyResult<Self> {
pub fn new(
keypair: &identity::Keypair,
bootstrap_peers: Vec<libp2p::Multiaddr>,
) -> alias::AnyResult<Self> {
Ok(Self {
discovery: discovery::Behaviour::new(keypair)?,
discovery: discovery::Behaviour::new(keypair, bootstrap_peers)?,
gossipsub: gossipsub_behaviour(keypair),
})
}
+107
View File
@@ -0,0 +1,107 @@
use futures_lite::StreamExt;
use networking::swarm::{FromSwarm, create_swarm};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
/// Helper: find a free TCP port.
fn free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
/// Two nodes connect via bootstrap peers — no mDNS needed.
///
/// Node A listens on a fixed port. Node B bootstraps to A's address.
/// We verify that B emits `FromSwarm::Discovered` for A's peer ID.
#[tokio::test]
async fn two_nodes_connect_via_bootstrap_peers() {
let port_a = free_port();
// Node A: listens on a known port, no bootstrap peers
let keypair_a = libp2p::identity::Keypair::generate_ed25519();
let peer_id_a = keypair_a.public().to_peer_id();
let (_tx_a, rx_a) = mpsc::channel(16);
let swarm_a = create_swarm(keypair_a, rx_a, vec![], port_a).expect("create swarm A");
let mut stream_a = swarm_a.into_stream();
// Node B: bootstraps to A's address
let keypair_b = libp2p::identity::Keypair::generate_ed25519();
let (_tx_b, rx_b) = mpsc::channel(16);
let swarm_b = create_swarm(
keypair_b,
rx_b,
vec![format!("/ip4/127.0.0.1/tcp/{port_a}")],
0,
)
.expect("create swarm B");
let mut stream_b = swarm_b.into_stream();
// Wait for B to discover A (connection established)
let connected = timeout(Duration::from_secs(10), async {
loop {
tokio::select! {
Some(event) = stream_a.next() => {
// A will also see B connect, but we check from B's perspective
let _ = event;
}
Some(event) = stream_b.next() => {
if let FromSwarm::Discovered { peer_id } = event {
if peer_id == peer_id_a {
return true;
}
}
}
}
}
})
.await;
assert!(
connected.is_ok() && connected.unwrap(),
"Node B should discover Node A via bootstrap peer"
);
}
/// Empty bootstrap peers should work (backward compatible).
#[tokio::test]
async fn create_swarm_with_empty_bootstrap_peers() {
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(keypair, rx, vec![], 0);
assert!(
swarm.is_ok(),
"create_swarm with no bootstrap peers should succeed"
);
}
/// Invalid multiaddr strings are silently filtered out.
#[tokio::test]
async fn create_swarm_ignores_invalid_bootstrap_addrs() {
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(
keypair,
rx,
vec![
"not-a-valid-multiaddr".to_string(),
"".to_string(),
"/ip4/10.0.0.1/tcp/30000".to_string(), // valid
],
0,
);
assert!(
swarm.is_ok(),
"create_swarm should succeed even with invalid bootstrap addrs"
);
}
/// Fixed listen port works correctly.
#[tokio::test]
async fn create_swarm_with_fixed_port() {
let port = free_port();
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(keypair, rx, vec![], port);
assert!(swarm.is_ok(), "create_swarm with fixed port should succeed");
}
View File
@@ -4,7 +4,7 @@ import time
from collections.abc import AsyncGenerator
from typing import Any
from exo.shared.types.api import (
from exo.api.types import (
ChatCompletionChoice,
ChatCompletionMessage,
ChatCompletionMessageText,
@@ -202,6 +202,8 @@ async def generate_chat_stream(
usage=last_usage,
)
yield f"data: {tool_response.model_dump_json()}\n\n"
if chunk.stats is not None:
yield f": generation_stats {chunk.stats.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
@@ -216,7 +218,10 @@ async def generate_chat_stream(
yield f"data: {chunk_response.model_dump_json()}\n\n"
if chunk.finish_reason is not None:
if chunk.stats is not None:
yield f": generation_stats {chunk.stats.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
async def collect_chat_response(
@@ -5,14 +5,8 @@ import re
from collections.abc import AsyncGenerator
from typing import Any
from exo.shared.types.api import FinishReason, Usage
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.claude_api import (
from exo.api.types import FinishReason, Usage
from exo.api.types.claude_api import (
ClaudeContentBlock,
ClaudeContentBlockDeltaEvent,
ClaudeContentBlockStartEvent,
@@ -35,6 +29,12 @@ from exo.shared.types.claude_api import (
ClaudeToolUseBlock,
ClaudeUsage,
)
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
@@ -4,14 +4,7 @@ import json
from collections.abc import AsyncGenerator
from typing import Any
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.ollama_api import (
from exo.api.types.ollama_api import (
OllamaChatRequest,
OllamaChatResponse,
OllamaDoneReason,
@@ -21,6 +14,13 @@ from exo.shared.types.ollama_api import (
OllamaToolCall,
OllamaToolFunction,
)
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
@@ -4,15 +4,8 @@ from collections.abc import AsyncGenerator
from itertools import count
from typing import Any
from exo.shared.types.api import Usage
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.openai_responses import (
from exo.api.types import Usage
from exo.api.types.openai_responses import (
FunctionCallInputItem,
ResponseCompletedEvent,
ResponseContentPart,
@@ -42,6 +35,13 @@ from exo.shared.types.openai_responses import (
ResponseTextDoneEvent,
ResponseUsage,
)
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.text_generation import (
InputMessage,
TextGenerationTaskParams,
+76 -53
View File
@@ -21,17 +21,17 @@ from hypercorn.config import Config
from hypercorn.typing import ASGIFramework
from loguru import logger
from exo.master.adapters.chat_completions import (
from exo.api.adapters.chat_completions import (
chat_request_to_text_generation,
collect_chat_response,
generate_chat_stream,
)
from exo.master.adapters.claude import (
from exo.api.adapters.claude import (
claude_request_to_text_generation,
collect_claude_response,
generate_claude_stream,
)
from exo.master.adapters.ollama import (
from exo.api.adapters.ollama import (
collect_ollama_chat_response,
collect_ollama_generate_response,
generate_ollama_chat_stream,
@@ -39,34 +39,12 @@ from exo.master.adapters.ollama import (
ollama_generate_request_to_text_generation,
ollama_request_to_text_generation,
)
from exo.master.adapters.responses import (
from exo.api.adapters.responses import (
collect_responses_response,
generate_responses_stream,
responses_request_to_text_generation,
)
from exo.master.event_log import DiskEventLog
from exo.master.image_store import ImageStore
from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
from exo.shared.constants import (
DASHBOARD_DIR,
EXO_CACHE_HOME,
EXO_EVENT_LOG_DIR,
EXO_IMAGE_CACHE_DIR,
EXO_MAX_CHUNK_SIZE,
EXO_TRACING_CACHE_DIR,
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
from exo.shared.models.model_cards import (
ModelCard,
ModelId,
delete_custom_card,
get_model_cards,
is_custom_card,
)
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
from exo.shared.types.api import (
from exo.api.types import (
AddCustomModelParams,
AdvancedImageParams,
BenchChatCompletionRequest,
@@ -114,6 +92,48 @@ from exo.shared.types.api import (
TraceStatsResponse,
normalize_image_size,
)
from exo.api.types.claude_api import (
ClaudeMessagesRequest,
ClaudeMessagesResponse,
)
from exo.api.types.ollama_api import (
OllamaChatRequest,
OllamaChatResponse,
OllamaGenerateRequest,
OllamaGenerateResponse,
OllamaModelDetails,
OllamaModelTag,
OllamaPsModel,
OllamaPsResponse,
OllamaShowRequest,
OllamaShowResponse,
OllamaTagsResponse,
)
from exo.api.types.openai_responses import (
ResponsesRequest,
ResponsesResponse,
)
from exo.master.image_store import ImageStore
from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
from exo.shared.constants import (
DASHBOARD_DIR,
EXO_CACHE_HOME,
EXO_EVENT_LOG_DIR,
EXO_IMAGE_CACHE_DIR,
EXO_MAX_CHUNK_SIZE,
EXO_TRACING_CACHE_DIR,
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
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 (
ErrorChunk,
ImageChunk,
@@ -122,13 +142,11 @@ from exo.shared.types.chunks import (
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.claude_api import (
ClaudeMessagesRequest,
ClaudeMessagesResponse,
)
from exo.shared.types.commands import (
AddCustomModelCard,
Command,
CreateInstance,
DeleteCustomModelCard,
DeleteDownload,
DeleteInstance,
DownloadCommand,
@@ -151,29 +169,13 @@ from exo.shared.types.events import (
TracesMerged,
)
from exo.shared.types.memory import Memory
from exo.shared.types.ollama_api import (
OllamaChatRequest,
OllamaChatResponse,
OllamaGenerateRequest,
OllamaGenerateResponse,
OllamaModelDetails,
OllamaModelTag,
OllamaPsModel,
OllamaPsResponse,
OllamaShowRequest,
OllamaShowResponse,
OllamaTagsResponse,
)
from exo.shared.types.openai_responses import (
ResponsesRequest,
ResponsesResponse,
)
from exo.shared.types.state import State
from exo.shared.types.worker.downloads import DownloadCompleted
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding
from exo.utils.banner import print_startup_banner
from exo.utils.channels import Receiver, Sender, channel
from exo.utils.disk_event_log import DiskEventLog
from exo.utils.power_sampler import PowerSampler
from exo.utils.task_group import TaskGroup
@@ -413,6 +415,7 @@ class API:
node_network=self.state.node_network,
topology=self.state.topology,
current_instances=self.state.instances,
download_status=self.state.downloads,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -475,6 +478,7 @@ class API:
topology=self.state.topology,
current_instances=self.state.instances,
required_nodes=required_nodes,
download_status=self.state.downloads,
)
except ValueError as exc:
if (model_card.model_id, sharding, instance_meta, 0) not in seen:
@@ -1558,7 +1562,7 @@ class API:
storage_size_megabytes=card.storage_size.in_mb,
supports_tensor=card.supports_tensor,
tasks=[task.value for task in card.tasks],
is_custom=is_custom_card(card.model_id),
is_custom=card.is_custom,
family=card.family,
quantization=card.quantization,
base_model=card.base_model,
@@ -1569,7 +1573,7 @@ class API:
)
async def add_custom_model(self, payload: AddCustomModelParams) -> ModelListModel:
"""Fetch a model from HuggingFace and save as a custom model card."""
"""Fetch a model from HuggingFace and save as a custom model card, then sync across the cluster."""
try:
card = await ModelCard.fetch_from_hf(payload.model_id)
except Exception as exc:
@@ -1577,6 +1581,17 @@ class API:
status_code=400, detail=f"Failed to fetch model: {exc}"
) from exc
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=AddCustomModelCard(model_card=card),
)
)
# Immediately update the local cache so the subsequent GET /models
# returns the new model without waiting for the event round-trip.
add_to_card_cache(card)
return ModelListModel(
id=card.model_id,
hugging_face_id=card.model_id,
@@ -1590,10 +1605,18 @@ class API:
)
async def delete_custom_model(self, model_id: ModelId) -> JSONResponse:
"""Delete a user-added custom model card."""
deleted = await delete_custom_card(model_id)
if not deleted:
"""Delete a user-added custom model card and sync deletion across the cluster."""
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")
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=DeleteCustomModelCard(model_id=model_id),
)
)
return JSONResponse(
{"message": "Model card deleted", "model_id": str(model_id)}
)
@@ -4,10 +4,11 @@ from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from exo.api.main import API
def test_http_exception_handler_formats_openai_style() -> None:
"""Test that HTTPException is converted to OpenAI-style error format."""
from exo.master.api import API
app = FastAPI()
@@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from exo.api.main import API
from exo.shared.types.common import CommandId
def _make_api() -> Any:
"""Create a minimal API instance with cancel route and error handler."""
from exo.master.api import API
app = FastAPI()
api = object.__new__(API)
@@ -3,11 +3,11 @@
import pydantic
import pytest
from exo.master.adapters.claude import (
from exo.api.adapters.claude import (
claude_request_to_text_generation,
finish_reason_to_claude_stop_reason,
)
from exo.shared.types.claude_api import (
from exo.api.types.claude_api import (
ClaudeMessage,
ClaudeMessagesRequest,
ClaudeTextBlock,
@@ -4,12 +4,12 @@ import json
from collections.abc import AsyncGenerator
from typing import Any, cast
from exo.master.adapters.claude import (
from exo.api.adapters.claude import (
ClaudeMessagesResponse,
collect_claude_response,
generate_claude_stream,
)
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
from exo.shared.types.chunks import ErrorChunk, TokenChunk, ToolCallChunk
from exo.shared.types.common import CommandId, ModelId
@@ -7,11 +7,11 @@ The responses adapter converts it to TextGenerationTaskParams for the pipeline.
import pydantic
import pytest
from exo.shared.types.common import ModelId
from exo.shared.types.openai_responses import (
from exo.api.types.openai_responses import (
ResponseInputMessage,
ResponsesRequest,
)
from exo.shared.types.common import ModelId
class TestResponsesRequestValidation:
+57
View File
@@ -0,0 +1,57 @@
from .api import AddCustomModelParams as AddCustomModelParams
from .api import AdvancedImageParams as AdvancedImageParams
from .api import BenchChatCompletionRequest as BenchChatCompletionRequest
from .api import BenchChatCompletionResponse as BenchChatCompletionResponse
from .api import BenchImageGenerationResponse as BenchImageGenerationResponse
from .api import BenchImageGenerationTaskParams as BenchImageGenerationTaskParams
from .api import CancelCommandResponse as CancelCommandResponse
from .api import ChatCompletionChoice as ChatCompletionChoice
from .api import ChatCompletionMessage as ChatCompletionMessage
from .api import ChatCompletionMessageText as ChatCompletionMessageText
from .api import ChatCompletionRequest as ChatCompletionRequest
from .api import ChatCompletionResponse as ChatCompletionResponse
from .api import CompletionTokensDetails as CompletionTokensDetails
from .api import CreateInstanceParams as CreateInstanceParams
from .api import CreateInstanceResponse as CreateInstanceResponse
from .api import DeleteDownloadResponse as DeleteDownloadResponse
from .api import DeleteInstanceResponse as DeleteInstanceResponse
from .api import DeleteTracesRequest as DeleteTracesRequest
from .api import DeleteTracesResponse as DeleteTracesResponse
from .api import ErrorInfo as ErrorInfo
from .api import ErrorResponse as ErrorResponse
from .api import FinishReason as FinishReason
from .api import GenerationStats as GenerationStats
from .api import HuggingFaceSearchResult as HuggingFaceSearchResult
from .api import ImageData as ImageData
from .api import ImageEditsTaskParams as ImageEditsTaskParams
from .api import ImageGenerationResponse as ImageGenerationResponse
from .api import ImageGenerationStats as ImageGenerationStats
from .api import ImageGenerationTaskParams as ImageGenerationTaskParams
from .api import ImageListItem as ImageListItem
from .api import ImageListResponse as ImageListResponse
from .api import ImageSize as ImageSize
from .api import Logprobs as Logprobs
from .api import LogprobsContentItem as LogprobsContentItem
from .api import ModelList as ModelList
from .api import ModelListModel as ModelListModel
from .api import NodePowerStats as NodePowerStats
from .api import PlaceInstanceParams as PlaceInstanceParams
from .api import PlacementPreview as PlacementPreview
from .api import PlacementPreviewResponse as PlacementPreviewResponse
from .api import PowerUsage as PowerUsage
from .api import PromptTokensDetails as PromptTokensDetails
from .api import StartDownloadParams as StartDownloadParams
from .api import StartDownloadResponse as StartDownloadResponse
from .api import StreamingChoiceResponse as StreamingChoiceResponse
from .api import ToolCall as ToolCall
from .api import ToolCallItem as ToolCallItem
from .api import TopLogprobItem as TopLogprobItem
from .api import TraceCategoryStats as TraceCategoryStats
from .api import TraceEventResponse as TraceEventResponse
from .api import TraceListItem as TraceListItem
from .api import TraceListResponse as TraceListResponse
from .api import TraceRankStats as TraceRankStats
from .api import TraceResponse as TraceResponse
from .api import TraceStatsResponse as TraceStatsResponse
from .api import Usage as Usage
from .api import normalize_image_size as normalize_image_size
+95 -66
View File
@@ -1,17 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import anyio
from anyio import current_time
from anyio import current_time, to_thread
from loguru import logger
from exo.download.download_utils import (
RepoDownloadProgress,
delete_model,
is_read_only_model_dir,
map_repo_download_progress_to_download_progress_data,
resolve_model_in_path,
resolve_existing_model,
)
from exo.download.shard_downloader import ShardDownloader
from exo.shared.constants import EXO_MODELS_DIR, EXO_MODELS_PATH
from exo.shared.constants import EXO_DEFAULT_MODELS_DIR, EXO_MODELS_READ_ONLY_DIRS
from exo.shared.models.model_cards import ModelId, get_model_cards
from exo.shared.types.commands import (
CancelDownload,
@@ -24,6 +28,7 @@ from exo.shared.types.events import (
Event,
NodeDownloadProgress,
)
from exo.shared.types.memory import Memory
from exo.shared.types.worker.downloads import (
DownloadCompleted,
DownloadFailed,
@@ -49,6 +54,7 @@ class DownloadCoordinator:
active_downloads: dict[ModelId, anyio.CancelScope] = field(default_factory=dict)
_tg: TaskGroup = field(init=False, default_factory=TaskGroup)
_stopped: anyio.Event = field(init=False, default_factory=anyio.Event)
# Per-model throttle for download progress events
_last_progress_time: dict[ModelId, float] = field(default_factory=dict)
@@ -56,8 +62,23 @@ class DownloadCoordinator:
def __post_init__(self) -> None:
self.shard_downloader.on_progress(self._download_progress_callback)
def _model_dir(self, model_id: ModelId) -> str:
return str(EXO_MODELS_DIR / model_id.normalize())
@staticmethod
def _default_model_dir(model_id: ModelId) -> str:
return str(EXO_DEFAULT_MODELS_DIR / model_id.normalize())
def _completed_from_path(
self,
shard: ShardMetadata,
found: Path,
total: Memory,
) -> DownloadCompleted:
return DownloadCompleted(
shard_metadata=shard,
node_id=self.node_id,
total=total,
model_directory=str(found),
read_only=is_read_only_model_dir(found),
)
async def _download_progress_callback(
self, callback_shard: ShardMetadata, progress: RepoDownloadProgress
@@ -66,12 +87,18 @@ class DownloadCoordinator:
throttle_interval_secs = 1.0
if progress.status == "complete":
completed = DownloadCompleted(
shard_metadata=callback_shard,
node_id=self.node_id,
total=progress.total,
model_directory=self._model_dir(model_id),
)
found = await to_thread.run_sync(resolve_existing_model, model_id)
if found is not None:
completed = self._completed_from_path(
callback_shard, found, progress.total
)
else:
completed = DownloadCompleted(
shard_metadata=callback_shard,
node_id=self.node_id,
total=progress.total,
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = completed
await self.event_sender.send(
NodeDownloadProgress(download_progress=completed)
@@ -88,7 +115,7 @@ class DownloadCoordinator:
download_progress=map_repo_download_progress_to_download_progress_data(
progress
),
model_directory=self._model_dir(model_id),
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = ongoing
await self.event_sender.send(
@@ -100,12 +127,16 @@ class DownloadCoordinator:
logger.info(
f"Starting DownloadCoordinator{' (offline mode)' if self.offline else ''}"
)
async with self._tg as tg:
tg.start_soon(self._command_processor)
tg.start_soon(self._emit_existing_download_progress)
try:
async with self._tg as tg:
tg.start_soon(self._command_processor)
tg.start_soon(self._emit_existing_download_progress)
finally:
self._stopped.set()
def shutdown(self) -> None:
async def shutdown(self) -> None:
self._tg.cancel_tasks()
await self._stopped.wait()
async def _command_processor(self) -> None:
with self.download_command_receiver as commands:
@@ -130,7 +161,7 @@ class DownloadCoordinator:
pending = DownloadPending(
shard_metadata=current_status.shard_metadata,
node_id=self.node_id,
model_directory=self._model_dir(model_id),
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = pending
await self.event_sender.send(
@@ -149,18 +180,12 @@ class DownloadCoordinator:
)
return
# Check EXO_MODELS_PATH for pre-downloaded models
found_path = resolve_model_in_path(model_id)
# Check all model directories for pre-existing complete models
found_path = await to_thread.run_sync(resolve_existing_model, model_id)
if found_path is not None:
logger.info(
f"DownloadCoordinator: Model {model_id} found in EXO_MODELS_PATH at {found_path}"
)
completed = DownloadCompleted(
shard_metadata=shard,
node_id=self.node_id,
total=shard.model_card.storage_size,
model_directory=str(found_path),
read_only=True,
logger.info(f"DownloadCoordinator: Model {model_id} found at {found_path}")
completed = self._completed_from_path(
shard, found_path, shard.model_card.storage_size
)
self.download_status[model_id] = completed
await self.event_sender.send(
@@ -172,7 +197,7 @@ class DownloadCoordinator:
progress = DownloadPending(
shard_metadata=shard,
node_id=self.node_id,
model_directory=self._model_dir(model_id),
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = progress
await self.event_sender.send(NodeDownloadProgress(download_progress=progress))
@@ -183,12 +208,18 @@ class DownloadCoordinator:
)
if initial_progress.status == "complete":
completed = DownloadCompleted(
shard_metadata=shard,
node_id=self.node_id,
total=initial_progress.total,
model_directory=self._model_dir(model_id),
)
found = await to_thread.run_sync(resolve_existing_model, model_id)
if found is not None:
completed = self._completed_from_path(
shard, found, initial_progress.total
)
else:
completed = DownloadCompleted(
shard_metadata=shard,
node_id=self.node_id,
total=initial_progress.total,
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = completed
await self.event_sender.send(
NodeDownloadProgress(download_progress=completed)
@@ -203,7 +234,7 @@ class DownloadCoordinator:
shard_metadata=shard,
node_id=self.node_id,
error_message=f"Model files not found locally in offline mode: {model_id}",
model_directory=self._model_dir(model_id),
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = failed
await self.event_sender.send(NodeDownloadProgress(download_progress=failed))
@@ -224,7 +255,7 @@ class DownloadCoordinator:
download_progress=map_repo_download_progress_to_download_progress_data(
initial_progress
),
model_directory=self._model_dir(model_id),
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = status
self.event_sender.send_nowait(NodeDownloadProgress(download_progress=status))
@@ -239,7 +270,7 @@ class DownloadCoordinator:
shard_metadata=shard,
node_id=self.node_id,
error_message=str(e),
model_directory=self._model_dir(model_id),
model_directory=self._default_model_dir(model_id),
)
self.download_status[model_id] = failed
await self.event_sender.send(
@@ -256,13 +287,11 @@ class DownloadCoordinator:
self.active_downloads[model_id] = scope
async def _delete_download(self, model_id: ModelId) -> None:
# Protect read-only models (from EXO_MODELS_PATH) from deletion
# Protect read-only models from deletion
if model_id in self.download_status:
current = self.download_status[model_id]
if isinstance(current, DownloadCompleted) and current.read_only:
logger.warning(
f"Refusing to delete read-only model {model_id} (from EXO_MODELS_PATH)"
)
logger.warning(f"Refusing to delete read-only model {model_id}")
return
# Cancel if active
@@ -285,7 +314,7 @@ class DownloadCoordinator:
pending = DownloadPending(
shard_metadata=current_status.shard_metadata,
node_id=self.node_id,
model_directory=self._model_dir(model_id),
model_directory=self._default_model_dir(model_id),
)
await self.event_sender.send(
NodeDownloadProgress(download_progress=pending)
@@ -309,22 +338,26 @@ class DownloadCoordinator:
continue
if progress.status == "complete":
status: DownloadProgress = DownloadCompleted(
node_id=self.node_id,
shard_metadata=progress.shard,
total=progress.total,
model_directory=self._model_dir(
progress.shard.model_card.model_id
),
found = await to_thread.run_sync(
resolve_existing_model, model_id
)
if found is not None:
status: DownloadProgress = self._completed_from_path(
progress.shard, found, progress.total
)
else:
status = DownloadCompleted(
node_id=self.node_id,
shard_metadata=progress.shard,
total=progress.total,
model_directory=self._default_model_dir(model_id),
)
elif progress.status in ["in_progress", "not_started"]:
if progress.downloaded_this_session.in_bytes == 0:
status = DownloadPending(
node_id=self.node_id,
shard_metadata=progress.shard,
model_directory=self._model_dir(
progress.shard.model_card.model_id
),
model_directory=self._default_model_dir(model_id),
downloaded=progress.downloaded,
total=progress.total,
)
@@ -335,9 +368,7 @@ class DownloadCoordinator:
download_progress=map_repo_download_progress_to_download_progress_data(
progress
),
model_directory=self._model_dir(
progress.shard.model_card.model_id
),
model_directory=self._default_model_dir(model_id),
)
else:
continue
@@ -346,8 +377,8 @@ class DownloadCoordinator:
await self.event_sender.send(
NodeDownloadProgress(download_progress=status)
)
# Scan EXO_MODELS_PATH for pre-downloaded models
if EXO_MODELS_PATH is not None:
# Scan read-only directories for pre-downloaded models
if EXO_MODELS_READ_ONLY_DIRS:
for card in await get_model_cards():
mid = card.model_id
if mid in self.active_downloads:
@@ -357,8 +388,8 @@ class DownloadCoordinator:
(DownloadCompleted, DownloadOngoing, DownloadFailed),
):
continue
found = resolve_model_in_path(mid)
if found is not None:
found = await to_thread.run_sync(resolve_existing_model, mid)
if found is not None and is_read_only_model_dir(found):
path_shard = PipelineShardMetadata(
model_card=card,
device_rank=0,
@@ -367,12 +398,10 @@ class DownloadCoordinator:
end_layer=card.n_layers,
n_layers=card.n_layers,
)
path_completed: DownloadProgress = DownloadCompleted(
node_id=self.node_id,
shard_metadata=path_shard,
total=card.storage_size,
model_directory=str(found),
read_only=True,
path_completed: DownloadProgress = (
self._completed_from_path(
path_shard, found, card.storage_size
)
)
self.download_status[mid] = path_completed
await self.event_sender.send(
+124 -57
View File
@@ -30,7 +30,11 @@ from exo.download.huggingface_utils import (
get_hf_endpoint,
get_hf_token,
)
from exo.shared.constants import EXO_MODELS_DIR, EXO_MODELS_PATH
from exo.shared.constants import (
EXO_DEFAULT_MODELS_DIR,
EXO_MODELS_DIRS,
EXO_MODELS_READ_ONLY_DIRS,
)
from exo.shared.models.model_cards import ModelTask
from exo.shared.types.common import ModelId
from exo.shared.types.memory import Memory
@@ -110,50 +114,87 @@ def map_repo_download_progress_to_download_progress_data(
)
def resolve_model_in_path(model_id: ModelId) -> Path | None:
"""Search EXO_MODELS_PATH directories for a pre-existing model.
class InsufficientDiskSpaceError(Exception):
"""Raised when no writable model directory has enough free space."""
Checks each directory for the normalized name (org--model). A candidate
is only returned if ``is_model_directory_complete`` confirms all weight
files are present.
def resolve_existing_model(model_id: ModelId) -> Path | None:
"""Search all model directories for a complete, pre-existing model.
Checks read-only directories first, then writable directories.
A candidate is only returned if ``is_model_directory_complete`` confirms
all weight files are present.
"""
if EXO_MODELS_PATH is None:
return None
normalized = model_id.normalize()
for search_dir in EXO_MODELS_PATH:
for search_dir in (*EXO_MODELS_READ_ONLY_DIRS, *EXO_MODELS_DIRS):
candidate = search_dir / normalized
if candidate.is_dir() and is_model_directory_complete(candidate):
return candidate
return None
def is_read_only_model_dir(model_dir: Path) -> bool:
"""Check if a model directory lives under a read-only models root."""
return any(model_dir.is_relative_to(d) for d in EXO_MODELS_READ_ONLY_DIRS)
def build_model_path(model_id: ModelId) -> Path:
found = resolve_model_in_path(model_id)
found = resolve_existing_model(model_id)
if found is not None:
return found
return EXO_MODELS_DIR / model_id.normalize()
return EXO_DEFAULT_MODELS_DIR / model_id.normalize()
async def resolve_model_path_for_repo(model_id: ModelId) -> Path:
return (await ensure_models_dir()) / model_id.normalize()
def select_download_dir(required_bytes: int) -> Path:
"""Pick the first writable model directory with enough free space.
Raises ``InsufficientDiskSpaceError`` if none have enough space.
"""
for candidate_dir in EXO_MODELS_DIRS:
if not candidate_dir.exists():
continue
try:
usage = shutil.disk_usage(candidate_dir)
if usage.free >= required_bytes:
return candidate_dir
except OSError:
continue
raise InsufficientDiskSpaceError(
f"No writable model directory has {required_bytes / (1024**3):.1f} GiB free. "
f"Checked: {[str(d) for d in EXO_MODELS_DIRS]}"
)
async def ensure_models_dir() -> Path:
await aios.makedirs(EXO_MODELS_DIR, exist_ok=True)
return EXO_MODELS_DIR
async def resolve_model_dir(model_id: ModelId) -> Path:
"""Return the directory for a model's files, creating it if needed.
Checks all model directories for an existing complete model first,
then falls back to the default models directory.
"""
target = await asyncio.to_thread(build_model_path, model_id)
await aios.makedirs(target, exist_ok=True)
return target
async def ensure_cache_dir(model_id: ModelId) -> Path:
"""Return the cache directory for a model's metadata, creating it if needed."""
target = EXO_DEFAULT_MODELS_DIR / "caches" / model_id.normalize()
await aios.makedirs(target, exist_ok=True)
return target
async def delete_model(model_id: ModelId) -> bool:
models_dir = await ensure_models_dir()
model_dir = models_dir / model_id.normalize()
cache_dir = models_dir / "caches" / model_id.normalize()
"""Delete a model from writable directories. Skips read-only dirs."""
normalized = model_id.normalize()
deleted = False
if await aios.path.exists(model_dir):
await asyncio.to_thread(shutil.rmtree, model_dir, ignore_errors=False)
deleted = True
for models_dir in EXO_MODELS_DIRS:
model_dir = models_dir / normalized
if await aios.path.exists(model_dir):
await asyncio.to_thread(shutil.rmtree, model_dir, ignore_errors=False)
deleted = True
# Also clear cache
# Clear cache from default dir
cache_dir = EXO_DEFAULT_MODELS_DIR / "caches" / normalized
if await aios.path.exists(cache_dir):
await asyncio.to_thread(shutil.rmtree, cache_dir, ignore_errors=False)
@@ -161,9 +202,10 @@ async def delete_model(model_id: ModelId) -> bool:
async def seed_models(seed_dir: str | Path):
"""Move models from resources folder to EXO_MODELS_DIR."""
"""Move models from resources folder to the default models directory."""
source_dir = Path(seed_dir)
dest_dir = await ensure_models_dir()
await aios.makedirs(EXO_DEFAULT_MODELS_DIR, exist_ok=True)
dest_dir = EXO_DEFAULT_MODELS_DIR
for path in source_dir.iterdir():
if path.is_dir() and path.name.startswith("models--"):
dest_path = dest_dir / path.name
@@ -253,14 +295,16 @@ async def _build_file_list_from_local_directory(
a local directory must contain a *.safetensors.index.json and
safetensors listed there.
"""
model_dir = (await ensure_models_dir()) / model_id.normalize()
if not await aios.path.exists(model_dir):
return None
file_list = await asyncio.to_thread(_scan_model_directory, model_dir, recursive)
if not file_list:
return None
return file_list
normalized = model_id.normalize()
for search_dir in (*EXO_MODELS_READ_ONLY_DIRS, *EXO_MODELS_DIRS):
model_dir = search_dir / normalized
if await aios.path.exists(model_dir):
file_list = await asyncio.to_thread(
_scan_model_directory, model_dir, recursive
)
if file_list:
return file_list
return None
_fetched_file_lists_this_session: set[str] = set()
@@ -273,8 +317,7 @@ async def fetch_file_list_with_cache(
skip_internet: bool = False,
on_connection_lost: Callable[[], None] = lambda: None,
) -> list[FileListEntry]:
target_dir = (await ensure_models_dir()) / "caches" / model_id.normalize()
await aios.makedirs(target_dir, exist_ok=True)
target_dir = await ensure_cache_dir(model_id)
cache_file = target_dir / f"{model_id.normalize()}--{revision}--file_list.json"
cache_key = f"{model_id.normalize()}--{revision}"
@@ -329,7 +372,7 @@ async def fetch_file_list_with_cache(
)
if local_file_list is not None:
logger.warning(
f"Failed to fetch file list for {model_id} and no cache exists, "
f"Failed to fetch file list for {model_id} and no cache exists, using local file list"
)
return local_file_list
raise FileNotFoundError(f"Failed to fetch file list for {model_id}: {e}") from e
@@ -658,8 +701,7 @@ def calculate_repo_progress(
async def get_weight_map(model_id: ModelId, revision: str = "main") -> dict[str, str]:
target_dir = (await ensure_models_dir()) / model_id.normalize()
await aios.makedirs(target_dir, exist_ok=True)
target_dir = await resolve_model_dir(model_id)
index_files_dir = snapshot_download(
repo_id=model_id,
@@ -730,30 +772,46 @@ async def download_shard(
if not skip_download:
logger.debug(f"Downloading {shard.model_card.model_id=}")
model_id = shard.model_card.model_id
revision = "main"
target_dir = await ensure_models_dir() / str(shard.model_card.model_id).replace(
"/", "--"
)
if not skip_download:
await aios.makedirs(target_dir, exist_ok=True)
if not allow_patterns:
allow_patterns = await resolve_allow_patterns(shard)
if not skip_download:
logger.debug(f"Downloading {shard.model_card.model_id=} with {allow_patterns=}")
logger.debug(f"Downloading {model_id=} with {allow_patterns=}")
all_start_time = time.time()
file_list = await fetch_file_list_with_cache(
shard.model_card.model_id,
revision,
recursive=True,
skip_internet=skip_internet,
on_connection_lost=on_connection_lost,
)
try:
file_list = await fetch_file_list_with_cache(
model_id,
revision,
recursive=True,
skip_internet=skip_internet,
on_connection_lost=on_connection_lost,
)
except FileNotFoundError:
not_started_progress = RepoDownloadProgress(
repo_id=str(model_id),
repo_revision=revision,
shard=shard,
completed_files=0,
total_files=0,
downloaded=Memory.from_bytes(0),
downloaded_this_session=Memory.from_bytes(0),
total=Memory.from_bytes(0),
overall_speed=0.0,
overall_eta=timedelta(0),
status="not_started",
file_progress={},
)
return EXO_DEFAULT_MODELS_DIR / model_id.normalize(), not_started_progress
filtered_file_list = list(
filter_repo_objects(
file_list, allow_patterns=allow_patterns, key=lambda x: x.path
file_list,
allow_patterns=allow_patterns,
ignore_patterns=["original/*", "metal/*"],
key=lambda x: x.path,
)
)
@@ -765,6 +823,15 @@ async def download_shard(
for f in filtered_file_list
if "/" in f.path or not f.path.endswith(".safetensors")
]
# Pick a writable directory with enough free space
total_size = sum(f.size or 0 for f in filtered_file_list)
models_dir = (
select_download_dir(total_size) if not skip_download else EXO_DEFAULT_MODELS_DIR
)
target_dir = models_dir / model_id.normalize()
if not skip_download:
await aios.makedirs(target_dir, exist_ok=True)
file_progress: dict[str, RepoFileDownloadProgress] = {}
async def on_progress_wrapper(
@@ -801,7 +868,7 @@ async def download_shard(
else timedelta(seconds=0)
)
file_progress[file.path] = RepoFileDownloadProgress(
repo_id=shard.model_card.model_id,
repo_id=model_id,
repo_revision=revision,
file_path=file.path,
downloaded=Memory.from_bytes(curr_bytes),
@@ -829,7 +896,7 @@ async def download_shard(
downloaded_bytes = await get_downloaded_size(target_dir / file.path)
final_file_exists = await aios.path.exists(target_dir / file.path)
file_progress[file.path] = RepoFileDownloadProgress(
repo_id=shard.model_card.model_id,
repo_id=model_id,
repo_revision=revision,
file_path=file.path,
downloaded=Memory.from_bytes(downloaded_bytes),
@@ -855,7 +922,7 @@ async def download_shard(
async def download_with_semaphore(file: FileListEntry) -> None:
async with semaphore:
await download_file_with_retry(
shard.model_card.model_id,
model_id,
revision,
file.path,
target_dir,
@@ -871,7 +938,7 @@ async def download_shard(
*[download_with_semaphore(file) for file in filtered_file_list]
)
final_repo_progress = calculate_repo_progress(
shard, shard.model_card.model_id, revision, file_progress, all_start_time
shard, model_id, revision, file_progress, all_start_time
)
await on_progress(shard, final_repo_progress)
if gguf := next((f for f in filtered_file_list if f.path.endswith(".gguf")), None):
@@ -1,7 +1,6 @@
"""Tests for download verification and cache behavior."""
import time
from collections.abc import AsyncIterator
from datetime import timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
@@ -25,15 +24,6 @@ def model_id() -> ModelId:
return ModelId("test-org/test-model")
@pytest.fixture
async def temp_models_dir(tmp_path: Path) -> AsyncIterator[Path]:
"""Set up a temporary models directory for testing."""
models_dir = tmp_path / "models"
await aios.makedirs(models_dir, exist_ok=True)
with patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir):
yield models_dir
class TestFileVerification:
"""Tests for file size verification in _download_file."""
@@ -188,7 +178,8 @@ class TestFileListCache:
]
with (
patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (models_dir,)),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", models_dir),
patch(
"exo.download.download_utils.fetch_file_list_with_retry",
new_callable=AsyncMock,
@@ -234,7 +225,8 @@ class TestFileListCache:
)
with (
patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (models_dir,)),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", models_dir),
patch(
"exo.download.download_utils.fetch_file_list_with_retry",
new_callable=AsyncMock,
@@ -252,7 +244,8 @@ class TestFileListCache:
models_dir = tmp_path / "models"
with (
patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (models_dir,)),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", models_dir),
patch(
"exo.download.download_utils.fetch_file_list_with_retry",
new_callable=AsyncMock,
@@ -284,7 +277,10 @@ class TestModelDeletion:
async with aiofiles.open(cache_dir / "file_list.json", "w") as f:
await f.write("[]")
with patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir):
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (models_dir,)),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", models_dir),
):
result = await delete_model(model_id)
assert result is True
@@ -303,7 +299,10 @@ class TestModelDeletion:
async with aiofiles.open(cache_dir / "file_list.json", "w") as f:
await f.write("[]")
with patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir):
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (models_dir,)),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", models_dir),
):
result = await delete_model(model_id)
# Returns False because model dir didn't exist
@@ -318,7 +317,10 @@ class TestModelDeletion:
models_dir = tmp_path / "models"
await aios.makedirs(models_dir, exist_ok=True)
with patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir):
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (models_dir,)),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", models_dir),
):
result = await delete_model(model_id)
assert result is False
+297
View File
@@ -0,0 +1,297 @@
"""Tests for multi-directory model resolution, download target selection, and deletion."""
import json
import shutil
from collections.abc import AsyncIterator
from pathlib import Path
from unittest.mock import patch
import aiofiles
import aiofiles.os as aios
import pytest
from exo.download.download_utils import (
InsufficientDiskSpaceError,
delete_model,
is_read_only_model_dir,
resolve_existing_model,
select_download_dir,
)
from exo.shared.types.common import ModelId
MODEL_ID = ModelId("test-org/test-model")
NORMALIZED = MODEL_ID.normalize()
def _create_complete_model(model_dir: Path) -> None:
"""Create a minimal complete model directory on disk."""
model_dir.mkdir(parents=True, exist_ok=True)
weight_map = {"layer.weight": "model.safetensors"}
index = {"metadata": {"total_size": 1024}, "weight_map": weight_map}
(model_dir / "model.safetensors.index.json").write_text(json.dumps(index))
(model_dir / "model.safetensors").write_bytes(b"weights")
(model_dir / "config.json").write_text('{"model_type": "test"}')
def _create_incomplete_model(model_dir: Path) -> None:
"""Create a model directory missing weight files."""
model_dir.mkdir(parents=True, exist_ok=True)
weight_map = {"layer.weight": "model.safetensors"}
index = {"metadata": {"total_size": 1024}, "weight_map": weight_map}
(model_dir / "model.safetensors.index.json").write_text(json.dumps(index))
# model.safetensors is missing
# ---------------------------------------------------------------------------
# resolve_existing_model
# ---------------------------------------------------------------------------
class TestResolveExistingModel:
def test_returns_none_when_no_dirs_have_model(self, tmp_path: Path) -> None:
writable = tmp_path / "writable"
writable.mkdir()
with (
patch("exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", ()),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (writable,)),
):
assert resolve_existing_model(MODEL_ID) is None
def test_finds_model_in_writable_dir(self, tmp_path: Path) -> None:
writable = tmp_path / "writable"
_create_complete_model(writable / NORMALIZED)
with (
patch("exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", ()),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (writable,)),
):
assert resolve_existing_model(MODEL_ID) == writable / NORMALIZED
def test_finds_model_in_read_only_dir(self, tmp_path: Path) -> None:
read_only = tmp_path / "readonly"
_create_complete_model(read_only / NORMALIZED)
writable = tmp_path / "writable"
writable.mkdir()
with (
patch(
"exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", (read_only,)
),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (writable,)),
):
assert resolve_existing_model(MODEL_ID) == read_only / NORMALIZED
def test_read_only_takes_priority_over_writable(self, tmp_path: Path) -> None:
read_only = tmp_path / "readonly"
_create_complete_model(read_only / NORMALIZED)
writable = tmp_path / "writable"
_create_complete_model(writable / NORMALIZED)
with (
patch(
"exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", (read_only,)
),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (writable,)),
):
result = resolve_existing_model(MODEL_ID)
assert result == read_only / NORMALIZED
def test_skips_incomplete_model(self, tmp_path: Path) -> None:
incomplete = tmp_path / "incomplete"
_create_incomplete_model(incomplete / NORMALIZED)
complete = tmp_path / "complete"
_create_complete_model(complete / NORMALIZED)
with (
patch(
"exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", (incomplete,)
),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (complete,)),
):
result = resolve_existing_model(MODEL_ID)
assert result == complete / NORMALIZED
def test_searches_multiple_read_only_dirs_in_order(self, tmp_path: Path) -> None:
ro1 = tmp_path / "ro1"
ro1.mkdir()
ro2 = tmp_path / "ro2"
_create_complete_model(ro2 / NORMALIZED)
writable = tmp_path / "writable"
writable.mkdir()
with (
patch("exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", (ro1, ro2)),
patch("exo.download.download_utils.EXO_MODELS_DIRS", (writable,)),
):
assert resolve_existing_model(MODEL_ID) == ro2 / NORMALIZED
# ---------------------------------------------------------------------------
# is_read_only_model_dir
# ---------------------------------------------------------------------------
class TestIsReadOnlyModelDir:
def test_path_under_read_only_dir(self, tmp_path: Path) -> None:
ro = tmp_path / "readonly"
with patch("exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", (ro,)):
assert is_read_only_model_dir(ro / NORMALIZED) is True
def test_path_under_writable_dir(self, tmp_path: Path) -> None:
writable = tmp_path / "writable"
with patch("exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", ()):
assert is_read_only_model_dir(writable / NORMALIZED) is False
def test_path_not_under_any_read_only_dir(self, tmp_path: Path) -> None:
ro = tmp_path / "readonly"
other = tmp_path / "other"
with patch("exo.download.download_utils.EXO_MODELS_READ_ONLY_DIRS", (ro,)):
assert is_read_only_model_dir(other / NORMALIZED) is False
# ---------------------------------------------------------------------------
# select_download_dir
# ---------------------------------------------------------------------------
class TestSelectDownloadDir:
def test_picks_first_dir_with_enough_space(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
# Both exist on same filesystem so both have space; first wins
with patch("exo.download.download_utils.EXO_MODELS_DIRS", (dir1, dir2)):
assert select_download_dir(1) == dir1
def test_skips_dir_without_enough_space(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
real_disk_usage = shutil.disk_usage
def mock_disk_usage(path: str | Path) -> object:
if Path(path).is_relative_to(dir1):
real = real_disk_usage(path)
return shutil._ntuple_diskusage(real.total, real.total, 0) # pyright: ignore[reportPrivateUsage]
return real_disk_usage(path)
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (dir1, dir2)),
patch("shutil.disk_usage", side_effect=mock_disk_usage),
):
assert select_download_dir(1024) == dir2
def test_raises_when_no_dir_has_space(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir1.mkdir()
real_disk_usage = shutil.disk_usage
def mock_disk_usage(path: str | Path) -> object:
real = real_disk_usage(path)
return shutil._ntuple_diskusage(real.total, real.total, 0) # pyright: ignore[reportPrivateUsage]
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (dir1,)),
patch("shutil.disk_usage", side_effect=mock_disk_usage),
pytest.raises(InsufficientDiskSpaceError),
):
select_download_dir(1024)
def test_skips_nonexistent_dir(self, tmp_path: Path) -> None:
nonexistent = tmp_path / "does-not-exist"
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (nonexistent,)),
pytest.raises(InsufficientDiskSpaceError),
):
select_download_dir(1)
def test_skips_dir_raising_oserror(self, tmp_path: Path) -> None:
dir1 = tmp_path / "unmounted"
dir2 = tmp_path / "ok"
dir1.mkdir()
dir2.mkdir()
real_disk_usage = shutil.disk_usage
def mock_disk_usage(path: str | Path) -> object:
if Path(path).is_relative_to(dir1):
raise OSError("device not mounted")
return real_disk_usage(path)
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (dir1, dir2)),
patch("shutil.disk_usage", side_effect=mock_disk_usage),
):
assert select_download_dir(1) == dir2
# ---------------------------------------------------------------------------
# delete_model
# ---------------------------------------------------------------------------
class TestDeleteModel:
@pytest.fixture
async def dirs(self, tmp_path: Path) -> AsyncIterator[tuple[Path, Path, Path]]:
writable1 = tmp_path / "w1"
writable2 = tmp_path / "w2"
default = tmp_path / "default"
await aios.makedirs(writable1, exist_ok=True)
await aios.makedirs(writable2, exist_ok=True)
await aios.makedirs(default, exist_ok=True)
with (
patch(
"exo.download.download_utils.EXO_MODELS_DIRS",
(writable1, writable2, default),
),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", default),
):
yield writable1, writable2, default
async def test_deletes_from_writable_dir(
self, dirs: tuple[Path, Path, Path]
) -> None:
w1, _, _ = dirs
model_dir = w1 / NORMALIZED
await aios.makedirs(model_dir, exist_ok=True)
async with aiofiles.open(model_dir / "weights.safetensors", "w") as f:
await f.write("data")
result = await delete_model(MODEL_ID)
assert result is True
assert not await aios.path.exists(model_dir)
async def test_deletes_from_multiple_writable_dirs(
self, dirs: tuple[Path, Path, Path]
) -> None:
w1, w2, _ = dirs
model_dir1 = w1 / NORMALIZED
model_dir2 = w2 / NORMALIZED
await aios.makedirs(model_dir1, exist_ok=True)
await aios.makedirs(model_dir2, exist_ok=True)
async with aiofiles.open(model_dir1 / "w.safetensors", "w") as f:
await f.write("data")
async with aiofiles.open(model_dir2 / "w.safetensors", "w") as f:
await f.write("data")
result = await delete_model(MODEL_ID)
assert result is True
assert not await aios.path.exists(model_dir1)
assert not await aios.path.exists(model_dir2)
async def test_cleans_cache_from_default_dir(
self, dirs: tuple[Path, Path, Path]
) -> None:
_, _, default = dirs
cache_dir = default / "caches" / NORMALIZED
await aios.makedirs(cache_dir, exist_ok=True)
async with aiofiles.open(cache_dir / "file_list.json", "w") as f:
await f.write("[]")
await delete_model(MODEL_ID)
assert not await aios.path.exists(cache_dir)
async def test_returns_false_when_model_not_found(
self, dirs: tuple[Path, Path, Path]
) -> None:
result = await delete_model(MODEL_ID)
assert result is False
+4 -1
View File
@@ -26,7 +26,10 @@ def model_id() -> ModelId:
async def temp_models_dir(tmp_path: Path) -> AsyncIterator[Path]:
models_dir = tmp_path / "models"
await aios.makedirs(models_dir, exist_ok=True)
with patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir):
with (
patch("exo.download.download_utils.EXO_MODELS_DIRS", (models_dir,)),
patch("exo.download.download_utils.EXO_DEFAULT_MODELS_DIR", models_dir),
):
yield models_dir
+1 -1
View File
@@ -186,7 +186,7 @@ async def test_re_download_after_delete_completes() -> None:
"Re-download after deletion should complete"
)
finally:
coordinator.shutdown()
await coordinator.shutdown()
coordinator_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await coordinator_task
+32 -5
View File
@@ -11,9 +11,9 @@ from loguru import logger
from pydantic import PositiveInt
import exo.routing.topics as topics
from exo.api.main import API
from exo.download.coordinator import DownloadCoordinator
from exo.download.impl_shard_downloader import exo_shard_downloader
from exo.master.api import API # TODO: should API be in master?
from exo.master.main import Master
from exo.routing.event_router import EventRouter
from exo.routing.router import Router, get_node_id_keypair
@@ -47,7 +47,11 @@ class Node:
keypair = get_node_id_keypair()
node_id = NodeId(keypair.to_node_id())
session_id = SessionId(master_node_id=node_id, election_clock=0)
router = Router.create(keypair)
router = Router.create(
keypair,
bootstrap_peers=args.bootstrap_peers,
listen_port=args.libp2p_port,
)
await router.register_topic(topics.GLOBAL_EVENTS)
await router.register_topic(topics.LOCAL_EVENTS)
await router.register_topic(topics.COMMANDS)
@@ -224,7 +228,7 @@ class Node:
)
if result.is_new_master:
if self.download_coordinator:
self.download_coordinator.shutdown()
await self.download_coordinator.shutdown()
self.download_coordinator = DownloadCoordinator(
self.node_id,
exo_shard_downloader(offline=self.offline),
@@ -236,7 +240,7 @@ class Node:
)
self._tg.start_soon(self.download_coordinator.run)
if self.worker:
self.worker.shutdown()
await self.worker.shutdown()
# TODO: add profiling etc to resource monitor
self.worker = Worker(
self.node_id,
@@ -264,12 +268,17 @@ def main():
mp.set_start_method("spawn", force=True)
# TODO: Refactor the current verbosity system
logger_setup(EXO_LOG, args.verbosity)
logger.info("Starting EXO")
logger.info(f"{'=' * 40}")
logger.info(f"Starting EXO | pid={os.getpid()}")
logger.info(f"{'=' * 40}")
logger.info(f"EXO_LIBP2P_NAMESPACE: {os.getenv('EXO_LIBP2P_NAMESPACE')}")
if args.offline:
logger.info("Running in OFFLINE mode — no internet checks, local models only")
if args.bootstrap_peers:
logger.info(f"Bootstrap peers: {args.bootstrap_peers}")
if args.no_batch:
os.environ["EXO_NO_BATCH"] = "1"
logger.info("Continuous batching disabled (--no-batch)")
@@ -306,6 +315,8 @@ class Args(CamelCaseModel):
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
no_batch: bool = False
fast_synch: bool | None = None # None = auto, True = force on, False = force off
bootstrap_peers: list[str] = []
libp2p_port: int
@classmethod
def parse(cls) -> Self:
@@ -363,6 +374,22 @@ class Args(CamelCaseModel):
action="store_true",
help="Disable continuous batching, use sequential generation",
)
parser.add_argument(
"--bootstrap-peers",
type=lambda s: [p for p in s.split(",") if p],
default=os.getenv("EXO_BOOTSTRAP_PEERS", "").split(",")
if os.getenv("EXO_BOOTSTRAP_PEERS")
else [],
dest="bootstrap_peers",
help="Comma-separated libp2p multiaddrs to dial on startup (env: EXO_BOOTSTRAP_PEERS)",
)
parser.add_argument(
"--libp2p-port",
type=int,
default=0,
dest="libp2p_port",
help="Fixed TCP port for libp2p to listen on (0 = OS-assigned).",
)
fast_synch_group = parser.add_mutually_exclusive_group()
fast_synch_group.add_argument(
"--fast-synch",
+14 -1
View File
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
import anyio
from loguru import logger
from exo.master.event_log import DiskEventLog
from exo.master.placement import (
add_instance_to_placements,
cancel_unnecessary_downloads,
@@ -14,7 +13,9 @@ from exo.master.placement import (
from exo.shared.apply import apply
from exo.shared.constants import EXO_EVENT_LOG_DIR, EXO_TRACING_ENABLED
from exo.shared.types.commands import (
AddCustomModelCard,
CreateInstance,
DeleteCustomModelCard,
DeleteInstance,
ForwarderCommand,
ForwarderDownloadCommand,
@@ -30,6 +31,8 @@ from exo.shared.types.commands import (
)
from exo.shared.types.common import CommandId, NodeId, SessionId, SystemId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
GlobalForwarderEvent,
IndexedEvent,
@@ -61,6 +64,7 @@ from exo.shared.types.tasks import (
)
from exo.shared.types.worker.instances import InstanceId
from exo.utils.channels import Receiver, Sender
from exo.utils.disk_event_log import DiskEventLog
from exo.utils.event_buffer import MultiSourceBuffer
from exo.utils.task_group import TaskGroup
@@ -294,6 +298,7 @@ class Master:
self.state.instances,
self.state.node_memory,
self.state.node_network,
download_status=self.state.downloads,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
@@ -344,6 +349,14 @@ class Master:
f"Finished command {command.finished_command_id} finished"
)
case AddCustomModelCard():
generated_events.append(
CustomModelCardAdded(model_card=command.model_card)
)
case DeleteCustomModelCard():
generated_events.append(
CustomModelCardDeleted(model_id=command.model_id)
)
case RequestEventLog():
# We should just be able to send everything, since other buffers will ignore old messages
# rate limit to 1000 at a time
+57 -4
View File
@@ -32,7 +32,10 @@ from exo.shared.types.memory import Memory
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,
DownloadFailed,
DownloadOngoing,
DownloadPending,
DownloadProgress,
)
from exo.shared.types.worker.instances import (
@@ -60,6 +63,45 @@ def add_instance_to_placements(
return {**current_instances, command.instance.instance_id: command.instance}
def _get_node_download_fraction(
node_id: NodeId,
model_id: ModelId,
download_status: Mapping[NodeId, Sequence[DownloadProgress]],
) -> float:
"""Return the download fraction (0.01.0) for a model on a given node."""
for progress in download_status.get(node_id, []):
if progress.shard_metadata.model_card.model_id != model_id:
continue
match progress:
case DownloadCompleted():
return 1.0
case DownloadOngoing():
total = progress.download_progress.total.in_bytes
return (
progress.download_progress.downloaded.in_bytes / total
if total > 0
else 0.0
)
case DownloadPending():
total = progress.total.in_bytes
return progress.downloaded.in_bytes / total if total > 0 else 0.0
case DownloadFailed():
return 0.0
return 0.0
def _cycle_download_score(
cycle: Cycle,
model_id: ModelId,
download_status: Mapping[NodeId, Sequence[DownloadProgress]],
) -> float:
"""Sum of download fractions across all nodes in a cycle."""
return sum(
_get_node_download_fraction(node_id, model_id, download_status)
for node_id in cycle
)
def place_instance(
command: PlaceInstance,
topology: Topology,
@@ -67,6 +109,7 @@ def place_instance(
node_memory: Mapping[NodeId, MemoryUsage],
node_network: Mapping[NodeId, NodeNetworkInfo],
required_nodes: set[NodeId] | None = None,
download_status: Mapping[NodeId, Sequence[DownloadProgress]] | None = None,
) -> dict[InstanceId, Instance]:
cycles = topology.get_cycles()
candidate_cycles = list(filter(lambda it: len(it) >= command.min_nodes, cycles))
@@ -130,11 +173,21 @@ def place_instance(
if any(topology.node_is_leaf(node_id) for node_id in cycle)
]
resolved_download_status = download_status or {}
candidate_cycles = (
cycles_with_leaf_nodes if cycles_with_leaf_nodes != [] else smallest_cycles
)
selected_cycle = max(
cycles_with_leaf_nodes if cycles_with_leaf_nodes != [] else smallest_cycles,
key=lambda cycle: sum(
(node_memory[node_id].ram_available for node_id in cycle),
start=Memory(),
candidate_cycles,
key=lambda cycle: (
_cycle_download_score(
cycle, command.model_card.model_id, resolved_download_status
),
sum(
(node_memory[node_id].ram_available for node_id in cycle),
start=Memory(),
),
),
)
+187 -1
View File
@@ -25,6 +25,12 @@ 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, TextGenerationTaskParams
from exo.shared.types.topology import Connection, SocketConnection
from exo.shared.types.worker.downloads import (
DownloadCompleted,
DownloadFailed,
DownloadOngoing,
DownloadProgressData,
)
from exo.shared.types.worker.instances import (
Instance,
InstanceId,
@@ -33,7 +39,7 @@ from exo.shared.types.worker.instances import (
MlxRingInstance,
)
from exo.shared.types.worker.runners import ShardAssignments
from exo.shared.types.worker.shards import Sharding
from exo.shared.types.worker.shards import PipelineShardMetadata, Sharding
@pytest.fixture
@@ -576,3 +582,183 @@ def test_get_transition_events_delete_instance_cancels_only_matching_tasks(
assert cancel_events[0].task_status == TaskStatus.Cancelled
assert len(delete_events) == 1
assert delete_events[0].instance_id == instance_id_a
def _make_shard_metadata(model_card: ModelCard) -> PipelineShardMetadata:
return PipelineShardMetadata(
model_card=model_card,
device_rank=0,
world_size=1,
start_layer=0,
end_layer=model_card.n_layers,
n_layers=model_card.n_layers,
)
def test_placement_prefers_cycle_with_downloaded_model(
model_card: ModelCard,
) -> None:
"""When two cycles are otherwise equal, prefer the one with the model already downloaded."""
topology = Topology()
model_card.storage_size = Memory.from_bytes(500)
node_a = NodeId()
node_b = NodeId()
node_memory = {
node_a: create_node_memory(1000),
node_b: create_node_memory(1000),
}
node_network = {
node_a: create_node_network(),
node_b: create_node_network(),
}
topology.add_node(node_a)
topology.add_node(node_b)
# No connections between them — two single-node cycles
shard_meta = _make_shard_metadata(model_card)
# node_b has the model fully downloaded, node_a does not
download_status = {
node_b: [
DownloadCompleted(
node_id=node_b,
shard_metadata=shard_meta,
total=model_card.storage_size,
),
],
}
cic = place_instance_command(model_card)
placements = place_instance(
cic, topology, {}, node_memory, node_network, download_status=download_status
)
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assert assigned_nodes == {node_b}
def test_placement_prefers_cycle_with_higher_download_progress(
model_card: ModelCard,
) -> None:
"""When two cycles are otherwise equal, prefer the one with more download progress."""
topology = Topology()
model_card.storage_size = Memory.from_bytes(1000)
node_a = NodeId()
node_b = NodeId()
node_memory = {
node_a: create_node_memory(1000),
node_b: create_node_memory(1000),
}
node_network = {
node_a: create_node_network(),
node_b: create_node_network(),
}
topology.add_node(node_a)
topology.add_node(node_b)
shard_meta = _make_shard_metadata(model_card)
# node_a: 30% downloaded, node_b: 80% downloaded
download_status = {
node_a: [
DownloadOngoing(
node_id=node_a,
shard_metadata=shard_meta,
download_progress=DownloadProgressData(
total=Memory.from_bytes(1000),
downloaded=Memory.from_bytes(300),
downloaded_this_session=Memory.from_bytes(300),
completed_files=0,
total_files=1,
speed=0.0,
eta_ms=0,
files={},
),
),
],
node_b: [
DownloadOngoing(
node_id=node_b,
shard_metadata=shard_meta,
download_progress=DownloadProgressData(
total=Memory.from_bytes(1000),
downloaded=Memory.from_bytes(800),
downloaded_this_session=Memory.from_bytes(800),
completed_files=0,
total_files=1,
speed=0.0,
eta_ms=0,
files={},
),
),
],
}
cic = place_instance_command(model_card)
placements = place_instance(
cic, topology, {}, node_memory, node_network, download_status=download_status
)
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assert assigned_nodes == {node_b}
def test_placement_does_not_prefer_cycle_with_failed_download(
model_card: ModelCard,
) -> None:
"""A failed download should count as 0% — not preferred over a node with no download history."""
topology = Topology()
model_card.storage_size = Memory.from_bytes(500)
node_a = NodeId()
node_b = NodeId()
# node_a has slightly more RAM so it would win on the RAM tiebreaker
node_memory = {
node_a: create_node_memory(1001),
node_b: create_node_memory(1000),
}
node_network = {
node_a: create_node_network(),
node_b: create_node_network(),
}
topology.add_node(node_a)
topology.add_node(node_b)
shard_meta = _make_shard_metadata(model_card)
# node_b has a failed download — should not be preferred
download_status = {
node_b: [
DownloadFailed(
node_id=node_b,
shard_metadata=shard_meta,
error_message="connection reset",
),
],
}
cic = place_instance_command(model_card)
placements = place_instance(
cic, topology, {}, node_memory, node_network, download_status=download_status
)
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
# node_a should win on RAM tiebreaker since failed download scores 0.0
assert assigned_nodes == {node_a}
+10 -2
View File
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from copy import copy
from itertools import count
from math import inf
@@ -102,8 +103,15 @@ class TopicRouter[T: CamelCaseModel]:
class Router:
@classmethod
def create(cls, identity: Keypair) -> "Router":
return cls(handle=NetworkingHandle(identity))
def create(
cls,
identity: Keypair,
bootstrap_peers: Sequence[str] = (),
listen_port: int = 0,
) -> "Router":
return cls(
handle=NetworkingHandle(identity, list(bootstrap_peers), listen_port)
)
def __init__(self, handle: NetworkingHandle):
self.topic_routers: dict[str, TopicRouter[CamelCaseModel]] = {}
+11 -1
View File
@@ -7,6 +7,8 @@ from loguru import logger
from exo.shared.types.common import NodeId
from exo.shared.types.events import (
ChunkGenerated,
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
IndexedEvent,
InputChunkReceived,
@@ -65,6 +67,8 @@ def event_apply(event: Event, state: State) -> State:
| InputChunkReceived()
| TracesCollected()
| TracesMerged()
| CustomModelCardAdded()
| CustomModelCardDeleted()
): # Pass-through events that don't modify state
return state
case InstanceCreated():
@@ -115,7 +119,13 @@ def apply_node_download_progress(event: NodeDownloadProgress, state: State) -> S
replaced = False
for i, existing_dp in enumerate(current):
if existing_dp.shard_metadata == dp.shard_metadata:
# TODO(ciaran): deduplicate by model_id for now. Will need to use
# shard_metadata again when pipeline and tensor downloads differ.
# For now this is fine
if (
existing_dp.shard_metadata.model_card.model_id
== dp.shard_metadata.model_card.model_id
):
current[i] = dp
replaced = True
break
+26 -12
View File
@@ -26,21 +26,35 @@ EXO_CONFIG_HOME = _get_xdg_dir("XDG_CONFIG_HOME", ".config")
EXO_DATA_HOME = _get_xdg_dir("XDG_DATA_HOME", ".local/share")
EXO_CACHE_HOME = _get_xdg_dir("XDG_CACHE_HOME", ".cache")
# Models directory (data)
_EXO_MODELS_DIR_ENV = os.environ.get("EXO_MODELS_DIR", None)
EXO_MODELS_DIR = (
EXO_DATA_HOME / "models"
if _EXO_MODELS_DIR_ENV is None
else Path.home() / _EXO_MODELS_DIR_ENV
# Default models directory (always included as first entry in writable dirs)
_EXO_DEFAULT_MODELS_DIR_ENV = os.environ.get("EXO_DEFAULT_MODELS_DIR", None)
EXO_DEFAULT_MODELS_DIR = (
Path(_EXO_DEFAULT_MODELS_DIR_ENV).expanduser()
if _EXO_DEFAULT_MODELS_DIR_ENV is not None
else EXO_DATA_HOME / "models"
)
# Read-only search path for pre-downloaded models (colon-separated directories)
_EXO_MODELS_PATH_ENV = os.environ.get("EXO_MODELS_PATH", None)
EXO_MODELS_PATH: tuple[Path, ...] | None = (
tuple(Path(p).expanduser() for p in _EXO_MODELS_PATH_ENV.split(":") if p)
if _EXO_MODELS_PATH_ENV is not None
else None
def _parse_colon_dirs(env_var: str) -> tuple[Path, ...]:
raw = os.environ.get(env_var, None)
if raw is None:
return ()
return tuple(Path(p).expanduser() for p in raw.split(":") if p)
# Read-only model directories (colon-separated). Never written to or deleted from.
_EXO_MODELS_READ_ONLY_DIRS_ENV = _parse_colon_dirs("EXO_MODELS_READ_ONLY_DIRS")
# Writable model directories (colon-separated). Default dir is always prepended.
_EXO_MODELS_DIRS_ENV = _parse_colon_dirs("EXO_MODELS_DIRS")
# If a directory appears in both lists, treat it as read-only.
_read_only_set = frozenset(_EXO_MODELS_READ_ONLY_DIRS_ENV)
EXO_MODELS_DIRS: tuple[Path, ...] = tuple(
d
for d in (EXO_DEFAULT_MODELS_DIR, *_EXO_MODELS_DIRS_ENV)
if d not in _read_only_set
)
EXO_MODELS_READ_ONLY_DIRS: tuple[Path, ...] = _EXO_MODELS_READ_ONLY_DIRS_ENV
_RESOURCES_DIR_ENV = os.environ.get("EXO_RESOURCES_DIR", None)
RESOURCES_DIR = (
+2 -2
View File
@@ -66,7 +66,7 @@ def logger_setup(log_file: Path | None, verbosity: int = 0):
else:
logger.add(
sys.__stderr__, # type: ignore
format="[ {time:HH:mm:ss.SSS} | <level>{level: <8}</level> | {name}:{function}:{line} ] <level>{message}</level>",
format="[ {time:YYYY-MM-DD HH:mm:ss.SSS} | <level>{level: <8}</level> | {name}:{function}:{line} ] <level>{message}</level>",
level="DEBUG",
colorize=True,
enqueue=True,
@@ -76,7 +76,7 @@ def logger_setup(log_file: Path | None, verbosity: int = 0):
logger.add(
log_file,
format="[ {time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} ] {message}",
level="INFO",
level="DEBUG" if verbosity > 0 else "INFO",
colorize=False,
enqueue=True,
rotation=lambda _, __: next(rotate_once),
+45 -34
View File
@@ -30,30 +30,42 @@ from exo.utils.pydantic_ext import CamelCaseModel
# kinda ugly...
# TODO: load search path from config.toml
_custom_cards_dir = Path(str(EXO_CUSTOM_MODEL_CARDS_DIR))
CARD_SEARCH_PATH = [
_BUILTIN_CARD_DIRS = [
Path(RESOURCES_DIR) / "inference_model_cards",
Path(RESOURCES_DIR) / "image_model_cards",
_custom_cards_dir,
]
_card_cache: dict[ModelId, "ModelCard"] = {}
async def _refresh_card_cache():
for path in CARD_SEARCH_PATH:
async for toml_file in path.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
if card.model_id not in _card_cache:
_card_cache[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
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()
@@ -92,6 +104,7 @@ class ModelCard(CamelCaseModel):
capabilities: list[str] = []
uses_cfg: bool = False
trust_remote_code: bool = True
is_custom: bool = False
@field_validator("tasks", mode="before")
@classmethod
@@ -100,7 +113,7 @@ class ModelCard(CamelCaseModel):
async def save(self, path: Path) -> None:
async with await open_file(path, "w") as f:
py = self.model_dump(exclude_none=True)
py = self.model_dump(exclude_none=True, exclude={"is_custom"})
data = tomlkit.dumps(py) # pyright: ignore[reportUnknownMemberType]
await f.write(data)
@@ -122,17 +135,24 @@ class ModelCard(CamelCaseModel):
if (mc := _card_cache.get(model_id)) is not None:
return mc
return await ModelCard.fetch_from_hf(model_id)
mc = await ModelCard.fetch_from_hf(model_id)
await mc.save_to_custom_dir()
_card_cache[model_id] = mc
return mc
@staticmethod
async def fetch_from_hf(model_id: ModelId) -> "ModelCard":
"""Fetches storage size and number of layers for a Hugging Face model, returns Pydantic ModelMeta."""
"""Fetches storage size and number of layers for a Hugging Face model, returns Pydantic ModelMeta.
This is a pure fetch it does NOT save to disk or update the cache.
Persistence is handled by the event-sourcing layer (worker event handler).
"""
# TODO: failure if files do not exist
config_data = await fetch_config_data(model_id)
num_layers = config_data.layer_count
mem_size_bytes = await fetch_safetensors_size(model_id)
mc = ModelCard(
return ModelCard(
model_id=ModelId(model_id),
storage_size=mem_size_bytes,
n_layers=num_layers,
@@ -141,10 +161,13 @@ class ModelCard(CamelCaseModel):
num_key_value_heads=config_data.num_key_value_heads,
tasks=[ModelTask.TextGeneration],
trust_remote_code=False,
is_custom=True,
)
await mc.save_to_custom_dir()
_card_cache[model_id] = mc
return mc
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:
@@ -157,16 +180,6 @@ async def delete_custom_card(model_id: ModelId) -> bool:
return False
def is_custom_card(model_id: ModelId) -> bool:
"""Check if a model card exists in the custom cards directory."""
import os
card_path = Path(str(EXO_CUSTOM_MODEL_CARDS_DIR)) / (
ModelId(model_id).normalize() + ".toml"
)
return os.path.isfile(str(card_path))
class ConfigData(BaseModel):
model_config = {"extra": "ignore"} # Allow unknown fields
@@ -230,11 +243,10 @@ async def fetch_config_data(model_id: ModelId) -> ConfigData:
"""Downloads and parses config.json for a model."""
from exo.download.download_utils import (
download_file_with_retry,
ensure_models_dir,
resolve_model_dir,
)
target_dir = (await ensure_models_dir()) / model_id.normalize()
await aios.makedirs(target_dir, exist_ok=True)
target_dir = await resolve_model_dir(model_id)
config_path = await download_file_with_retry(
model_id,
"main",
@@ -252,12 +264,11 @@ async def fetch_safetensors_size(model_id: ModelId) -> Memory:
"""Gets model size from safetensors index or falls back to HF API."""
from exo.download.download_utils import (
download_file_with_retry,
ensure_models_dir,
resolve_model_dir,
)
from exo.shared.types.worker.downloads import ModelSafetensorsIndex
target_dir = (await ensure_models_dir()) / model_id.normalize()
await aios.makedirs(target_dir, exist_ok=True)
target_dir = await resolve_model_dir(model_id)
index_path = await download_file_with_retry(
model_id,
"main",
+106 -4
View File
@@ -105,9 +105,9 @@ def test_node_id_in_config_dir():
def test_models_in_data_dir():
"""Test that models directory is in the data directory."""
# Clear EXO_MODELS_DIR to test default behavior
env = {k: v for k, v in os.environ.items() if k != "EXO_MODELS_DIR"}
"""Test that default models directory is in the data directory."""
# Clear EXO_MODELS_DIRS to test default behavior
env = {k: v for k, v in os.environ.items() if k != "EXO_MODELS_DIRS"}
with mock.patch.dict(os.environ, env, clear=True):
import importlib
@@ -115,4 +115,106 @@ def test_models_in_data_dir():
importlib.reload(constants)
assert constants.EXO_MODELS_DIR.parent == constants.EXO_DATA_HOME
assert constants.EXO_DEFAULT_MODELS_DIR.parent == constants.EXO_DATA_HOME
def test_default_dir_always_prepended_to_models_dirs():
"""Test that the default models dir is always the first entry in EXO_MODELS_DIRS."""
env = {
k: v
for k, v in os.environ.items()
if k not in ("EXO_MODELS_DIRS", "EXO_MODELS_READ_ONLY_DIRS", "EXO_HOME")
}
env["EXO_MODELS_DIRS"] = "/tmp/custom-models"
with mock.patch.dict(os.environ, env, clear=True):
import importlib
import exo.shared.constants as constants
importlib.reload(constants)
assert constants.EXO_MODELS_DIRS[0] == constants.EXO_DEFAULT_MODELS_DIR
assert Path("/tmp/custom-models") in constants.EXO_MODELS_DIRS
def test_default_models_dir_override():
"""Test that EXO_DEFAULT_MODELS_DIR can be overridden via env var."""
env = {
k: v
for k, v in os.environ.items()
if k
not in (
"EXO_MODELS_DIRS",
"EXO_MODELS_READ_ONLY_DIRS",
"EXO_HOME",
"EXO_DEFAULT_MODELS_DIR",
)
}
env["EXO_DEFAULT_MODELS_DIR"] = "/Volumes/FastSSD/exo-models"
with mock.patch.dict(os.environ, env, clear=True):
import importlib
import exo.shared.constants as constants
importlib.reload(constants)
assert Path("/Volumes/FastSSD/exo-models") == constants.EXO_DEFAULT_MODELS_DIR
assert constants.EXO_MODELS_DIRS[0] == constants.EXO_DEFAULT_MODELS_DIR
def test_default_dir_only_entry_when_env_unset():
"""Test that EXO_MODELS_DIRS contains only the default when env var is not set."""
env = {
k: v
for k, v in os.environ.items()
if k not in ("EXO_MODELS_DIRS", "EXO_MODELS_READ_ONLY_DIRS", "EXO_HOME")
}
with mock.patch.dict(os.environ, env, clear=True):
import importlib
import exo.shared.constants as constants
importlib.reload(constants)
assert constants.EXO_MODELS_DIRS == (constants.EXO_DEFAULT_MODELS_DIR,)
def test_overlap_between_dirs_and_read_only_dirs():
"""Test that a directory in both lists is excluded from writable dirs."""
env = {
k: v
for k, v in os.environ.items()
if k not in ("EXO_MODELS_DIRS", "EXO_MODELS_READ_ONLY_DIRS", "EXO_HOME")
}
env["EXO_MODELS_DIRS"] = "/tmp/shared:/tmp/writable-only"
env["EXO_MODELS_READ_ONLY_DIRS"] = "/tmp/shared:/tmp/ro-only"
with mock.patch.dict(os.environ, env, clear=True):
import importlib
import exo.shared.constants as constants
importlib.reload(constants)
# /tmp/shared should be excluded from writable dirs
assert Path("/tmp/shared") not in constants.EXO_MODELS_DIRS
assert Path("/tmp/writable-only") in constants.EXO_MODELS_DIRS
# /tmp/shared should still be in read-only dirs
assert Path("/tmp/shared") in constants.EXO_MODELS_READ_ONLY_DIRS
assert Path("/tmp/ro-only") in constants.EXO_MODELS_READ_ONLY_DIRS
def test_empty_read_only_dirs_when_unset():
"""Test that EXO_MODELS_READ_ONLY_DIRS is empty when env var is not set."""
env = {
k: v
for k, v in os.environ.items()
if k not in ("EXO_MODELS_DIRS", "EXO_MODELS_READ_ONLY_DIRS", "EXO_HOME")
}
with mock.patch.dict(os.environ, env, clear=True):
import importlib
import exo.shared.constants as constants
importlib.reload(constants)
assert constants.EXO_MODELS_READ_ONLY_DIRS == ()
+4 -4
View File
@@ -1,18 +1,18 @@
from collections.abc import Generator
from typing import Any, Literal
from exo.shared.models.model_cards import ModelId
from exo.shared.types.api import (
from exo.api.types import (
FinishReason,
GenerationStats,
ImageGenerationStats,
ToolCallItem,
TopLogprobItem,
Usage,
)
from exo.shared.models.model_cards import ModelId
from exo.utils.pydantic_ext import TaggedModel
from .api import FinishReason
from .common import CommandId
from .worker.runner_response import ToolCallItem
class BaseChunk(TaggedModel):
+12 -2
View File
@@ -1,10 +1,10 @@
from pydantic import Field
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.api import (
from exo.api.types import (
ImageEditsTaskParams,
ImageGenerationTaskParams,
)
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.chunks import InputImageChunk
from exo.shared.types.common import CommandId, NodeId, SystemId
from exo.shared.types.text_generation import TextGenerationTaskParams
@@ -81,6 +81,14 @@ class CancelDownload(BaseCommand):
model_id: ModelId
class AddCustomModelCard(BaseCommand):
model_card: ModelCard
class DeleteCustomModelCard(BaseCommand):
model_id: ModelId
DownloadCommand = StartDownload | DeleteDownload | CancelDownload
@@ -96,6 +104,8 @@ Command = (
| TaskCancelled
| TaskFinished
| SendInputChunk
| AddCustomModelCard
| DeleteCustomModelCard
)
+12 -1
View File
@@ -3,9 +3,10 @@ from typing import final
from pydantic import Field
from exo.shared.models.model_cards import ModelCard
from exo.shared.topology import Connection
from exo.shared.types.chunks import GenerationChunk, InputImageChunk
from exo.shared.types.common import CommandId, Id, NodeId, SessionId, SystemId
from exo.shared.types.common import CommandId, Id, ModelId, NodeId, SessionId, SystemId
from exo.shared.types.tasks import Task, TaskId, TaskStatus
from exo.shared.types.worker.downloads import DownloadProgress
from exo.shared.types.worker.instances import Instance, InstanceId
@@ -106,6 +107,14 @@ class TopologyEdgeDeleted(BaseEvent):
conn: Connection
class CustomModelCardAdded(BaseEvent):
model_card: ModelCard
class CustomModelCardDeleted(BaseEvent):
model_id: ModelId
@final
class TraceEventData(FrozenModel):
name: str
@@ -147,6 +156,8 @@ Event = (
| TopologyEdgeDeleted
| TracesCollected
| TracesMerged
| CustomModelCardAdded
| CustomModelCardDeleted
)
+1 -1
View File
@@ -2,7 +2,7 @@ from enum import Enum
from pydantic import Field
from exo.shared.types.api import (
from exo.api.types import (
ImageEditsTaskParams,
ImageGenerationTaskParams,
)
@@ -1,7 +1,7 @@
from collections.abc import Generator
from typing import Any, Literal
from exo.shared.types.api import (
from exo.api.types import (
FinishReason,
GenerationStats,
ImageGenerationStats,
+111 -79
View File
@@ -10,11 +10,10 @@ from typing import Self, cast
import anyio
from anyio import fail_after, open_process, to_thread
from anyio.streams.buffered import BufferedByteReceiveStream
from anyio.streams.text import TextReceiveStream
from loguru import logger
from pydantic import ValidationError
from exo.shared.constants import EXO_CONFIG_FILE, EXO_MODELS_DIR
from exo.shared.constants import EXO_CONFIG_FILE, EXO_DEFAULT_MODELS_DIR
from exo.shared.types.memory import Memory
from exo.shared.types.profiling import (
DiskUsage,
@@ -288,7 +287,7 @@ class ThunderboltBridgeInfo(TaggedModel):
)
)
except Exception as e:
logger.warning(f"Failed to gather Thunderbolt Bridge info: {e}")
logger.opt(exception=e).warning("Failed to gather Thunderbolt Bridge info")
return None
@@ -329,7 +328,7 @@ class NodeDiskUsage(TaggedModel):
async def gather(cls) -> Self:
return cls(
disk_usage=await to_thread.run_sync(
lambda: DiskUsage.from_path(EXO_MODELS_DIR)
DiskUsage.from_path, EXO_DEFAULT_MODELS_DIR
)
)
@@ -372,36 +371,58 @@ GatheredInfo = (
@dataclass
class InfoGatherer:
info_sender: Sender[GatheredInfo]
interface_watcher_interval: float | None = 10
misc_poll_interval: float | None = 60
system_profiler_interval: float | None = 5 if IS_DARWIN else None
memory_poll_rate: float | None = None if IS_DARWIN else 1
macmon_interval: float | None = 1 if IS_DARWIN else None
thunderbolt_bridge_poll_interval: float | None = 10 if IS_DARWIN else None
static_info_poll_interval: float | None = 60
rdma_ctl_poll_interval: float | None = 10 if IS_DARWIN else None
disk_poll_interval: float | None = 30
_tg: TaskGroup = field(init=False, default_factory=TaskGroup)
_psutil_enabled: bool = field(init=False, default=False)
async def _can_read_macmon_metrics(self, macmon_path: str) -> bool:
try:
with fail_after(5):
proc = await anyio.run_process(
[macmon_path, "pipe", "--samples", "1", "--interval", "100"],
check=False,
)
except Exception as e:
logger.opt(exception=e).warning(
f"Failed to validate macmon at {macmon_path}"
)
return False
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
logger.warning(
f"macmon preflight failed with return code {proc.returncode}: "
f"{stderr or 'no stderr'}"
)
return False
stdout = proc.stdout.decode("utf-8", errors="replace").strip()
if not stdout:
logger.warning("macmon preflight returned no metrics")
return False
try:
MacmonMetrics.from_raw_json(stdout.splitlines()[0])
except ValidationError as e:
logger.opt(exception=e).warning(
"macmon preflight returned unexpected metrics JSON"
)
return False
return True
async def run(self):
async with self._tg as tg:
if IS_DARWIN:
if (macmon_path := shutil.which("macmon")) is not None:
tg.start_soon(self._monitor_macmon, macmon_path)
else:
# macmon not installed — fall back to psutil for memory
logger.warning(
"macmon not found, falling back to psutil for memory monitoring"
)
self.memory_poll_rate = 1
tg.start_soon(self._monitor_system_profiler_thunderbolt_data)
tg.start_soon(self._monitor_thunderbolt_bridge_status)
tg.start_soon(self._monitor_rdma_ctl_status)
tg.start_soon(self._watch_system_info)
tg.start_soon(self._monitor_memory_usage)
tg.start_soon(self._monitor_misc)
tg.start_soon(self._monitor_static_info)
tg.start_soon(self._monitor_disk_usage)
tg.start_soon(self._monitor_macmon, 1)
tg.start_soon(self._monitor_system_profiler_thunderbolt_data, 5)
tg.start_soon(self._monitor_thunderbolt_bridge_status, 10)
tg.start_soon(self._monitor_rdma_ctl_status, 10)
if not IS_DARWIN:
tg.start_soon(self._monitor_memory_usage, 1)
tg.start_soon(self._watch_system_info, 10)
tg.start_soon(self._monitor_misc, 60)
tg.start_soon(self._monitor_static_info, 60)
tg.start_soon(self._monitor_disk_usage, 30)
nc = await NodeConfig.gather()
if nc is not None:
@@ -410,32 +431,27 @@ class InfoGatherer:
def shutdown(self):
self._tg.cancel_tasks()
async def _monitor_static_info(self):
if self.static_info_poll_interval is None:
return
async def _monitor_static_info(self, static_info_poll_interval: float):
while True:
try:
with fail_after(30):
await self.info_sender.send(await StaticNodeInformation.gather())
except Exception as e:
logger.warning(f"Error gathering static node info: {e}")
await anyio.sleep(self.static_info_poll_interval)
logger.opt(exception=e).warning("Error gathering static node info")
await anyio.sleep(static_info_poll_interval)
async def _monitor_misc(self):
if self.misc_poll_interval is None:
return
async def _monitor_misc(self, misc_poll_interval: float):
while True:
try:
with fail_after(10):
await self.info_sender.send(await MiscData.gather())
except Exception as e:
logger.warning(f"Error gathering misc data: {e}")
await anyio.sleep(self.misc_poll_interval)
async def _monitor_system_profiler_thunderbolt_data(self):
if self.system_profiler_interval is None:
return
logger.opt(exception=e).warning("Error gathering misc data")
await anyio.sleep(misc_poll_interval)
async def _monitor_system_profiler_thunderbolt_data(
self, system_profiler_interval: float
):
while True:
try:
with fail_after(30):
@@ -456,42 +472,41 @@ class InfoGatherer:
conns = [it for i in data if (it := i.conn()) is not None]
await self.info_sender.send(MacThunderboltConnections(conns=conns))
except Exception as e:
logger.warning(f"Error gathering Thunderbolt data: {e}")
await anyio.sleep(self.system_profiler_interval)
logger.opt(exception=e).warning("Error gathering Thunderbolt data")
await anyio.sleep(system_profiler_interval)
async def _monitor_memory_usage(self):
async def _monitor_memory_usage(self, memory_poll_rate: float):
if self._psutil_enabled:
return
self._psutil_enabled = True
override_memory_env = os.getenv("OVERRIDE_MEMORY_MB")
override_memory: int | None = (
Memory.from_mb(int(override_memory_env)).in_bytes
if override_memory_env
else None
)
if self.memory_poll_rate is None:
return
while True:
try:
await self.info_sender.send(
MemoryUsage.from_psutil(override_memory=override_memory)
)
except Exception as e:
logger.warning(f"Error gathering memory usage: {e}")
await anyio.sleep(self.memory_poll_rate)
logger.opt(exception=e).warning("Error gathering memory usage")
await anyio.sleep(memory_poll_rate)
async def _watch_system_info(self):
if self.interface_watcher_interval is None:
return
async def _watch_system_info(self, interface_watcher_interval: float):
while True:
try:
with fail_after(10):
nics = await get_network_interfaces()
await self.info_sender.send(NodeNetworkInterfaces(ifaces=nics))
except Exception as e:
logger.warning(f"Error gathering network interfaces: {e}")
await anyio.sleep(self.interface_watcher_interval)
logger.opt(exception=e).warning("Error gathering network interfaces")
await anyio.sleep(interface_watcher_interval)
async def _monitor_thunderbolt_bridge_status(self):
if self.thunderbolt_bridge_poll_interval is None:
return
async def _monitor_thunderbolt_bridge_status(
self, thunderbolt_bridge_poll_interval: float
):
while True:
try:
with fail_after(30):
@@ -499,39 +514,49 @@ class InfoGatherer:
if curr is not None:
await self.info_sender.send(curr)
except Exception as e:
logger.warning(f"Error gathering Thunderbolt Bridge status: {e}")
await anyio.sleep(self.thunderbolt_bridge_poll_interval)
logger.opt(exception=e).warning(
"Error gathering Thunderbolt Bridge status"
)
await anyio.sleep(thunderbolt_bridge_poll_interval)
async def _monitor_rdma_ctl_status(self):
if self.rdma_ctl_poll_interval is None:
return
async def _monitor_rdma_ctl_status(self, rdma_ctl_poll_interval: float):
while True:
try:
curr = await RdmaCtlStatus.gather()
if curr is not None:
await self.info_sender.send(curr)
except Exception as e:
logger.warning(f"Error gathering RDMA ctl status: {e}")
await anyio.sleep(self.rdma_ctl_poll_interval)
logger.opt(exception=e).warning("Error gathering RDMA ctl status")
await anyio.sleep(rdma_ctl_poll_interval)
async def _monitor_disk_usage(self):
if self.disk_poll_interval is None:
return
async def _monitor_disk_usage(self, disk_poll_interval: float):
while True:
try:
with fail_after(5):
await self.info_sender.send(await NodeDiskUsage.gather())
except Exception as e:
logger.warning(f"Error gathering disk usage: {e}")
await anyio.sleep(self.disk_poll_interval)
logger.opt(exception=e).warning("Error gathering disk usage")
await anyio.sleep(disk_poll_interval)
async def _monitor_macmon(self, macmon_path: str):
if self.macmon_interval is None:
async def _monitor_macmon(self, macmon_interval: float):
if (
macmon_path := os.getenv("EXO_MACMON_PATH") or shutil.which("macmon")
) is None:
logger.warning(
"macmon not found, falling back to psutil for memory monitoring"
)
self._tg.start_soon(self._monitor_memory_usage, 1)
return
if not await self._can_read_macmon_metrics(macmon_path):
logger.warning(
f"macmon at {macmon_path} is unusable, falling back to psutil memory monitoring"
)
self._tg.start_soon(self._monitor_memory_usage, 1)
return
# macmon pipe --interval [interval in ms]
# Timeout: if macmon produces no output for this many seconds, restart it.
# macmon writes every macmon_interval seconds, so 10x that is generous.
read_timeout = max(self.macmon_interval * 10, 30)
read_timeout = max(macmon_interval * 10, 30)
while True:
try:
async with await open_process(
@@ -539,21 +564,26 @@ class InfoGatherer:
macmon_path,
"pipe",
"--interval",
str(self.macmon_interval * 1000),
str(macmon_interval * 1000),
]
) as p:
if not p.stdout:
logger.critical("MacMon closed stdout")
return
stream = TextReceiveStream(BufferedByteReceiveStream(p.stdout))
stream = BufferedByteReceiveStream(p.stdout)
while True:
with fail_after(read_timeout):
text = await stream.receive()
await self.info_sender.send(MacmonMetrics.from_raw_json(text))
data = await stream.receive_until(
delimiter=b"\n", max_bytes=8 * 1024
)
text = data.decode("utf-8", errors="replace").strip()
metrics = MacmonMetrics.from_raw_json(text)
await self.info_sender.send(metrics)
except TimeoutError:
logger.warning(
f"MacMon produced no output for {read_timeout}s, restarting"
)
self._tg.start_soon(self._monitor_memory_usage, 1)
except CalledProcessError as e:
stderr_msg = "no stderr"
stderr_output = cast(bytes | str | None, e.stderr)
@@ -566,6 +596,8 @@ class InfoGatherer:
logger.warning(
f"MacMon failed with return code {e.returncode}: {stderr_msg}"
)
self._tg.start_soon(self._monitor_memory_usage, 1)
except Exception as e:
logger.warning(f"Error in macmon monitor: {e}")
await anyio.sleep(self.macmon_interval)
logger.opt(exception=e).warning("Error in macmon monitor")
self._tg.start_soon(self._monitor_memory_usage, 1)
await anyio.sleep(macmon_interval)
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import final
import anyio
from exo.shared.types.api import NodePowerStats, PowerUsage
from exo.api.types import NodePowerStats, PowerUsage
from exo.shared.types.common import NodeId
from exo.shared.types.profiling import SystemPerformanceProfile
@@ -2,8 +2,8 @@ from pathlib import Path
import pytest
from exo.master.event_log import DiskEventLog
from exo.shared.types.events import TestEvent
from exo.utils.disk_event_log import DiskEventLog
@pytest.fixture
+1 -1
View File
@@ -3,7 +3,7 @@ from collections.abc import Mapping
import anyio
import pytest
from exo.shared.types.api import PowerUsage
from exo.api.types import PowerUsage
from exo.shared.types.common import NodeId
from exo.shared.types.profiling import SystemPerformanceProfile
from exo.utils.power_sampler import PowerSampler
@@ -1,4 +1,4 @@
from collections.abc import Generator
from collections.abc import Callable, Generator
from pathlib import Path
from typing import Any, Literal, Optional
@@ -6,8 +6,8 @@ import mlx.core as mx
from mflux.models.common.config.config import Config
from PIL import Image
from exo.api.types import AdvancedImageParams
from exo.download.download_utils import build_model_path
from exo.shared.types.api import AdvancedImageParams
from exo.shared.types.worker.instances import BoundInstance
from exo.shared.types.worker.shards import CfgShardMetadata, PipelineShardMetadata
from exo.worker.engines.image.config import ImageModelConfig
@@ -116,6 +116,7 @@ class DistributedImageModel:
image_path: Path | None = None,
partial_images: int = 0,
advanced_params: AdvancedImageParams | None = None,
cancel_checker: Callable[[], bool] | None = None,
) -> Generator[Image.Image | tuple[Image.Image, int, int], None, None]:
if (
advanced_params is not None
@@ -163,6 +164,7 @@ class DistributedImageModel:
guidance_override=guidance_override,
negative_prompt=negative_prompt,
num_sync_steps=num_sync_steps,
cancel_checker=cancel_checker,
):
if isinstance(result, tuple):
# Partial image: (GeneratedImage, partial_index, total_partials)
+4 -1
View File
@@ -3,13 +3,14 @@ import io
import random
import tempfile
import time
from collections.abc import Callable
from pathlib import Path
from typing import Generator, Literal
import mlx.core as mx
from PIL import Image
from exo.shared.types.api import (
from exo.api.types import (
AdvancedImageParams,
ImageEditsTaskParams,
ImageGenerationStats,
@@ -69,6 +70,7 @@ def warmup_image_generator(model: DistributedImageModel) -> Image.Image | None:
def generate_image(
model: DistributedImageModel,
task: ImageGenerationTaskParams | ImageEditsTaskParams,
cancel_checker: Callable[[], bool] | None = None,
) -> Generator[ImageGenerationResponse | PartialImageResponse, None, None]:
"""Generate image(s), optionally yielding partial results.
@@ -127,6 +129,7 @@ def generate_image(
image_path=image_path,
partial_images=partial_images,
advanced_params=advanced_params,
cancel_checker=cancel_checker,
):
if isinstance(result, tuple):
# Partial image: (Image, partial_index, total_partials)
@@ -56,10 +56,10 @@ class QwenJointBlockWrapper(JointBlockWrapper[QwenTransformerBlock]):
attn = self.block.attn
img_mod_params = self.block.img_mod_linear(
self.block.img_mod_silu(text_embeddings) # pyright: ignore[reportUnknownArgumentType]
self.block.img_mod_silu(text_embeddings)
)
txt_mod_params = self.block.txt_mod_linear(
self.block.txt_mod_silu(text_embeddings) # pyright: ignore[reportUnknownArgumentType]
self.block.txt_mod_silu(text_embeddings)
)
img_mod1, img_mod2 = mx.split(img_mod_params, 2, axis=-1)
+92 -65
View File
@@ -1,4 +1,4 @@
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from math import ceil
from typing import Any, Optional, final
@@ -100,6 +100,8 @@ class DiffusionRunner:
self.total_layers = config.total_blocks
self._guidance_override: float | None = None
self._cancel_checker: Callable[[], bool] | None = None
self._cancelling: bool = False
self._compute_assigned_blocks()
@@ -240,6 +242,43 @@ class DiffusionRunner:
def is_distributed(self) -> bool:
return self.group is not None
def _is_sentinel(self, tensor: mx.array) -> bool:
return bool(mx.all(mx.isnan(tensor)).item())
def _check_cancellation(self) -> None:
if self._cancelling:
return
if (
self.is_first_stage
and self._cancel_checker is not None
and self._cancel_checker()
):
self._cancelling = True
def _send(self, data: mx.array, dst: int) -> mx.array:
assert self.group is not None
if self._cancelling:
data = mx.full(data.shape, float("nan"), dtype=data.dtype)
return mx.distributed.send(data, dst, group=self.group)
def _recv_and_check(self, result: mx.array) -> mx.array:
mx.eval(result)
if self._is_sentinel(result):
self._cancelling = True
return result
def _recv(self, shape: tuple[int, ...], dtype: mx.Dtype, src: int) -> mx.array:
assert self.group is not None
return self._recv_and_check(
mx.distributed.recv(shape, dtype, src, group=self.group)
)
def _recv_like(self, template: mx.array, src: int) -> mx.array:
assert self.group is not None
return self._recv_and_check(
mx.distributed.recv_like(template, src, group=self.group)
)
def _get_effective_guidance_scale(self) -> float | None:
if self._guidance_override is not None:
return self._guidance_override
@@ -313,19 +352,13 @@ class DiffusionRunner:
assert self.cfg_peer_rank is not None
if is_positive:
noise = mx.distributed.send(noise, self.cfg_peer_rank, group=self.group)
noise = self._send(noise, self.cfg_peer_rank)
mx.async_eval(noise)
noise_neg = mx.distributed.recv_like(
noise, self.cfg_peer_rank, group=self.group
)
mx.eval(noise_neg)
noise_neg = self._recv_like(noise, src=self.cfg_peer_rank)
noise_pos = noise
else:
noise_pos = mx.distributed.recv_like(
noise, self.cfg_peer_rank, group=self.group
)
mx.eval(noise_pos)
noise = mx.distributed.send(noise, self.cfg_peer_rank, group=self.group)
noise_pos = self._recv_like(noise, src=self.cfg_peer_rank)
noise = self._send(noise, self.cfg_peer_rank)
mx.async_eval(noise)
noise_neg = noise
@@ -432,6 +465,7 @@ class DiffusionRunner:
guidance_override: float | None = None,
negative_prompt: str | None = None,
num_sync_steps: int = 1,
cancel_checker: Callable[[], bool] | None = None,
):
"""Primary entry point for image generation.
@@ -454,6 +488,8 @@ class DiffusionRunner:
Final GeneratedImage
"""
self._guidance_override = guidance_override
self._cancel_checker = cancel_checker
self._cancelling = False
latents = self.adapter.create_latents(seed, runtime_config)
prompt_data = self.adapter.encode_prompt(prompt, negative_prompt)
@@ -495,7 +531,7 @@ class DiffusionRunner:
except StopIteration as e:
latents = e.value # pyright: ignore[reportAny]
if self.is_last_stage:
if self.is_last_stage and not self._cancelling:
yield self.adapter.decode_latents(latents, runtime_config, seed, prompt) # pyright: ignore[reportAny]
def _run_diffusion_loop(
@@ -524,7 +560,12 @@ class DiffusionRunner:
latents=latents,
)
t = -1 # default if time_steps is empty; drain condition uses t
for t in time_steps:
self._check_cancellation()
if self._cancelling and self.group is None:
break
try:
latents = self._diffusion_step(
t=t,
@@ -542,7 +583,7 @@ class DiffusionRunner:
mx.eval(latents)
if t in capture_steps and self.is_last_stage:
if t in capture_steps and self.is_last_stage and not self._cancelling:
yield (latents, t)
except KeyboardInterrupt: # noqa: PERF203
@@ -551,6 +592,24 @@ class DiffusionRunner:
f"Stopping image generation at step {t + 1}/{len(time_steps)}"
) from None
if self._cancelling:
break
# Drain pending ring recvs after cancellation during async steps.
# The last stage sent patches during the final completed step, but
# the first stage will never enter the next step to recv them.
if (
self._cancelling
and self.is_first_stage
and not self.is_last_stage
and self.group is not None
and t >= runtime_config.init_time_step + num_sync_steps
and t != runtime_config.num_inference_steps - 1
):
patch_latents_drain, _ = self._create_patches(latents, runtime_config)
for patch in patch_latents_drain:
self._recv_like(patch, src=self.last_pipeline_rank)
ctx.after_loop(latents=latents) # pyright: ignore[reportAny]
return latents
@@ -777,19 +836,16 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
hidden_states = mx.distributed.recv(
hidden_states = self._recv(
(batch_size, num_img_tokens, hidden_dim),
dtype,
self.prev_pipeline_rank,
group=self.group,
)
encoder_hidden_states = mx.distributed.recv(
encoder_hidden_states = self._recv(
(batch_size, text_seq_len, hidden_dim),
dtype,
self.prev_pipeline_rank,
group=self.group,
)
mx.eval(hidden_states, encoder_hidden_states)
assert self.joint_block_wrappers is not None
assert encoder_hidden_states is not None
@@ -825,9 +881,7 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
concatenated = mx.distributed.send(
concatenated, self.next_pipeline_rank, group=self.group
)
concatenated = self._send(concatenated, self.next_pipeline_rank)
mx.async_eval(concatenated)
elif self.has_joint_blocks and not self.is_last_stage:
@@ -838,11 +892,9 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
hidden_states = mx.distributed.send(
hidden_states, self.next_pipeline_rank, group=self.group
)
encoder_hidden_states = mx.distributed.send(
encoder_hidden_states, self.next_pipeline_rank, group=self.group
hidden_states = self._send(hidden_states, self.next_pipeline_rank)
encoder_hidden_states = self._send(
encoder_hidden_states, self.next_pipeline_rank
)
mx.async_eval(hidden_states, encoder_hidden_states)
@@ -854,13 +906,11 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
hidden_states = mx.distributed.recv(
hidden_states = self._recv(
(batch_size, text_seq_len + num_img_tokens, hidden_dim),
dtype,
self.prev_pipeline_rank,
group=self.group,
)
mx.eval(hidden_states)
assert self.single_block_wrappers is not None
with trace(
@@ -886,9 +936,7 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
hidden_states = mx.distributed.send(
hidden_states, self.next_pipeline_rank, group=self.group
)
hidden_states = self._send(hidden_states, self.next_pipeline_rank)
mx.async_eval(hidden_states)
hidden_states = hidden_states[:, text_seq_len:, ...]
@@ -961,16 +1009,11 @@ class DiffusionRunner:
)
if not self.is_first_stage:
hidden_states = mx.distributed.send(
hidden_states, self.first_pipeline_rank, group=self.group
)
hidden_states = self._send(hidden_states, self.first_pipeline_rank)
mx.async_eval(hidden_states)
elif self.is_first_stage:
hidden_states = mx.distributed.recv_like(
prev_latents, src=self.last_pipeline_rank, group=self.group
)
mx.eval(hidden_states)
hidden_states = self._recv_like(prev_latents, src=self.last_pipeline_rank)
else:
hidden_states = prev_latents
@@ -1006,10 +1049,7 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
patch = mx.distributed.recv_like(
patch, src=self.last_pipeline_rank, group=self.group
)
mx.eval(patch)
patch = self._recv_like(patch, src=self.last_pipeline_rank)
results: list[tuple[bool, mx.array]] = []
@@ -1066,10 +1106,9 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
patch_latents[patch_idx] = mx.distributed.send(
patch_latents[patch_idx] = self._send(
patch_latents[patch_idx],
self.first_pipeline_rank,
group=self.group,
)
mx.async_eval(patch_latents[patch_idx])
@@ -1116,13 +1155,11 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
patch = mx.distributed.recv(
patch = self._recv(
(batch_size, patch_len, hidden_dim),
patch.dtype,
self.prev_pipeline_rank,
group=self.group,
)
mx.eval(patch)
if patch_idx == 0:
with trace(
@@ -1130,13 +1167,11 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
encoder_hidden_states = mx.distributed.recv(
encoder_hidden_states = self._recv(
(batch_size, text_seq_len, hidden_dim),
patch.dtype,
self.prev_pipeline_rank,
group=self.group,
)
mx.eval(encoder_hidden_states)
if self.is_first_stage:
patch, encoder_hidden_states = self.adapter.compute_embeddings(
@@ -1175,9 +1210,7 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
patch_concat = mx.distributed.send(
patch_concat, self.next_pipeline_rank, group=self.group
)
patch_concat = self._send(patch_concat, self.next_pipeline_rank)
mx.async_eval(patch_concat)
elif self.has_joint_blocks and not self.is_last_stage:
@@ -1187,9 +1220,7 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
patch = mx.distributed.send(
patch, self.next_pipeline_rank, group=self.group
)
patch = self._send(patch, self.next_pipeline_rank)
mx.async_eval(patch)
if patch_idx == 0:
@@ -1199,8 +1230,8 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
encoder_hidden_states = mx.distributed.send(
encoder_hidden_states, self.next_pipeline_rank, group=self.group
encoder_hidden_states = self._send(
encoder_hidden_states, self.next_pipeline_rank
)
mx.async_eval(encoder_hidden_states)
@@ -1213,13 +1244,11 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
patch = mx.distributed.recv(
patch = self._recv(
(batch_size, text_seq_len + patch_len, hidden_dim),
patch.dtype,
self.prev_pipeline_rank,
group=self.group,
)
mx.eval(patch)
assert self.single_block_wrappers is not None
with trace(
@@ -1245,9 +1274,7 @@ class DiffusionRunner:
rank=self.rank,
category="comms",
):
patch = mx.distributed.send(
patch, self.next_pipeline_rank, group=self.group
)
patch = self._send(patch, self.next_pipeline_rank)
mx.async_eval(patch)
noise: mx.array | None = None
+2 -2
View File
@@ -57,8 +57,8 @@ from mlx_lm.models.step3p5 import Model as Step35Model
from mlx_lm.models.step3p5 import Step3p5MLP as Step35MLP
from mlx_lm.models.step3p5 import Step3p5Model as Step35InnerModel
from exo.shared.logging import logger
from exo.shared.types.worker.shards import PipelineShardMetadata
from exo.worker.runner.bootstrap import logger
if TYPE_CHECKING:
from mlx_lm.models.cache import Cache
@@ -480,7 +480,7 @@ def patch_tensor_model[T](model: T) -> T:
last = cache[-1] # pyright: ignore[reportAny]
dep_cache = last[0] if hasattr(last, "caches") else last # pyright: ignore[reportAny]
if hasattr(dep_cache, "keys"): # type: ignore
dep_cache.keys = mx.depends(dep_cache.keys, logits) # pyright: ignore[reportAny,reportUnknownMemberType]
dep_cache.keys = mx.depends(dep_cache.keys, logits) # pyright: ignore[reportAny]
return logits
+23 -2
View File
@@ -4,7 +4,7 @@ from typing import Any
from mlx_lm.chat_templates import deepseek_v32
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
BOS_TOKEN: str = deepseek_v32.bos_token
EOS_TOKEN: str = deepseek_v32.eos_token
@@ -15,7 +15,28 @@ USER_TOKEN = "<\uff5cUser\uff5c>"
ASSISTANT_TOKEN = "<\uff5cAssistant\uff5c>"
TOOL_CALLS_START = f"<{DSML_TOKEN}function_calls>"
TOOL_CALLS_END = f"</{DSML_TOKEN}function_calls>"
encode_messages = deepseek_v32.encode_messages
_ORPHAN_THINK_END = ASSISTANT_TOKEN + THINKING_END
_FIXED_THINK_BLOCK = ASSISTANT_TOKEN + THINKING_START + "\n" + THINKING_END
def encode_messages(
messages: list[dict[str, Any]],
thinking_mode: str = "thinking",
context: list[dict[str, Any]] | None = None,
drop_thinking: bool = True,
add_default_bos_token: bool = True,
tools: Any = None, # pyright: ignore[reportAny]
) -> str:
prompt: str = deepseek_v32.encode_messages(
messages,
thinking_mode=thinking_mode,
context=context,
drop_thinking=drop_thinking,
add_default_bos_token=add_default_bos_token,
tools=tools,
)
return prompt.replace(_ORPHAN_THINK_END, _FIXED_THINK_BLOCK)
_INVOKE_PATTERN = re.compile(
rf"<{re.escape(DSML_TOKEN)}invoke\s+name=\"([^\"]+)\">"
@@ -6,11 +6,14 @@ import mlx.core as mx
from mlx_lm.generate import (
BatchGenerator as MlxBatchGenerator,
)
from mlx_lm.generate import (
generation_stream,
)
from mlx_lm.models.cache import RotatingKVCache
from mlx_lm.sample_utils import make_logits_processors, make_sampler
from mlx_lm.tokenizer_utils import StreamingDetokenizer, TokenizerWrapper
from exo.shared.types.api import (
from exo.api.types import (
CompletionTokensDetails,
FinishReason,
GenerationStats,
@@ -63,9 +66,12 @@ class _EngineTask:
potential_stop_sequence_text: str = ""
completion_tokens: int = 0
generation_start_time: float = 0.0
generation_time_at_start: float = 0.0
in_thinking: bool = False
reasoning_tokens: int = 0
prefill_tps: float = 0.0
first_gen_token_time: float | None = None
last_gen_token_time: float | None = None
@dataclass(eq=False)
@@ -75,22 +81,23 @@ class ExoBatchGenerator:
group: mx.distributed.Group | None
kv_prefix_cache: KVPrefixCache | None
_exo_gen: MlxBatchGenerator = field(init=False)
_mlx_gen: MlxBatchGenerator = field(init=False)
_active_tasks: dict[int, _EngineTask] = field(default_factory=dict, init=False)
def __post_init__(self) -> None:
self._exo_gen = MlxBatchGenerator(
self._mlx_gen = MlxBatchGenerator(
model=self.model,
stop_tokens=set(eos_ids_from_tokenizer(self.tokenizer)),
prefill_step_size=4096,
)
self._mlx_gen._needs_topk = False # pyright: ignore[reportAttributeAccessIssue]
@property
def has_work(self) -> bool:
return (
bool(self._active_tasks)
or bool(self._exo_gen.unprocessed_prompts)
or self._exo_gen.active_batch is not None
or bool(self._mlx_gen.unprocessed_prompts)
or self._mlx_gen.active_batch is not None
)
def submit(
@@ -188,7 +195,7 @@ class ExoBatchGenerator:
max_tokens = task_params.max_output_tokens or MAX_TOKENS
uids = self._exo_gen.insert(
uids = self._mlx_gen.insert(
prompts=[last_tokens.tolist()],
max_tokens=[max_tokens],
caches=[list(cache)],
@@ -211,6 +218,7 @@ class ExoBatchGenerator:
on_generation_token=on_generation_token,
generation_start_time=time.perf_counter(),
prefill_tps=_prefill_tps,
generation_time_at_start=self._mlx_gen._stats.generation_time,
)
return uid
@@ -219,7 +227,12 @@ class ExoBatchGenerator:
if not self.has_work:
return []
responses = self._exo_gen.next()
self._mlx_gen._needs_topk = any( # pyright: ignore[reportAttributeAccessIssue]
t.task_params.logprobs for t in self._active_tasks.values()
)
_step_tic = time.perf_counter()
responses = self._mlx_gen.next()
_next_elapsed = time.perf_counter() - _step_tic
results: list[tuple[int, GenerationResponse]] = []
@@ -231,6 +244,10 @@ class ExoBatchGenerator:
continue
state = self._active_tasks[response.uid]
now = time.perf_counter()
if state.first_gen_token_time is None:
state.first_gen_token_time = now
state.last_gen_token_time = now
if state.on_generation_token is not None:
state.on_generation_token()
if response.finish_reason != "stop":
@@ -239,6 +256,9 @@ class ExoBatchGenerator:
state.detokenizer.finalize()
text = state.detokenizer.last_segment
state.completion_tokens += 1
if state.task_params.bench:
delta = now - state.first_gen_token_time
logger.debug(f"[bench] uid={response.uid} tok#{state.completion_tokens} {text!r} t={delta:.4f}s")
state.generated_text_parts.append(text)
state.potential_stop_sequence_text += text
@@ -277,28 +297,31 @@ class ExoBatchGenerator:
logprob: float | None = None
top_logprobs: list[TopLogprobItem] | None = None
if task_params.logprobs:
logprob, top_logprobs = extract_top_logprobs(
logprobs=response.logprobs,
tokenizer=self.tokenizer,
top_logprobs=task_params.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=response.token,
)
with mx.stream(generation_stream):
logprob, top_logprobs = extract_top_logprobs(
logprobs=response.logprobs,
tokenizer=self.tokenizer,
top_logprobs=task_params.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=response.token,
precomputed_indices=getattr(response, "_topk_indices", None),
precomputed_values=getattr(response, "_topk_values", None),
precomputed_selected=getattr(
response, "_selected_logprob", None
),
)
stats: GenerationStats | None = None
usage: Usage | None = None
if is_done:
try:
mlx_stats = self._exo_gen.stats()
generation_tps = mlx_stats.generation_tps
except ZeroDivisionError:
generation_elapsed = (
time.perf_counter() - state.generation_start_time
)
generation_tps = (
state.completion_tokens / generation_elapsed
if generation_elapsed > 0
else 0.0
)
if (
state.first_gen_token_time is not None
and state.last_gen_token_time is not None
and state.completion_tokens > 1
):
gen_span = state.last_gen_token_time - state.first_gen_token_time
generation_tps = (state.completion_tokens - 1) / gen_span if gen_span > 0 else 0.0
else:
generation_tps = 0.0
stats = GenerationStats(
prompt_tps=state.prefill_tps,
@@ -345,15 +368,22 @@ class ExoBatchGenerator:
-max_stop_len:
]
_step_elapsed = time.perf_counter() - _step_tic
_overhead = _step_elapsed - _next_elapsed
if self._mlx_gen._next_count % 64 == 0 and responses:
logger.debug(
f"step overhead: {_overhead * 1000:.2f}ms (next={_next_elapsed * 1000:.2f}ms total={_step_elapsed * 1000:.2f}ms)"
)
return results
def cancel(self, uids: list[int]) -> None:
self._exo_gen.remove(uids)
self._mlx_gen.remove(uids)
for uid in uids:
self._active_tasks.pop(uid, None)
def close(self) -> None:
self._exo_gen.close()
self._mlx_gen.close()
def _save_prefix_cache(
self,
@@ -372,9 +402,8 @@ class ExoBatchGenerator:
if len(all_prompt_tokens) > 0
else 0.0
)
if (
matched_index is not None
and hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
if matched_index is not None and (
prefix_hit_length > 1000 or hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
):
self.kv_prefix_cache.update_kv_cache(
matched_index,
@@ -13,7 +13,7 @@ from mlx_lm.models.cache import ArraysCache, RotatingKVCache
from mlx_lm.sample_utils import make_logits_processors, make_sampler
from mlx_lm.tokenizer_utils import TokenizerWrapper
from exo.shared.types.api import (
from exo.api.types import (
CompletionTokensDetails,
FinishReason,
GenerationStats,
@@ -179,7 +179,8 @@ def pipeline_parallel_prefill(
flush_prefill_sends()
assert _prompt_cache is not None
mx.eval([c.state for c in _prompt_cache]) # type: ignore
with mx.stream(generation_stream):
mx.eval([c.state for c in _prompt_cache]) # type: ignore
# Final callback matching generate_step
prompt_progress_callback(total, total)
@@ -312,52 +313,46 @@ def warmup_inference(
model_id: ModelId,
) -> int:
logger.info(f"warming up inference for instance: {model_id}")
t = time.monotonic()
content = "Prompt to warm up the inference engine. Repeat this."
warmup_task_params = TextGenerationTaskParams(
model=model_id,
input=[InputMessage(role="user", content=content)],
max_output_tokens=50,
temperature=0.0,
)
warmup_prompt = apply_chat_template(
tokenizer=tokenizer,
task_params=TextGenerationTaskParams(
model=ModelId(""),
input=[InputMessage(role="user", content=content)],
),
task_params=warmup_task_params,
)
tokens_generated = 0
cache = make_kv_cache(
model=model,
)
# Use a default sampler for warmup
sampler = make_sampler(temp=0.0)
mx_barrier(group)
logger.info("Generating warmup tokens")
for _r in stream_generate(
t = time.monotonic()
for _r in mlx_generate(
model=model,
tokenizer=tokenizer,
task=warmup_task_params,
prompt=warmup_prompt,
max_tokens=50,
sampler=sampler,
prompt_cache=cache,
prefill_step_size=2048,
kv_group_size=KV_GROUP_SIZE,
kv_bits=KV_BITS,
kv_prefix_cache=None,
group=group,
):
logger.info("Generated warmup token: " + str(_r.text))
tokens_generated += 1
logger.info("Generated ALL warmup tokens")
check_for_cancel_every = min(
math.ceil(tokens_generated / min(time.monotonic() - t, 0.001)), 100
)
mx_barrier(group)
logger.info(f"warmed up by generating {tokens_generated} tokens")
check_for_cancel_every = min(
math.ceil(tokens_generated / min(time.monotonic() - t, 0.001)), 100
)
if group is not None:
check_for_cancel_every = int(
mx.max(
@@ -398,52 +393,44 @@ def extract_top_logprobs(
tokenizer: TokenizerWrapper,
top_logprobs: int,
selected_token: int,
precomputed_indices: list[int] | None = None,
precomputed_values: list[float] | None = None,
precomputed_selected: float | None = None,
) -> tuple[float, list[TopLogprobItem]]:
"""Extract the selected token's logprob and top alternative tokens.
Args:
logprobs: Full vocabulary logprobs array from MLX
tokenizer: Tokenizer for decoding token IDs to strings
top_logprobs: Number of top alternatives to return
selected_token: The token ID that was actually sampled
Returns:
Tuple of (selected_token_logprob, list of TopLogprobItem for top alternatives)
"""
# Get the logprob of the selected token
selected_logprob = float(logprobs[selected_token].item())
# Get top indices (most probable tokens)
# mx.argpartition gives indices that would partition the array
# We negate logprobs since argpartition finds smallest, and we want largest
top_logprobs = min(top_logprobs, logprobs.shape[0]) # Don't exceed vocab size
top_indices = mx.argpartition(-logprobs, top_logprobs)[:top_logprobs]
# Get the actual logprob values for these indices
top_values = logprobs[top_indices]
# Sort by logprob (descending) for consistent ordering
sort_order = mx.argsort(-top_values)
top_indices = top_indices[sort_order]
top_values = top_values[sort_order]
if (
precomputed_indices is not None
and precomputed_values is not None
and precomputed_selected is not None
):
top_indices_list: list[int] = precomputed_indices[:top_logprobs]
top_values_list: list[float] = precomputed_values[:top_logprobs]
selected_logprob = precomputed_selected
else:
selected_logprob_arr = logprobs[selected_token]
top_logprobs = min(top_logprobs, logprobs.shape[0] - 1)
top_indices = mx.argpartition(-logprobs, top_logprobs)[:top_logprobs]
top_values = logprobs[top_indices]
sort_order = mx.argsort(-top_values)
top_indices = top_indices[sort_order]
top_values = top_values[sort_order]
mx.eval(selected_logprob_arr, top_indices, top_values)
selected_logprob = float(selected_logprob_arr.item())
top_indices_list = top_indices.tolist() # type: ignore
top_values_list = top_values.tolist() # type: ignore
# Convert to list of TopLogprobItem
top_logprob_items: list[TopLogprobItem] = []
for i in range(top_logprobs):
token_id = int(top_indices[i].item())
token_logprob = float(top_values[i].item())
for token_id, token_logprob in zip(top_indices_list, top_values_list, strict=True):
if math.isnan(token_logprob):
continue
# Decode token ID to string
token_str = tokenizer.decode([token_id])
# Get byte representation
token_bytes = list(token_str.encode("utf-8"))
top_logprob_items.append(
TopLogprobItem(
token=token_str,
logprob=token_logprob,
bytes=token_bytes,
bytes=list(token_str.encode("utf-8")),
)
)
@@ -624,12 +611,13 @@ def mlx_generate(
logprob: float | None = None
top_logprobs: list[TopLogprobItem] | None = None
if task.logprobs:
logprob, top_logprobs = extract_top_logprobs(
logprobs=out.logprobs,
tokenizer=tokenizer,
top_logprobs=task.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=out.token,
)
with mx.stream(generation_stream):
logprob, top_logprobs = extract_top_logprobs(
logprobs=out.logprobs,
tokenizer=tokenizer,
top_logprobs=task.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=out.token,
)
if is_done:
# Log generation stats
@@ -657,9 +645,9 @@ def mlx_generate(
if len(all_prompt_tokens) > 0
else 0.0
)
if (
matched_index is not None
and hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
if matched_index is not None and (
prefix_hit_length > 1000
or hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
):
kv_prefix_cache.update_kv_cache(
matched_index,
@@ -0,0 +1,14 @@
from exo.worker.engines.mlx.patches.opt_batch_gen import apply_batch_gen_patch
from exo.worker.engines.mlx.patches.standard_yarn_rope import patch_yarn_rope
_applied = False
def apply_mlx_patches() -> None:
global _applied
if _applied:
return
_applied = True
patch_yarn_rope()
# patch_gdn_softplus()
apply_batch_gen_patch()
@@ -0,0 +1,173 @@
import time
from typing import Any, cast
import mlx.core as mx
from mlx_lm.generate import BatchGenerator, generation_stream
_PRECOMPUTE_TOP_K = 20
_original_public_next = BatchGenerator.next
_pending_topk_idx: mx.array | None = None
_pending_topk_val: mx.array | None = None
_pending_selected_lps: mx.array | None = None
def _fast_next(self: BatchGenerator) -> list[BatchGenerator.Response]:
tic = time.perf_counter()
batch = self.active_batch
assert batch is not None
batch_size = len(batch)
prev_tokens = batch.y
prev_logprobs = batch.logprobs
has_processors = any(p for ps in batch.logits_processors for p in ps)
if has_processors:
for i, toks in enumerate(batch.tokens):
batch.tokens[i] = mx.concatenate([toks, prev_tokens[i : i + 1]])
logits = self.model(prev_tokens[:, None], cache=batch.cache)
logits = logits[:, -1, :]
if has_processors:
processed_logits: list[mx.array] = []
for e in range(batch_size):
sample_logits: mx.array = logits[e : e + 1]
for processor in batch.logits_processors[e]:
sample_logits = processor(batch.tokens[e], sample_logits)
processed_logits.append(sample_logits)
logits = mx.concatenate(processed_logits, axis=0)
logprobs = logits - mx.logsumexp(logits, axis=-1, keepdims=True)
if (
batch_size == 1
or any(batch.samplers)
and all(s is batch.samplers[0] for s in batch.samplers)
):
sampler = batch.samplers[0] or self.sampler
batch.y = sampler(logprobs)
elif any(batch.samplers):
all_samples: list[mx.array] = []
for e in range(batch_size):
s = batch.samplers[e] or self.sampler
all_samples.append(s(logprobs[e : e + 1]))
batch.y = mx.concatenate(all_samples, axis=0)
else:
batch.y = self.sampler(logprobs)
batch.logprobs = list(logprobs)
global _pending_topk_idx, _pending_topk_val, _pending_selected_lps
emit_topk_indices: list[list[int]] = (
cast(list[list[int]], _pending_topk_idx.tolist())
if _pending_topk_idx is not None
else []
)
emit_topk_values: list[list[float]] = (
cast(list[list[float]], _pending_topk_val.tolist())
if _pending_topk_val is not None
else []
)
emit_selected_lps: list[float] = (
cast(list[float], _pending_selected_lps.tolist())
if _pending_selected_lps is not None
else []
)
needs_topk: bool = getattr(self, "_needs_topk", False)
if needs_topk:
k = min(_PRECOMPUTE_TOP_K, logprobs.shape[1])
_pending_topk_idx = mx.argpartition(-logprobs, k, axis=1)[:, :k]
_pending_topk_val = mx.take_along_axis(logprobs, _pending_topk_idx, axis=1)
sort_order = mx.argsort(-_pending_topk_val, axis=1)
_pending_topk_idx = mx.take_along_axis(_pending_topk_idx, sort_order, axis=1)
_pending_topk_val = mx.take_along_axis(_pending_topk_val, sort_order, axis=1)
_pending_selected_lps = logprobs[mx.arange(batch_size), batch.y]
mx.async_eval(
batch.y,
*batch.logprobs,
*batch.tokens,
_pending_topk_idx,
_pending_topk_val,
_pending_selected_lps,
)
else:
_pending_topk_idx = None
_pending_topk_val = None
_pending_selected_lps = None
mx.async_eval(batch.y, *batch.logprobs, *batch.tokens)
prev_token_list: list[int] = cast(list[int], prev_tokens.tolist())
toc = time.perf_counter()
self._stats.generation_time += toc - tic
keep_idx: list[int] = []
end_idx: list[int] = []
responses: list[Any] = []
stop_tokens = self.stop_tokens
for e in range(batch_size):
t = prev_token_list[e]
uid = batch.uids[e]
num_tok = batch.num_tokens[e] + 1
batch.num_tokens[e] = num_tok
if t in stop_tokens:
finish_reason = "stop"
end_idx.append(e)
elif num_tok >= batch.max_tokens[e]:
finish_reason = "length"
end_idx.append(e)
else:
finish_reason = None
keep_idx.append(e)
cache = None
if finish_reason is not None:
cache = batch.extract_cache(e)
response = self.Response(uid, t, prev_logprobs[e], finish_reason, cache)
if emit_topk_indices and e < len(emit_topk_indices):
response._topk_indices = emit_topk_indices[e] # pyright: ignore[reportAttributeAccessIssue]
response._topk_values = emit_topk_values[e] # pyright: ignore[reportAttributeAccessIssue]
response._selected_logprob = emit_selected_lps[e] # pyright: ignore[reportAttributeAccessIssue]
responses.append(response)
if end_idx:
if keep_idx:
batch.filter(keep_idx)
if (
_pending_topk_idx is not None
and _pending_topk_val is not None
and _pending_selected_lps is not None
):
ki = mx.array(keep_idx)
_pending_topk_idx = _pending_topk_idx[ki]
_pending_topk_val = _pending_topk_val[ki]
_pending_selected_lps = _pending_selected_lps[ki]
else:
self.active_batch = None
_pending_topk_idx = None
_pending_topk_val = None
_pending_selected_lps = None
self._next_count += 1
if self._next_count % 512 == 0:
mx.clear_cache()
self._stats.generation_tokens += len(responses)
return responses
def _patched_public_next(self: BatchGenerator) -> list[BatchGenerator.Response]:
batch = self.active_batch
# Only do decode with fast_next
if batch is not None and not self.unprocessed_prompts:
with mx.stream(generation_stream):
return _fast_next(self)
return _original_public_next(self)
def apply_batch_gen_patch() -> None:
BatchGenerator.next = _patched_public_next
@@ -0,0 +1,118 @@
import math
import mlx.core as mx
from mlx_lm.models import rope_utils
_original_YarnRoPE_init = rope_utils.YarnRoPE.__init__ # noqa: N816
_original_initialize_rope = rope_utils.initialize_rope
def _patched_yarn_init(
self: rope_utils.YarnRoPE,
dims: int,
traditional: bool = False,
max_position_embeddings: int = 2048,
base: float = 10000,
scaling_factor: float = 1.0,
original_max_position_embeddings: int = 4096,
beta_fast: float = 32,
beta_slow: float = 1,
mscale: float = 1,
mscale_all_dim: float = 0,
truncate: bool = True,
) -> None:
"""Patch mlx_lm's YarnRoPE to match vLLM's inverse-frequency blending formula for compatability."""
super(rope_utils.YarnRoPE, self).__init__()
def yarn_find_correction_dim(num_rotations: float) -> float:
return (
dims
* math.log(original_max_position_embeddings / (num_rotations * 2 * math.pi))
) / (2 * math.log(base))
def yarn_find_correction_range() -> tuple[float, float]:
low: float = yarn_find_correction_dim(beta_fast)
high: float = yarn_find_correction_dim(beta_slow)
if truncate:
low = math.floor(low)
high = math.ceil(high)
return max(low, 0), min(high, dims - 1)
def yarn_get_mscale(scale: float = 1, ms: float = 1) -> float:
if scale <= 1:
return 1.0
return 0.1 * ms * math.log(scale) + 1.0
def yarn_linear_ramp_mask(min_val: float, max_val: float, dim: int) -> mx.array:
if min_val == max_val:
max_val += 0.001
linear_func = (mx.arange(dim, dtype=mx.float32) - min_val) / (max_val - min_val)
return mx.clip(linear_func, 0, 1)
self.mscale = yarn_get_mscale(scaling_factor, mscale) / yarn_get_mscale(
scaling_factor, mscale_all_dim
)
pos_freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
inv_freq_extrapolation = 1.0 / pos_freqs
inv_freq_interpolation = 1.0 / (scaling_factor * pos_freqs)
low, high = yarn_find_correction_range()
inv_freq_mask = 1.0 - yarn_linear_ramp_mask(low, high, dims // 2)
inv_freq = (
inv_freq_interpolation * (1 - inv_freq_mask)
+ inv_freq_extrapolation * inv_freq_mask
)
self._freqs = 1.0 / inv_freq
self.dims = dims
self.traditional = traditional
def _patched_initialize_rope(
dims: int,
base: float,
traditional: bool,
scaling_config: dict[str, str | int | float | bool] | None = None,
max_position_embeddings: int | None = None,
) -> object:
rope_type = "default"
if scaling_config is not None:
rope_type = str(
scaling_config.get("type") or scaling_config.get("rope_type", "default")
)
# All the yarn rope types supported in mlx lm
if rope_type in ("yarn", "deepseek_yarn"):
assert scaling_config is not None
cfg = scaling_config
def _float(key: str, default: float) -> float:
v = cfg.get(key)
return float(v) if v is not None else default
def _int(key: str, default: int) -> int:
v = cfg.get(key)
return int(v) if v is not None else default
return rope_utils.YarnRoPE(
dims=dims,
max_position_embeddings=max_position_embeddings or 2048,
traditional=traditional,
scaling_factor=_float("factor", 1.0),
base=base,
original_max_position_embeddings=_int(
"original_max_position_embeddings", 4096
),
beta_fast=_float("beta_fast", 32),
beta_slow=_float("beta_slow", 1),
mscale=_float("mscale", 1),
mscale_all_dim=_float("mscale_all_dim", 0),
)
return _original_initialize_rope(
dims, base, traditional, scaling_config, max_position_embeddings
)
def patch_yarn_rope() -> None:
rope_utils.YarnRoPE.__init__ = _patched_yarn_init
rope_utils.initialize_rope = _patched_initialize_rope
@@ -0,0 +1,290 @@
# type: ignore
import math
from unittest.mock import MagicMock
import mlx.core as mx
import mlx.nn as nn
import pytest
from mlx_lm.generate import BatchGenerator
from exo.worker.engines.mlx.generator.generate import extract_top_logprobs
from exo.worker.engines.mlx.patches.opt_batch_gen import (
_PRECOMPUTE_TOP_K,
apply_batch_gen_patch,
)
def _mock_tokenizer() -> MagicMock:
tok = MagicMock()
tok.decode = lambda ids: f"tok_{ids[0]}"
return tok
def _make_logprobs(values: list[float]) -> mx.array:
arr = mx.array(values, dtype=mx.float32)
mx.eval(arr)
return arr
class TestExtractTopLogprobsFallback:
def test_returns_correct_selected_logprob(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -0.5, -3.0, -4.0])
selected, _ = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=3, selected_token=2
)
assert selected == pytest.approx(-0.5)
def test_returns_top_k_sorted_descending(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -0.5, -3.0, -4.0])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=3, selected_token=0
)
logprob_values = [item.logprob for item in items]
assert logprob_values == sorted(logprob_values, reverse=True)
assert len(items) == 3
def test_top_tokens_are_most_probable(self) -> None:
lp = _make_logprobs([-5.0, -1.0, -3.0, -0.1, -2.0])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=2, selected_token=0
)
token_ids = [int(item.token.split("_")[1]) for item in items]
assert 3 in token_ids
assert 1 in token_ids
def test_top_logprobs_clamped_to_vocab_size(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -3.0, -4.0, -5.0])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=10, selected_token=0
)
assert len(items) == 4
def test_nan_logprobs_filtered(self) -> None:
lp = _make_logprobs([-1.0, float("nan"), -0.5])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=3, selected_token=0
)
for item in items:
assert not math.isnan(item.logprob)
def test_token_bytes_correct(self) -> None:
tok = MagicMock()
tok.decode = lambda ids: "hello"
lp = _make_logprobs([-1.0, -2.0])
_, items = extract_top_logprobs(lp, tok, top_logprobs=2, selected_token=0)
assert items[0].bytes == list("hello".encode("utf-8"))
class TestExtractTopLogprobsPrecomputed:
def test_uses_precomputed_data(self) -> None:
lp = _make_logprobs([-99.0])
selected, items = extract_top_logprobs(
lp,
_mock_tokenizer(),
top_logprobs=2,
selected_token=0,
precomputed_indices=[3, 1, 0],
precomputed_values=[-0.1, -1.0, -5.0],
precomputed_selected=-0.1,
)
assert selected == pytest.approx(-0.1)
assert len(items) == 2
assert items[0].token == "tok_3"
assert items[0].logprob == pytest.approx(-0.1)
assert items[1].token == "tok_1"
assert items[1].logprob == pytest.approx(-1.0)
def test_slices_precomputed_to_requested_k(self) -> None:
lp = _make_logprobs([-99.0])
_, items = extract_top_logprobs(
lp,
_mock_tokenizer(),
top_logprobs=1,
selected_token=0,
precomputed_indices=[3, 1, 0, 2, 4],
precomputed_values=[-0.1, -1.0, -2.0, -3.0, -4.0],
precomputed_selected=-0.1,
)
assert len(items) == 1
assert items[0].token == "tok_3"
def test_falls_back_when_precomputed_partial(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -0.5])
selected, items = extract_top_logprobs(
lp,
_mock_tokenizer(),
top_logprobs=2,
selected_token=2,
precomputed_indices=[0, 2],
precomputed_values=None,
precomputed_selected=None,
)
assert selected == pytest.approx(-0.5)
assert len(items) == 2
def test_precomputed_matches_fallback(self) -> None:
lp = _make_logprobs([-1.0, -0.3, -2.5, -0.1, -4.0, -0.8, -3.0, -1.5])
tok = _mock_tokenizer()
selected_fb, items_fb = extract_top_logprobs(
lp, tok, top_logprobs=5, selected_token=1
)
pre_indices = [item.token.split("_")[1] for item in items_fb]
pre_indices_int = [int(x) for x in pre_indices]
pre_values = [item.logprob for item in items_fb]
selected_pc, items_pc = extract_top_logprobs(
lp,
tok,
top_logprobs=5,
selected_token=1,
precomputed_indices=pre_indices_int,
precomputed_values=pre_values,
precomputed_selected=selected_fb,
)
assert selected_pc == pytest.approx(selected_fb)
assert len(items_pc) == len(items_fb)
for a, b in zip(items_pc, items_fb, strict=True):
assert a.token == b.token
assert a.logprob == pytest.approx(b.logprob)
def _tiny_model() -> nn.Module:
from mlx_lm.models.llama import Model, ModelArgs
mx.random.seed(42)
args = ModelArgs(
model_type="llama",
hidden_size=64,
num_hidden_layers=2,
intermediate_size=128,
num_attention_heads=2,
num_key_value_heads=1,
rms_norm_eps=1e-6,
vocab_size=256,
rope_theta=10000.0,
tie_word_embeddings=True,
)
model = Model(args)
mx.eval(model.parameters())
return model
@pytest.mark.slow
class TestBatchedTopKPrecompute:
@pytest.fixture(autouse=True)
def _reset_globals(self) -> None:
import exo.worker.engines.mlx.patches.opt_batch_gen as _mod
_mod._pending_topk_idx = None
_mod._pending_topk_val = None
_mod._pending_selected_lps = None
def _run_generator(
self, model: nn.Module, prompts: list[list[int]], steps: int, needs_topk: bool
) -> list[list[BatchGenerator.Response]]:
apply_batch_gen_patch()
gen = BatchGenerator(model=model, stop_tokens=set(), prefill_step_size=512)
gen._needs_topk = needs_topk
gen.insert(prompts)
all_responses: list[list[BatchGenerator.Response]] = []
for _ in range(steps + len(prompts)):
responses = gen.next()
if responses:
all_responses.append(responses)
if gen.active_batch is None and not gen.unprocessed_prompts:
break
gen.close()
return all_responses
def test_precomputed_topk_attached_to_responses(self) -> None:
model = _tiny_model()
steps = self._run_generator(model, [[1, 2, 3]], 5, needs_topk=True)
found_precomputed = False
for step_responses in steps:
for resp in step_responses:
if hasattr(resp, "_topk_indices"):
found_precomputed = True
assert hasattr(resp, "_topk_values"), (
"Response missing _topk_values"
)
assert hasattr(resp, "_selected_logprob"), (
"Response missing _selected_logprob"
)
assert len(resp._topk_indices) == _PRECOMPUTE_TOP_K
assert len(resp._topk_values) == _PRECOMPUTE_TOP_K
assert found_precomputed, "No responses had precomputed topk"
def test_no_topk_when_not_needed(self) -> None:
model = _tiny_model()
steps = self._run_generator(model, [[1, 2, 3]], 5, needs_topk=False)
for step_responses in steps:
for resp in step_responses:
assert not hasattr(resp, "_topk_indices")
def test_precomputed_matches_fallback_in_batch(self) -> None:
model = _tiny_model()
tok = _mock_tokenizer()
steps = self._run_generator(model, [[1, 2, 3]], 10, needs_topk=True)
for step_responses in steps[1:]:
for resp in step_responses:
if not hasattr(resp, "_topk_indices"):
continue
selected_fb, items_fb = extract_top_logprobs(
resp.logprobs, tok, top_logprobs=5, selected_token=resp.token
)
selected_pc, items_pc = extract_top_logprobs(
resp.logprobs,
tok,
top_logprobs=5,
selected_token=resp.token,
precomputed_indices=resp._topk_indices,
precomputed_values=resp._topk_values,
precomputed_selected=resp._selected_logprob,
)
assert selected_pc == pytest.approx(selected_fb, abs=1e-5)
for a, b in zip(items_pc, items_fb, strict=True):
assert a.token == b.token
assert a.logprob == pytest.approx(b.logprob, abs=1e-5)
def test_topk_correct_after_batch_shrink(self) -> None:
model = _tiny_model()
tok = _mock_tokenizer()
apply_batch_gen_patch()
gen = BatchGenerator(
model=model, stop_tokens={0}, prefill_step_size=512, max_tokens=3
)
gen._needs_topk = True
gen.insert([[1, 2, 3], [4, 5, 6]], max_tokens=[3, 20])
seen_shrink = False
for _ in range(30):
responses = gen.next()
for resp in responses:
if resp.finish_reason is not None:
seen_shrink = True
continue
if not hasattr(resp, "_topk_indices"):
continue
selected_fb, items_fb = extract_top_logprobs(
resp.logprobs, tok, top_logprobs=5, selected_token=resp.token
)
selected_pc, _ = extract_top_logprobs(
resp.logprobs,
tok,
top_logprobs=5,
selected_token=resp.token,
precomputed_indices=resp._topk_indices,
precomputed_values=resp._topk_values,
precomputed_selected=resp._selected_logprob,
)
assert selected_pc == pytest.approx(selected_fb, abs=1e-5), (
f"Mismatch after batch shrink: precomputed={selected_pc}, fallback={selected_fb}"
)
if gen.active_batch is None and not gen.unprocessed_prompts:
break
gen.close()
assert seen_shrink, "Expected at least one request to finish (batch shrink)"
+11 -18
View File
@@ -486,16 +486,7 @@ def _patch_lossy_chat_template(template: str) -> str | None:
def _needs_dsml_encoding(task_params: TextGenerationTaskParams) -> bool:
if "deepseek-v3.2" not in task_params.model.lower():
return False
# Use DSML encoding when tools are provided or tool results are in the conversation
if task_params.tools:
return True
if task_params.chat_template_messages:
return any(
msg.get("role") == "tool" for msg in task_params.chat_template_messages
)
return False
return "deepseek-v3.2" in task_params.model.lower()
def apply_chat_template(
@@ -514,8 +505,6 @@ def apply_chat_template(
if task_params.chat_template_messages is not None:
# Use pre-formatted messages that preserve tool_calls, thinking, etc.
formatted_messages = list(task_params.chat_template_messages)
for msg in formatted_messages:
_normalize_tool_calls(msg)
else:
# Add system message (instructions) if present
if task_params.instructions:
@@ -541,7 +530,10 @@ def apply_chat_template(
prompt = encode_messages(
messages=formatted_messages,
thinking_mode="thinking" if task_params.enable_thinking else "chat",
# Only use chat mode if enable thinking is explicitly Fakse.
thinking_mode="chat"
if task_params.enable_thinking is False
else "thinking",
tools=task_params.tools,
)
if partial_assistant_content:
@@ -549,6 +541,9 @@ def apply_chat_template(
logger.info(prompt)
return prompt
for msg in formatted_messages:
_normalize_tool_calls(msg)
extra_kwargs: dict[str, Any] = {}
if task_params.enable_thinking is not None:
# Qwen3 and GLM use "enable_thinking"; DeepSeek uses "thinking".
@@ -638,6 +633,7 @@ class NullKVCache(KVCache):
@property
def state(self) -> tuple[mx.array, mx.array]:
# matches what mx.save_safetensors / mx.eval expect
assert self.keys is not None and self.values is not None
return self.keys, self.values
@state.setter
@@ -739,12 +735,9 @@ def _parse_kimi_tool_calls(text: str):
if func_args_match is None:
raise ValueError("No tool call arguments found.")
func_args = func_args_match.group(1)
try:
arg_dct = json.loads(func_args) # pyright: ignore[reportAny]
except Exception:
arg_dct = None
arg_dct = json.loads(func_args) # pyright: ignore[reportAny]
return dict(id=tool_call_id, name=func_name, arguments=arg_dct)
return dict(id=tool_call_id, name=func_name, arguments=arg_dct) # pyright: ignore[reportAny]
tool_matches = _tool_call_split_regex.findall(text)
if tool_matches:
+22 -10
View File
@@ -2,13 +2,13 @@ from collections import defaultdict
from datetime import datetime, timezone
import anyio
from anyio import fail_after
from anyio import fail_after, to_thread
from loguru import logger
from exo.download.download_utils import resolve_model_in_path
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.models.model_cards import ModelId
from exo.shared.types.api import ImageEditsTaskParams
from exo.shared.models.model_cards import ModelId, add_to_card_cache, delete_custom_card
from exo.shared.types.commands import (
ForwarderCommand,
ForwarderDownloadCommand,
@@ -16,6 +16,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,
@@ -78,6 +80,7 @@ class Worker:
self.input_chunk_counts: dict[CommandId, int] = {}
self._download_backoff: KeyedBackoff[ModelId] = KeyedBackoff(base=0.5, cap=10.0)
self._stopped: anyio.Event = anyio.Event()
async def run(self):
logger.info("Starting Worker")
@@ -100,6 +103,7 @@ class Worker:
self.download_command_sender.close()
for runner in self.runners.values():
runner.shutdown()
self._stopped.set()
async def _forward_info(self, recv: Receiver[GatheredInfo]):
with recv as info_stream:
@@ -130,6 +134,13 @@ class Worker:
event.chunk.data
)
if isinstance(event, CustomModelCardAdded):
await event.model_card.save_to_custom_dir()
add_to_card_cache(event.model_card)
if isinstance(event, CustomModelCardDeleted):
await delete_custom_card(event.model_id)
async def plan_step(self):
while True:
await anyio.sleep(0.1)
@@ -170,11 +181,11 @@ class Worker:
model_id = shard.model_card.model_id
self._download_backoff.record_attempt(model_id)
found_path = resolve_model_in_path(model_id)
found_path = await to_thread.run_sync(
resolve_existing_model, model_id
)
if found_path is not None:
logger.info(
f"Model {model_id} found in EXO_MODELS_PATH at {found_path}"
)
logger.info(f"Model {model_id} found at {found_path}")
await self.event_sender.send(
NodeDownloadProgress(
download_progress=DownloadCompleted(
@@ -182,7 +193,7 @@ class Worker:
shard_metadata=shard,
model_directory=str(found_path),
total=shard.model_card.storage_size,
read_only=True,
read_only=is_read_only_model_dir(found_path),
)
)
)
@@ -271,8 +282,9 @@ class Worker:
case task:
await self._start_runner_task(task)
def shutdown(self):
async def shutdown(self):
self._tg.cancel_tasks()
await self._stopped.wait()
async def _start_runner_task(self, task: Task):
if (instance := self.state.instances.get(task.instance_id)) is not None:
+3
View File
@@ -8,6 +8,7 @@ from exo.shared.types.tasks import Task, TaskId
from exo.shared.types.worker.instances import BoundInstance
from exo.shared.types.worker.runners import RunnerFailed
from exo.utils.channels import ClosedResourceError, MpReceiver, MpSender
from exo.worker.engines.mlx.patches import apply_mlx_patches
logger: "loguru.Logger" = loguru.logger
@@ -45,6 +46,8 @@ def entrypoint(
else:
from exo.worker.runner.llm_inference.runner import Runner
apply_mlx_patches()
runner = Runner(
bound_instance, event_sender, task_receiver, cancel_receiver
)
+81 -119
View File
@@ -4,10 +4,14 @@ from typing import TYPE_CHECKING, Literal
import mlx.core as mx
from exo.api.types import (
ImageEditsTaskParams,
ImageGenerationStats,
ImageGenerationTaskParams,
)
from exo.shared.constants import EXO_MAX_CHUNK_SIZE, EXO_TRACING_ENABLED
from exo.shared.models.model_cards import ModelTask
from exo.shared.tracing import clear_trace_buffer, get_trace_buffer
from exo.shared.types.api import ImageGenerationStats
from exo.shared.types.chunks import ErrorChunk, ImageChunk
from exo.shared.types.common import CommandId, ModelId
from exo.shared.types.events import (
@@ -235,6 +239,77 @@ class Runner:
def acknowledge_task(self, task: Task):
self.event_sender.send(TaskAcknowledged(task_id=task.task_id))
def _check_cancelled(self, task_id: TaskId) -> bool:
for cancel_id in self.cancel_receiver.collect():
self.cancelled_tasks.add(cancel_id)
return (
task_id in self.cancelled_tasks or CANCEL_ALL_TASKS in self.cancelled_tasks
)
def _run_image_task(
self,
task: Task,
task_params: ImageGenerationTaskParams | ImageEditsTaskParams,
command_id: CommandId,
) -> None:
assert self.image_model
logger.info(f"received image task: {str(task)[:500]}")
logger.info("runner running")
self.update_status(RunnerRunning())
self.acknowledge_task(task)
def cancel_checker() -> bool:
return self._check_cancelled(task.task_id)
try:
image_index = 0
for response in generate_image(
model=self.image_model,
task=task_params,
cancel_checker=cancel_checker,
):
if _is_primary_output_node(self.shard_metadata):
match response:
case PartialImageResponse():
logger.info(
f"sending partial ImageChunk {response.partial_index}/{response.total_partials}"
)
_process_image_response(
response,
command_id,
self.shard_metadata,
self.event_sender,
image_index,
)
case ImageGenerationResponse():
logger.info("sending final ImageChunk")
_process_image_response(
response,
command_id,
self.shard_metadata,
self.event_sender,
image_index,
)
image_index += 1
except Exception as e:
if _is_primary_output_node(self.shard_metadata):
self.event_sender.send(
ChunkGenerated(
command_id=command_id,
chunk=ErrorChunk(
model=self.shard_metadata.model_card.model_id,
finish_reason="error",
error_message=str(e),
),
)
)
raise
finally:
_send_traces_if_enabled(self.event_sender, task.task_id, self.device_rank)
self.current_status = RunnerReady()
logger.info("runner ready")
def main(self):
with self.task_receiver as tasks:
for task in tasks:
@@ -306,124 +381,11 @@ class Runner:
self.current_status = RunnerReady()
logger.info("runner ready")
case ImageGeneration(task_params=task_params, command_id=command_id) if (
isinstance(self.current_status, RunnerReady)
):
assert self.image_model
logger.info(f"received image generation request: {str(task)[:500]}")
logger.info("runner running")
self.update_status(RunnerRunning())
self.acknowledge_task(task)
try:
image_index = 0
for response in generate_image(
model=self.image_model, task=task_params
):
is_primary_output = _is_primary_output_node(self.shard_metadata)
if is_primary_output:
match response:
case PartialImageResponse():
logger.info(
f"sending partial ImageChunk {response.partial_index}/{response.total_partials}"
)
_process_image_response(
response,
command_id,
self.shard_metadata,
self.event_sender,
image_index,
)
case ImageGenerationResponse():
logger.info("sending final ImageChunk")
_process_image_response(
response,
command_id,
self.shard_metadata,
self.event_sender,
image_index,
)
image_index += 1
# can we make this more explicit?
except Exception as e:
if _is_primary_output_node(self.shard_metadata):
self.event_sender.send(
ChunkGenerated(
command_id=command_id,
chunk=ErrorChunk(
model=self.shard_metadata.model_card.model_id,
finish_reason="error",
error_message=str(e),
),
)
)
raise
finally:
_send_traces_if_enabled(
self.event_sender, task.task_id, self.device_rank
)
self.current_status = RunnerReady()
logger.info("runner ready")
case ImageEdits(task_params=task_params, command_id=command_id) if (
isinstance(self.current_status, RunnerReady)
):
assert self.image_model
logger.info(f"received image edits request: {str(task)[:500]}")
logger.info("runner running")
self.update_status(RunnerRunning())
self.acknowledge_task(task)
try:
image_index = 0
for response in generate_image(
model=self.image_model, task=task_params
):
if _is_primary_output_node(self.shard_metadata):
match response:
case PartialImageResponse():
logger.info(
f"sending partial ImageChunk {response.partial_index}/{response.total_partials}"
)
_process_image_response(
response,
command_id,
self.shard_metadata,
self.event_sender,
image_index,
)
case ImageGenerationResponse():
logger.info("sending final ImageChunk")
_process_image_response(
response,
command_id,
self.shard_metadata,
self.event_sender,
image_index,
)
image_index += 1
except Exception as e:
if _is_primary_output_node(self.shard_metadata):
self.event_sender.send(
ChunkGenerated(
command_id=command_id,
chunk=ErrorChunk(
model=self.shard_metadata.model_card.model_id,
finish_reason="error",
error_message=str(e),
),
)
)
raise
finally:
_send_traces_if_enabled(
self.event_sender, task.task_id, self.device_rank
)
self.current_status = RunnerReady()
logger.info("runner ready")
case (
ImageGeneration(task_params=task_params, command_id=command_id)
| ImageEdits(task_params=task_params, command_id=command_id)
) if isinstance(self.current_status, RunnerReady):
self._run_image_task(task, task_params, command_id)
case Shutdown():
logger.info("runner shutting down")
@@ -195,21 +195,29 @@ class SequentialGenerator(InferenceGenerator):
assert self._active is not None
task, mlx_gen, queue, output_generator = self._active
response = None
output: list[
tuple[TaskId, GenerationResponse | ToolCallResponse | Cancelled | Finished]
] = []
try:
queue.push(next(mlx_gen))
response = next(output_generator)
response = next(mlx_gen)
queue.push(response)
# drain potentially many responses every time
while (parsed := next(output_generator, None)) is not None:
output.append((task.task_id, parsed))
except (StopIteration, PrefillCancelled):
response = Finished()
output.append((task.task_id, Finished()))
self._active = None
if self._queue:
self._start_next()
except Exception as e:
self._send_error(task, e)
self._active = None
raise
return itertools.chain(
[] if response is None else [(task.task_id, response)],
output,
map(lambda task: (task, Cancelled()), self._cancelled_tasks),
)
@@ -427,11 +435,11 @@ class BatchGenerator(InferenceGenerator):
task, queue, output_generator = self._active_tasks[uid]
queue.push(response)
parsed = next(output_generator)
if parsed is not None:
# If a generator fails to parse for some reason and returns early, we should not crash
while (parsed := next(output_generator, None)) is not None:
output.append((task.task_id, parsed))
# check if original response was terminal and append a Finished()
if response.finish_reason is not None:
output.append((task.task_id, Finished()))
del self._active_tasks[uid]
@@ -13,7 +13,7 @@ from openai_harmony import ( # pyright: ignore[reportMissingTypeStubs]
load_harmony_encoding,
)
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
from exo.shared.types.common import ModelId
from exo.shared.types.mlx import Model
from exo.shared.types.worker.runner_response import GenerationResponse, ToolCallResponse
@@ -159,11 +159,42 @@ def parse_deepseek_v32(
# Text accumulated during a tool call block
tool_call_text = ""
def _try_parse_tool_call(
text: str, response: GenerationResponse
) -> ToolCallResponse | GenerationResponse:
parsed = parse_dsml_output(text)
if parsed is not None:
return ToolCallResponse(
tool_calls=parsed, usage=response.usage, stats=response.stats
)
logger.warning(f"DSML tool call parsing failed for: {text}")
return response.model_copy(update={"text": text})
for response in responses:
if response is None:
yield None
continue
if response.finish_reason is not None:
yield from pending_buffer
pending_buffer.clear()
if in_tool_call:
tool_call_text += response.text
yield (
_try_parse_tool_call(tool_call_text, response)
if TOOL_CALLS_END in tool_call_text
else response.model_copy(update={"text": tool_call_text})
)
elif TOOL_CALLS_START in response.text and TOOL_CALLS_END in response.text:
dsml_start = response.text.index(TOOL_CALLS_START)
before = response.text[:dsml_start]
if before:
yield response.model_copy(update={"text": before})
yield _try_parse_tool_call(response.text[dsml_start:], response)
else:
yield response
break
# ── Handle thinking tags ──
if not thinking and THINKING_START in response.text:
thinking = True
@@ -191,28 +222,7 @@ def parse_deepseek_v32(
if in_tool_call:
tool_call_text += response.text
if TOOL_CALLS_END in tool_call_text:
# Parse the accumulated DSML block
parsed = parse_dsml_output(tool_call_text)
if parsed is not None:
logger.info(f"parsed DSML tool calls: {parsed}")
yield ToolCallResponse(
tool_calls=parsed,
usage=response.usage,
stats=response.stats,
)
else:
logger.warning(
f"DSML tool call parsing failed for: {tool_call_text}"
)
yield response.model_copy(update={"text": tool_call_text})
in_tool_call = False
tool_call_text = ""
continue
# EOS reached before end marker — yield buffered text as-is
if response.finish_reason is not None:
logger.info("DSML tool call parsing interrupted by EOS")
yield response.model_copy(update={"text": tool_call_text})
yield _try_parse_tool_call(tool_call_text, response)
in_tool_call = False
tool_call_text = ""
continue
@@ -228,33 +238,22 @@ def parse_deepseek_v32(
if pre_text:
# Flush pending buffer tokens that contributed text before the marker
for buf_resp in pending_buffer:
if pre_text:
chunk = buf_resp.text
if len(chunk) <= len(pre_text):
yield buf_resp
pre_text = pre_text[len(chunk) :]
else:
yield buf_resp.model_copy(update={"text": pre_text})
pre_text = ""
if not pre_text:
break
chunk = buf_resp.text
if len(chunk) <= len(pre_text):
yield buf_resp
pre_text = pre_text[len(chunk) :]
else:
yield buf_resp.model_copy(update={"text": pre_text})
pre_text = ""
pending_buffer = []
tool_call_text = accumulated[start_idx:]
accumulated = ""
# Check if the end marker is already present (entire tool call in one token)
if TOOL_CALLS_END in tool_call_text:
parsed = parse_dsml_output(tool_call_text)
if parsed is not None:
logger.info(f"parsed DSML tool calls: {parsed}")
yield ToolCallResponse(
tool_calls=parsed,
usage=response.usage,
stats=response.stats,
)
else:
logger.warning(
f"DSML tool call parsing failed for: {tool_call_text}"
)
yield response.model_copy(update={"text": tool_call_text})
yield _try_parse_tool_call(tool_call_text, response)
tool_call_text = ""
else:
in_tool_call = True
@@ -267,15 +266,13 @@ def parse_deepseek_v32(
continue
# No partial match — flush all pending tokens and the current one
for buf_resp in pending_buffer:
yield buf_resp
pending_buffer = []
yield from pending_buffer
pending_buffer.clear()
accumulated = ""
yield response
# Flush any remaining pending buffer at generator end
for buf_resp in pending_buffer:
yield buf_resp
yield from pending_buffer
def _could_be_dsml_prefix(text: str) -> bool:
@@ -358,8 +355,10 @@ def parse_tool_calls(
if parsed is None:
logger.warning(f"tool call parsing failed for text {combined}")
yield response.model_copy(update={"text": combined})
continue
yield response.model_copy(
update={"text": combined, "token": 0, "finish_reason": "error"}
)
break
yield ToolCallResponse(
tool_calls=parsed, usage=response.usage, stats=response.stats
@@ -374,6 +373,7 @@ def parse_tool_calls(
update={
"text": "".join(tool_call_text_parts),
"token": 0,
"finish_reason": "error",
}
)
yield response
@@ -319,7 +319,9 @@ class Runner:
return ExitCode.AllTasksComplete
def send_response(
self, response: GenerationResponse | ToolCallResponse, command_id: CommandId
self,
response: GenerationResponse | ToolCallResponse,
command_id: CommandId,
):
match response:
case GenerationResponse():
@@ -3,7 +3,7 @@ import math
from dataclasses import dataclass
from typing import Any, Callable
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
@dataclass
+36 -36
View File
@@ -110,39 +110,45 @@ class RunnerSupervisor:
async def run(self):
self.runner_process.start()
async with self._tg as tg:
tg.start_soon(self._watch_runner)
tg.start_soon(self._forward_events)
try:
async with self._tg as tg:
tg.start_soon(self._watch_runner)
tg.start_soon(self._forward_events)
finally:
logger.info("Runner supervisor shutting down")
if not self._cancel_watch_runner.cancel_called:
self._cancel_watch_runner.cancel()
with contextlib.suppress(ClosedResourceError):
self._ev_recv.close()
with contextlib.suppress(ClosedResourceError):
self._task_sender.close()
with contextlib.suppress(ClosedResourceError):
self._event_sender.close()
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.send(CANCEL_ALL_TASKS)
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.close()
await to_thread.run_sync(self.runner_process.join, 5)
if self.runner_process.is_alive():
logger.warning(
"Runner process didn't shutdown succesfully, terminating"
)
self.runner_process.terminate()
self.runner_process.join(timeout=5)
# This is overkill but it's not technically bad, just unnecessary.
if self.runner_process.is_alive():
logger.critical("Runner process didn't respond to SIGTERM, killing")
self.runner_process.kill()
self.runner_process.join(timeout=5)
else:
logger.info("Runner process succesfully terminated")
self.runner_process.close()
def shutdown(self):
logger.info("Runner supervisor shutting down")
self._tg.cancel_tasks()
if not self._cancel_watch_runner.cancel_called:
self._cancel_watch_runner.cancel()
with contextlib.suppress(ClosedResourceError):
self._ev_recv.close()
with contextlib.suppress(ClosedResourceError):
self._task_sender.close()
with contextlib.suppress(ClosedResourceError):
self._event_sender.close()
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.send(CANCEL_ALL_TASKS)
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.close()
self.runner_process.join(5)
if not self.runner_process.is_alive():
logger.info("Runner process succesfully terminated")
return
# This is overkill but it's not technically bad, just unnecessary.
logger.warning("Runner process didn't shutdown succesfully, terminating")
self.runner_process.terminate()
self.runner_process.join(1)
if not self.runner_process.is_alive():
return
logger.critical("Runner process didn't respond to SIGTERM, killing")
self.runner_process.kill()
async def start_task(self, task: Task):
if task.task_id in self.pending:
@@ -218,12 +224,6 @@ class RunnerSupervisor:
for tid in self.pending:
self.pending[tid].set()
def __del__(self) -> None:
if self.runner_process.is_alive():
logger.critical("RunnerSupervisor was not stopped cleanly.")
with contextlib.suppress(ValueError):
self.runner_process.kill()
async def _watch_runner(self) -> None:
with self._cancel_watch_runner:
while True:
@@ -0,0 +1,433 @@
# pyright: reportPrivateUsage=false
"""Tests for image generation cancellation logic.
Tests the NaN sentinel protocol, cancellation checking, and the
image runner's cancel_checker integration.
"""
from collections.abc import Callable
from unittest.mock import MagicMock
import mlx.core as mx
from exo.shared.types.tasks import CANCEL_ALL_TASKS, TaskId
from exo.worker.engines.image.pipeline.runner import DiffusionRunner
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_runner() -> DiffusionRunner:
"""Create a DiffusionRunner with minimal config for unit testing.
Uses a mock adapter and no distributed group (single-node).
"""
mock_config = MagicMock()
mock_config.joint_block_count = 10
mock_config.single_block_count = 10
mock_config.total_blocks = 20
mock_config.guidance_scale = None
mock_adapter = MagicMock()
mock_shard = MagicMock()
mock_shard.device_rank = 0
mock_shard.world_size = 1
mock_shard.start_layer = 0
mock_shard.end_layer = 20
runner = DiffusionRunner(
config=mock_config,
adapter=mock_adapter,
group=None,
shard_metadata=mock_shard,
)
return runner
class FakeCancelReceiver:
"""Fake MpReceiver that returns pre-loaded items from collect()."""
def __init__(self, items: list[TaskId] | None = None):
self._items = list(items) if items else []
def collect(self) -> list[TaskId]:
result = self._items
self._items = []
return result
class FakeImageRunner:
"""Fake image runner for testing _check_cancelled logic."""
def __init__(self, cancel_items: list[TaskId] | None = None) -> None:
self.cancel_receiver = FakeCancelReceiver(cancel_items)
self.cancelled_tasks = set[TaskId]()
def _check_cancelled(self, task_id: TaskId) -> bool:
for cancel_id in self.cancel_receiver.collect():
self.cancelled_tasks.add(cancel_id)
return (
task_id in self.cancelled_tasks or CANCEL_ALL_TASKS in self.cancelled_tasks
)
# ---------------------------------------------------------------------------
# _is_sentinel
# ---------------------------------------------------------------------------
class TestIsSentinel:
def test_all_nan_is_sentinel(self) -> None:
runner = _make_runner()
tensor = mx.full((2, 3), float("nan"))
mx.eval(tensor)
assert runner._is_sentinel(tensor) is True
def test_all_zeros_is_not_sentinel(self) -> None:
runner = _make_runner()
tensor = mx.zeros((2, 3))
mx.eval(tensor)
assert runner._is_sentinel(tensor) is False
def test_mixed_nan_and_real_is_not_sentinel(self) -> None:
"""A tensor with some NaN and some real values must NOT be a sentinel.
Using mx.any(isnan) would incorrectly flag this as a sentinel.
"""
runner = _make_runner()
tensor = mx.array([float("nan"), 1.0, 2.0])
mx.eval(tensor)
assert runner._is_sentinel(tensor) is False
def test_single_element_nan(self) -> None:
runner = _make_runner()
tensor = mx.array([float("nan")])
mx.eval(tensor)
assert runner._is_sentinel(tensor) is True
def test_large_tensor_all_nan(self) -> None:
runner = _make_runner()
tensor = mx.full((64, 128, 32), float("nan"))
mx.eval(tensor)
assert runner._is_sentinel(tensor) is True
def test_real_data_not_sentinel(self) -> None:
runner = _make_runner()
tensor = mx.random.normal((4, 8))
mx.eval(tensor)
assert runner._is_sentinel(tensor) is False
# ---------------------------------------------------------------------------
# _check_cancellation
# ---------------------------------------------------------------------------
class TestCheckCancellation:
def test_first_stage_polls_checker(self) -> None:
runner = _make_runner()
assert runner.is_first_stage # single-node is always first stage
checker: Callable[[], bool] = MagicMock(return_value=True)
runner._cancel_checker = checker
runner._check_cancellation()
checker.assert_called_once()
assert runner._cancelling is True
def test_checker_returning_false_does_not_cancel(self) -> None:
runner = _make_runner()
checker: Callable[[], bool] = MagicMock(return_value=False)
runner._cancel_checker = checker
runner._check_cancellation()
assert runner._cancelling is False
def test_no_checker_does_not_cancel(self) -> None:
runner = _make_runner()
runner._cancel_checker = None
runner._check_cancellation()
assert runner._cancelling is False
def test_already_cancelling_skips_checker(self) -> None:
runner = _make_runner()
runner._cancelling = True
checker: Callable[[], bool] = MagicMock(return_value=False)
runner._cancel_checker = checker
runner._check_cancellation()
checker.assert_not_called()
assert runner._cancelling is True # stays True
def test_cancelling_flag_is_false_on_init(self) -> None:
"""_cancelling defaults to False on a fresh runner."""
runner = _make_runner()
assert runner._cancelling is False
# ---------------------------------------------------------------------------
# _send wrapper
# ---------------------------------------------------------------------------
class TestSendWrapper:
def test_send_replaces_data_with_nan_when_cancelling(self) -> None:
"""When _cancelling is True, _send should replace data with NaN."""
runner = _make_runner()
runner._cancelling = True
# _send asserts group is not None, so we need a mock group
runner.group = MagicMock()
data = mx.ones((2, 3))
mx.eval(data)
# Mock mx.distributed.send to capture what's sent
original_send = mx.distributed.send
sent_data: list[mx.array] = []
def mock_send(d: mx.array, dst: int, group: mx.distributed.Group) -> mx.array:
mx.eval(d)
sent_data.append(d)
return d
mx.distributed.send = mock_send
try:
runner._send(data, dst=1)
assert len(sent_data) == 1
mx.eval(sent_data[0])
assert mx.all(mx.isnan(sent_data[0])).item()
assert sent_data[0].shape == (2, 3)
finally:
mx.distributed.send = original_send
def test_send_passes_real_data_when_not_cancelling(self) -> None:
runner = _make_runner()
runner._cancelling = False
runner.group = MagicMock()
data = mx.ones((2, 3))
mx.eval(data)
sent_data: list[mx.array] = []
def mock_send(d: mx.array, dst: int, group: mx.distributed.Group) -> mx.array:
mx.eval(d)
sent_data.append(d)
return d
original_send = mx.distributed.send
mx.distributed.send = mock_send
try:
runner._send(data, dst=1)
assert len(sent_data) == 1
mx.eval(sent_data[0])
assert not mx.any(mx.isnan(sent_data[0])).item()
finally:
mx.distributed.send = original_send
# ---------------------------------------------------------------------------
# Image runner _check_cancelled
# ---------------------------------------------------------------------------
class TestImageRunnerCheckCancelled:
"""Tests for the image runner's _check_cancelled method."""
def test_no_cancellation(self) -> None:
runner = FakeImageRunner()
assert runner._check_cancelled(TaskId("task-1")) is False
def test_specific_task_cancelled(self) -> None:
task_id = TaskId("task-1")
runner = FakeImageRunner([task_id])
assert runner._check_cancelled(task_id) is True
def test_different_task_not_cancelled(self) -> None:
runner = FakeImageRunner([TaskId("task-2")])
assert runner._check_cancelled(TaskId("task-1")) is False
def test_cancel_all_tasks(self) -> None:
runner = FakeImageRunner([CANCEL_ALL_TASKS])
assert runner._check_cancelled(TaskId("any-task")) is True
def test_collect_accumulates(self) -> None:
"""Multiple collect() calls accumulate cancelled task IDs."""
runner = FakeImageRunner([TaskId("task-1")])
runner._check_cancelled(TaskId("task-1"))
# First collect drained the receiver, but task-1 is in cancelled_tasks
assert runner._check_cancelled(TaskId("task-1")) is True
def test_collect_empty_after_drain(self) -> None:
"""After draining, collect returns empty and previous cancellations persist."""
runner = FakeImageRunner([TaskId("task-1")])
# First call drains
runner._check_cancelled(TaskId("other"))
# task-1 is now in cancelled_tasks but "other" was never cancelled
assert runner._check_cancelled(TaskId("other")) is False
assert runner._check_cancelled(TaskId("task-1")) is True
# ---------------------------------------------------------------------------
# Drain condition logic
# ---------------------------------------------------------------------------
class TestDrainCondition:
"""Verify the drain condition evaluates correctly for various scenarios."""
def _should_drain(
self,
*,
cancelling: bool,
is_first_stage: bool,
is_last_stage: bool,
is_distributed: bool,
t: int,
init_time_step: int,
num_sync_steps: int,
num_inference_steps: int,
) -> bool:
"""Replicate the drain condition from _run_diffusion_loop."""
return (
cancelling
and is_first_stage
and not is_last_stage
and is_distributed
and t >= init_time_step + num_sync_steps
and t != num_inference_steps - 1
)
def test_no_drain_during_sync_step(self) -> None:
"""Sync steps have no cross-timestep ring state."""
assert not self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=False,
is_distributed=True,
t=0, # sync step
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_drain_during_async_step(self) -> None:
assert self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=False,
is_distributed=True,
t=3, # async step
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_no_drain_on_last_step(self) -> None:
"""Last step doesn't send, so nothing to drain."""
assert not self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=False,
is_distributed=True,
t=9, # last step
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_no_drain_when_not_cancelling(self) -> None:
assert not self._should_drain(
cancelling=False,
is_first_stage=True,
is_last_stage=False,
is_distributed=True,
t=5,
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_no_drain_on_last_stage(self) -> None:
"""Last stage is also first stage (single pipeline) — no ring."""
assert not self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=True,
is_distributed=True,
t=5,
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_no_drain_single_node(self) -> None:
assert not self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=False,
is_distributed=False,
t=5,
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_no_drain_not_first_stage(self) -> None:
"""Only first stage needs to drain (it's the one receiving)."""
assert not self._should_drain(
cancelling=True,
is_first_stage=False,
is_last_stage=False,
is_distributed=True,
t=5,
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_drain_first_async_step(self) -> None:
"""First async step: last stage sends, so drain is needed."""
assert self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=False,
is_distributed=True,
t=2, # first async step (init=0, sync=2)
init_time_step=0,
num_sync_steps=2,
num_inference_steps=10,
)
def test_drain_with_nonzero_init_time_step(self) -> None:
"""img2img can have init_time_step > 0."""
assert self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=False,
is_distributed=True,
t=5,
init_time_step=3,
num_sync_steps=1,
num_inference_steps=10,
)
def test_no_drain_sync_with_nonzero_init(self) -> None:
assert not self._should_drain(
cancelling=True,
is_first_stage=True,
is_last_stage=False,
is_distributed=True,
t=3,
init_time_step=3,
num_sync_steps=1,
num_inference_steps=10,
)

Some files were not shown because too many files have changed in this diff Show More