Compare commits

...
Author SHA1 Message Date
ciaranbor 13ce4e9052 Warmup = 2 2026-05-10 18:44:55 +01:00
ciaranbor 4ea5244e85 Eco-integrated context scaling benchmarks 2026-05-10 18:11:53 +01:00
Andrei Cravtov 45df74ba98 Andrei/mp capture stdio (#2056)
## Motivation

Process-isolated runner crashes and C-extension failures can write
directly to fd-level stdout/stderr, bypassing Python/loguru. We need to
capture that output per runner process without polluting the main
process or other workers, and without breaking operation when the parent
stdio is detached.

## Changes

- Added `AsyncProcess`, a spawn-only multiprocessing wrapper that
redirects child stdout/stderr to pipes and exposes them as in-memory
`Receiver[bytes]`s
- Replaced runner-supervisor's raw `multiprocessing.Process` usage with
`AsyncProcess`
- Added `--no-stdio`, redirecting stdin/stdout/stderr to `/dev/null`
after logging is configured
- Disabled verbose MLX
- Added tests covering stdio capture, child crashes, repeated bad
children, SIGTERM/SIGKILL shutdown escalation, stdio detachment, and
spawning captured children from a stdio-detached parent

## Why It Works

The parent can redirect its own stdio fds to `/dev/null`, while
`AsyncProcess` installs fresh pipe fds over fd 1 and 2 inside each
spawned child. That keeps stdio-detached parents quiet while preserving
per-runner stdout/stderr capture. Runner shutdown is still bounded:
SIGTERM grace first, then SIGKILL escalation if needed.

Next direction: the runner supervisor currently drains captured output
and logs it as stdout/debug and stderr/warning. This should be split
into more useful process-isolated error reporting instead of just log
forwarding (regex match on errors to obtain "reason" string, best
effort).

## Test Plan

### Manual Testing

Ran on 4 Mac Minis in a Thunderbolt 4 ring, can see that runner's
stdout/stderr contents are being captured.

### Automated Testing

- Added async-process tests for fd-level stdout/stderr capture, Python
traceback capture, bounded-buffer output, child `exit`/abort, parent
stdio preservation, fd leak checks, spawn-context mp channels, and
SIGTERM/SIGKILL shutdown behavior
- Added stdio-detach tests proving stdio detaches to `/dev/null`, a
stdio-detached parent can still spawn and capture a child, and the same
stdio-detached parent can spawn/capture multiple children sequentially
- Updated runner-supervisor tests for the new `AsyncProcess.exitcode`
path
2026-05-09 22:45:14 +01:00
Kerollos Magdy ce37bdceb6 fix: Create directory for PID file if it doesn't exist (#2075)
Ensure the directory for the PID file exists before creating it.

## Motivation

Fixes https://github.com/exo-explore/exo/issues/2074

## 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-05-09 12:10:22 +00:00
Andrei Cravtov e5a1e5dadb Create PID file locking for EXO (#2072)
## Motivation

EXO should be PID file locked, to prevent duplicate processes from
clobbering the log, right now this isn't the case.

## Changes

I added a wrapper around a Rust PID file lock library, and used it to
implement PID locking for EXO, with the PID file being in exo cache
directory.

## Test Plan

### Manual Testing
Tested on e11, trying to spawn duplicate EXO processes prevented.
2026-05-08 18:50:18 +01:00
ciaranbor fa57131374 Integration tests infra (#1995)
## Motivation

No automated integration tests exist for exo. Manual testing against
real hardware clusters is slow and error-prone. We need a pytest
framework that deploys clusters via `eco`, runs inference scenarios, and
tears down cleanly.

## Changes

- **`tools/src/exo_tools/`** — New workspace member shared by bench,
eval, and tests:
- `client.py` — `ExoClient` HTTP client (extracted from
`bench/harness.py`)
- `harness.py` — instance lifecycle helpers (placement, wait-for-ready,
etc.)
- `cluster.py` — `EcoSession` for eco cluster lifecycle
(deploy/stop/start/release/logs/exec) with unique `USER=<prefix>-<uuid>`
per session and atexit/signal cleanup
- **`tests/integration/`** — 17 pytest tests across 5 files:
- `test_1node.py` — place, chat, multi-turn, delete, state/models
endpoints, cluster snapshot, download-from-scratch
- `test_2node.py` — parametrized tensor/jaccl + pipeline/ring inference
and multi-turn
- `test_4node.py` — parametrized 4-node pipeline/ring inference, cluster
state
- `test_resilience.py` — full disconnect/reconnect cycle (2-node →
disconnect → 1-node → reconnect → 2-node)
- `test_dashboard.py` — Playwright: dashboard loads, shows node info,
chat flow
- `helpers.py` — placement/inference helpers, re-exports from
`exo_tools`
- `conftest.py` — session-scoped cluster fixtures with constraint-based
eco reservations; `--hosts` override; `EXO_REF` env var for CI
deployments from a GitHub branch
- **`bench/`** — Updated imports from `exo_tools.client` /
`exo_tools.harness`
- **`pyproject.toml`** — Added `tools` workspace member, `playwright`
dev dep, `--ignore=tests/integration`

## Why It Works

Tests use `eco` for cluster lifecycle and `ExoClient` for API
interactions — same tools humans use. Session-scoped fixtures deploy
once per file. Unique eco users prevent test runs from interfering with
each other or manual usage.

## Test Plan

### Automated Testing

- `uv run pytest tests/integration/ -v -s` — full suite (~4-5 min, 17/17
passing)
- `uv run pytest tests/integration/ -v -s --hosts s4,s9,s10,s22` — pin
specific hosts
- `EXO_REF=main uv run pytest tests/integration/ -v` — deploy from a
GitHub branch (CI)
- `uv run pytest` — confirms integration tests are excluded from default
runs
2026-05-08 17:15:08 +01:00
Alex Cheema 414132ae9c Use time-weighted power sampling (#2038)
## Why

The power sampler currently averages sampled wattage values
arithmetically. That can be materially wrong when sample intervals are
uneven: a short high-power spike gets the same weight as a long steady
interval. Energy should be computed by integrating power over time, and
average power should be derived from energy / elapsed time.

## How

- Store each power sample with its relative timestamp.
- Anchor the first sample at `t=0` and take a final sample at `elapsed`
when producing results.
- Integrate per-node power using the trapezoidal rule.
- Sum node energy for total cluster energy, then derive total average
system power from total energy / elapsed.
- Add focused unit tests for uneven sample intervals and the
single-sample fallback.

## Tests

- `uv run pytest src/exo/utils/tests/test_power_sampler.py`
- `uv run basedpyright`
- `uv run ruff check src/exo/utils/power_sampler.py
src/exo/utils/tests/test_power_sampler.py`
- `nix fmt`
2026-05-07 10:42:14 +00:00
Alex Cheema edef8004f8 Store custom model cards in State (#2024)
## Why

Workers currently update their custom model-card cache by reacting to
`CustomModelCardAdded` / `CustomModelCardDeleted` events directly. That
is another snapshot footgun: a worker restored from State may never see
the historical add/delete event, so the durable State must include the
desired custom-card set.

## How

- Add `State.custom_model_cards`, keyed by `ModelId`.
- Reduce `CustomModelCardAdded` into State.
- Reduce `CustomModelCardDeleted` into State.
- Add focused reducer tests for add and delete.

This PR only makes custom cards durable in State. A follow-up PR will
make workers reconcile their on-disk custom-card cache from this state
instead of relying on those events directly.

## Tests

- `uv run pytest
src/exo/shared/tests/test_apply/test_apply_custom_model_cards.py
src/exo/shared/tests/test_state_serialization.py`
- `uv run pytest`
- `uv run ruff check src/exo/shared/types/state.py
src/exo/shared/apply.py
src/exo/shared/tests/test_apply/test_apply_custom_model_cards.py`
- `uv run basedpyright`
- `nix fmt`
2026-05-07 09:06:39 +01:00
Alex CheemaandClaude Opus 4.7 a0c00f9dfd fix(placement): gate RDMA on nodeRdmaCtl.enabled at both endpoints (#2014)
## Summary

- Fixes a bug where `POST /place_instance` (and the dashboard UI) would
accept an MlxJaccl/RDMA instance spanning nodes whose
`nodeRdmaCtl.enabled` was `false`, because topology + placement
consulted Thunderbolt-derived RDMA edges without checking the per-node
`rdma_ctl` status.
- Three-layer fix: topology only emits `RDMAConnection` edges when both
endpoints have `nodeRdmaCtl.enabled = true`; flipping a node to disabled
immediately purges every RDMA edge touching it; `place_instance`
additionally rejects RDMA cycles containing any disabled or unobserved
node as a defense-in-depth check on the API/master path.

## Details

- `src/exo/shared/apply.py`
- `MacThunderboltConnections` case now filters out RDMA connections
whose source or sink lacks observed-and-enabled `rdma_ctl` status
(missing entry → treated as disabled).
- `RdmaCtlStatus` case now calls
`topology.remove_all_rdma_connections_touching(node_id)` when the node
reports disabled, so consumers don't have to wait for the next TB poll.
- `src/exo/shared/topology.py`
- New `Topology.remove_all_rdma_connections_touching(node_id)` removes
every RDMA edge incident to the node (incoming and outgoing) while
leaving socket edges intact.
- `src/exo/master/placement.py`
- `place_instance` accepts `node_rdma_ctl: Mapping[NodeId,
NodeRdmaCtlStatus] | None`. The `is_rdma_cycle` filter now also requires
`nodeRdmaCtl.enabled` for every node in the cycle. MlxJaccl placement
raises the existing "no RDMA-connected cycles available" error if no
qualifying cycle remains.
- `src/exo/api/main.py`, `src/exo/master/main.py`
  - Both placement entrypoints now pass `state.node_rdma_ctl` through.

## Tests

- `src/exo/shared/tests/test_apply/test_apply_rdma_gating.py` (new): six
unit tests covering enabled/disabled/missing combinations on apply, the
immediate-purge transition, and that purging RDMA edges leaves socket
edges untouched.
- `src/exo/master/tests/test_placement.py`: existing
`test_tensor_rdma_backend_connectivity_matrix` updated to pass
`node_rdma_ctl`. Two new tests assert MlxJaccl placement is rejected
when any cycle node is `enabled=false` or has no `rdma_ctl` entry.

## Test plan

- [x] `uv run basedpyright` — 0 errors
- [x] `uv run ruff check` — clean
- [x] `nix fmt`
- [x] `uv run pytest` — 429 passed, 1 skipped
- [ ] On a real mixed cluster (s15/s16 disabled, s17/s18 enabled),
confirm:
- [ ] `POST /place_instance` for an RDMA instance including s15 or s16
returns an error
  - [ ] An RDMA instance can still be placed across {s17, s18}
- [ ] `GET /state` shows no `sourceRdmaIface`/`sinkRdmaIface` on s15↔s16
connections
- [ ] Dashboard previews don't surface RDMA-spanning options that
include s15/s16

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:00:15 +00:00
Drifter4242 89d20c1888 fix(inference): prevent TP collective deadlock via agree_on_tasks order (#2048)
If you have two machines and make two requests at the same time, it can
crash. This is because the tasks can sometimes end up in different
orders on different machines. We need to sort the tasks and
mx_all_gather_tasks already sorts the tasks but the code ignores that
ordering. The fix is to make sure the sort order is preserved.

The rest is written by Sonnet (reviewed by me):

Tensor-parallel inference requires that every rank enqueues tasks in the
same order before running agree_on_tasks collectives. The old
implementation filtered from _maybe_queue:

self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in
different]

Because _maybe_queue is independently ordered per-rank (tasks arrive via
gRPC in whatever order the API server sends them), two concurrent
requests could produce different _maybe_queue orderings on rank 0 vs
rank 1. The filter then preserved those different orders into _queue, so
each rank started processing tasks in a different sequence. The next mlx
collective (all_reduce, all_gather, etc.) on rank 0 corresponded to a
different task than on rank 1 → permanent deadlock.

Fix: extend from agreed directly. mx_all_gather_tasks returns agreed as
a list sorted by task_id on all ranks, so every rank appends the same
sequence regardless of local arrival order.

Applies to both SequentialGenerator and BatchGenerator.

## Motivation

`agree_on_tasks` is called on every rank after accumulating new requests
in
`_maybe_queue`. Its job is to run an `all_gather` collective so all
ranks agree
on which tasks to promote to `_queue` before the next inference step.

The old implementation re-imposed **local arrival order** when extending
`_queue`:

```python
self._queue.extend(task for task in self._maybe_queue if task in agreed)
```

`mx_all_gather_tasks` already returns `agreed` sorted by `task_id` — the
same
deterministic order on every rank. But iterating `self._maybe_queue`
instead of
`agreed` discarded that sort and substituted the local gRPC arrival
order, which
differs per rank under concurrent load. Two concurrent requests arriving
in
`[A, B]` order on rank 0 and `[B, A]` on rank 1 caused the first MLX
collective
in the next step to hang permanently: each rank was executing a
different task's
collective and would never match.

## Changes

`SequentialGenerator.agree_on_tasks` and
`BatchGenerator.agree_on_tasks`:

```python
# Before
self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in different]

# After
self._queue.extend(agreed)          # preserves mx_all_gather_tasks sort order
self._maybe_queue = list(different) # already in local order; filter was redundant
```

## Why It Works

`mx_all_gather_tasks` (in `utils_mlx.py`) computes the agreed set then
sorts by
`task_id`:

```python
agreed = [local_tasks[tid] for tid in sorted(agreed_ids)]
```

Because `task_id` is a UUID and the sort is lexicographic, every rank
produces
the same `agreed` list regardless of local arrival order. Using `agreed`
directly
preserves this guarantee. The `different` list (tasks not yet seen on
all ranks)
is built by iterating `tasks` in local order, which is already correct.

## Test Plan

### Manual Testing

**Hardware:** 2× Mac Studio M3 Ultra 512 GB, Thunderbolt 5 direct
bridge,
`MlxJaccl` RDMA tensor-parallel (`moonshotai/Kimi-K2.6`, 595 GB INT4, 61
layers).

- Sent concurrent streaming requests; confirmed all complete without
deadlock.
- This hardware configuration (sub-millisecond inter-node latency) is
the most
likely to trigger the race, as requests from separate HTTP connections
can
reach rank 0 and rank 1 in opposite order before `agree_on_tasks` runs.

### Automated Testing

All existing tests pass: `pytest src -m "not slow"
--import-mode=importlib`
— 422/422 passed. The existing `test_event_ordering.py` covers the
`agree_on_tasks` call path with a mock that returns tasks in consistent
order;
the race requires real distributed hardware to reproduce
deterministically.
2026-05-06 12:24:58 +00:00
Evan Quineyandciaranbor dbcceaa50c Initialise _cancelled_tasks in ImageEngine (#2051)
we yielded nonsense chunks from engines; we didn't initialize the image
engine correctly. mostly rewrite of #2049

---------

Co-authored-by: ciaranbor <ciaranborourke-dev@proton.me>
2026-05-05 17:27:57 +01:00
Sam BradburyandSam Bradbury 9c6ff4ce95 feat: update rdma_ctl instructions (#1977)
## Motivation

The RDMA setup instructions were missing a step: after booting to
Recovery mode, users need to open Terminal from the Utilities menu
before they can run the `rdma_ctl` command. Without this step, users
following the instructions wouldn't know how to access a terminal in
Recovery mode. This step was already in the README just not in the UI
notifications.

## Changes

Added a missing instruction step — "Open Terminal from the Utilities
menu" — to three instances of the RDMA setup flow in
`dashboard/src/routes/+page.svelte`.

## Why It Works

N/A copy change only. 

## Test Plan

### Manual Testing
Hardware: MacBook Pro M4 Max 48GB

### Automated Testing
No automated tests affected; this is a UI copy change only.

Co-authored-by: Sam Bradbury <sam@consultbradbury.com>
2026-05-01 11:18:57 +00:00
ecohash-coandJordan Miller b26268dfaf fix(macos-app): disable URL response caching for cluster-state polling (#2005)
Fixes #2004.

`ClusterStateService` polls `/state` at 2 Hz via `URLSession.shared`,
which keeps an on-disk `URLCache` attached by default. Every polled
response body gets persisted under `~/Library/Caches/exolabs.EXO/`,
sustaining ~500–620 KB/sec of file-backed memory dirtied — far above
macOS's ~25 KB/sec per-process daily-average baseline. Six
microstackshot reports observed on a single Mac Studio M3 Ultra over
eight days, with one 15-hour run accumulating 34.36 GB of cache writes.

Heaviest stack on every diagnostic report (96–98% of samples):

```
_dispatch_workloop_worker_thread → _dispatch_block_async_invoke2 →
  __CFURLCache::CreateAndStoreCacheNode → write
```

Full diagnostic data and analysis in #2004.

## What changed

`ClusterStateService` now defaults to an ephemeral, non-caching
`URLSession` instead of `URLSession.shared`. Cluster-state responses are
time-sensitive and small; nothing benefits from being cached on disk.

```swift
private static func makeNonCachingSession() -> URLSession {
    let config = URLSessionConfiguration.ephemeral
    config.urlCache = nil
    config.requestCachePolicy = .reloadIgnoringLocalCacheData
    return URLSession(configuration: config)
}
```

The existing per-request `request.cachePolicy =
.reloadIgnoringLocalCacheData` calls are kept as defense in depth — they
only affect read behavior, but harmless to leave alongside the
session-level config.

## Scope

- **Behavioral**: none. Polled requests still go out at the same
cadence; responses still parse the same; no semantic change to any API
surface.
- **Test injection**: the `session:` parameter remains in `init`, so
tests can still inject a custom mock session unchanged.
- **`BugReportService` and other `URLSession.shared` callers**:
untouched. If maintainers prefer an app-wide URLCache disable instead,
happy to switch the approach (issue body has the alternative spelled
out).

## Verification

Verified locally that compiling EXO with this change produces a working
menubar app and `ClusterStateService` continues to fetch state
correctly. After ~30 min of idle polling, no new entries in
`/Library/Logs/DiagnosticReports/EXO_*.diag` and no growth in
`~/Library/Caches/exolabs.EXO/`.

## Test plan
- [ ] Build EXO from this branch on macOS 26.4
- [ ] Launch, let cluster state polling run for 30+ min
- [ ] Confirm no new microstackshot diagnostic reports
- [ ] Confirm `~/Library/Caches/exolabs.EXO/Cache.db*` does not grow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Jordan Miller <jordan.d.miller@gmail.com>
2026-05-01 10:41:10 +00:00
ciaranbor 8dae3ecb9a A few targeted tweaks to address HF rate limits (#2009)
## Motivation

- exo bursts ~200 HF Hub-API requests on every cold start, blowing past
the anonymous 500-req/5-min budget.
- The existing retry loop catches 429 generically and gives up in ~3s —
well before HF's reset window.
- `file_meta` and `_download_file` had no 429 handling at all (became
`AssertionError`).
- Disk file-list cache was bypassed on every process restart.

## Changes

All in `src/exo/download/download_utils.py` + tests.

- Parse `t=` from HF's `RateLimit` header on 429; sleep `min(t, 300s) +
jitter`.
- Handle 429 at all three call sites (`_fetch_file_list`, `file_meta`,
`_download_file`).
- `n_attempts`: 3 → 5.
- Disk cache now primary across restarts (24h mtime TTL).
- `?recursive=true` instead of N+1 subdir walks.

## Why It Works

`t=<seconds>` is HF's "wait this long and you'll be unblocked" —
sleeping that long lets the window reset. Disk-cache-as-primary plus
recursive listing cuts cold-start Hub-API traffic by ~10×.

## Test Plan

### Manual Testing

MacBook Pro M1 Max. Tripped the real HF 429. Pre-fix: failed in 3.4s.
Post-fix: slept (HF returned `t=158`) and recovered.

### Automated Testing

- New `test_rate_limit_handling.py` (19 tests) — header parsing,
retry-loop behaviour, plus HTTP-level coverage that mocks aiohttp to
return a 429 and asserts each call site raises
`HuggingFaceRateLimitError(retry_after=52.0)`.
- New `TestFileListCacheTTL` in `test_offline_mode.py` — fresh cache
hits, stale cache refetches.
- 421 tests pass; basedpyright / ruff / nix fmt clean.
2026-04-30 18:06:15 +00:00
Alex CheemaandClaude Opus 4.7 fb12b403ea fix(app): tighten Share Bug Report prompt layout (#2008)
## Summary

Follow-ups to #2003 based on feedback that the Share Bug Report window
felt visually weighty: too much padding above and below, and a
description editor that invited an essay rather than a one-liner.

## Changes (one file)

`app/EXO/EXO/Views/BugReportWindowController.swift`:

- **Auto-size the window to its content.** Switched from `NSHostingView`
+ fixed `contentRect: 480x380` + SwiftUI `frame(minHeight: 320)` to
`NSHostingController` with `sizingOptions = [.preferredContentSize,
.minSize]`. The fixed-min combo was centering the form in dead vertical
space.
- **Smaller, lower-pressure editor.** Field is now labeled `Description
(optional)` with a placeholder hint (`What were you doing when it
broke?`) inside the editor. Editor height fixed at 72pt (was 120pt min).
Replaced the long lead-in paragraph and headline with a single one-line
caption between field and buttons: `Diagnostic logs will be uploaded
with your report.`
- **Tighter spacing.** Outer padding 20 -> 16, root spacing 16 -> 12,
prompting-section spacing 12 -> 8.
- **Remove em dash from copy.**

`BugReportService` and the menu wiring are unchanged.

## Test plan

- [ ] Click `Share Bug Report...` from the menu bar.
- [ ] The window opens centered and sized to its content (no big empty
bands top/bottom).
- [ ] Description editor is visibly compact, with the placeholder hint
showing when empty.
- [ ] The optional-ness is conveyed by the field label (no separate help
paragraph).
- [ ] Caption `Diagnostic logs will be uploaded with your report.`
appears in `.caption` style under the editor, above the buttons.
- [ ] Resize the window: persists across re-opens (frame autosave still
works).
- [ ] Send/Cancel/Try Again/Done flows behave the same as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:10:26 +01:00
Alex CheemaandClaude Opus 4.7 1606e63816 feat(app): open Share Bug Report in a dedicated window (#2003)
## Summary

- Adds a top-level **Share Bug Report…** menu item to the macOS popover
(between *Check for Updates* and *Quit*) with SF Symbol `ladybug`.
- Clicking it opens a dedicated resizable `NSWindow` ("Send a Bug
Report") that hosts the prompting / sending / success / failure flow.
- Removes the description-less duplicate from Settings → Debug Info, and
the dead `debugSection` it nominally lived behind.

## Why

PR #1959 added a user-description prompt to the bug-report flow, but its
trigger lived inside `ContentView.debugSection` — a view that's defined
but never rendered in the body. The path users actually hit was
`SettingsView.sendBugReportButton`, which called
`BugReportService.sendReport(isManual: true)` without ever passing
`userDescription`. So the description prompt was unreachable in the
built app.

## Approach

Per Apple HIG, an action that requires further input before completing
should open a dialog, not transform the menu inline. So:

- Add a top-level menu entry that ends in `…` (HIG: ellipsis indicates
"further input required").
- Move the prompting/sending/success/failure state machine into a
standalone `BugReportWindowController` modeled after the existing
`SettingsWindowController`.
- Single-instance window with frame-autosave name, sensible
`contentMinSize`, resizable, native button layout (`.cancelAction` /
`.defaultAction` keyboard shortcuts), light/dark-mode-correct
`.textBackgroundColor` and `.separatorColor`.
- Auto-focus the description field on open. `Try Again` from failure,
`Open GitHub Issue` + `Done` from success.

## Files

- `app/EXO/EXO/Views/BugReportWindowController.swift` (new) — controller
+ view.
- `app/EXO/EXO/EXOApp.swift` — wire `BugReportWindowController` as a
`@StateObject` and inject as environment object.
- `app/EXO/EXO/ContentView.swift` — replace inline state machine with
menu item that calls `bugReportWindowController.open()`. Remove
now-unused state, helpers, and dead `debugSection`.
- `app/EXO/EXO/Views/SettingsView.swift` — remove duplicate
`sendBugReportButton`, `sendBugReport()`, and related `@State`. Section
"Debug Info" keeps Thunderbolt / interface / RDMA info.

`BugReportService` is unchanged.

## Test plan

- [ ] Open the menu-bar popover → confirm **Share Bug Report…** appears
between *Check for Updates* and *Quit*, with a ladybug icon.
- [ ] Click it → a window titled "Send a Bug Report" appears, centered,
with the description editor focused.
- [ ] Resize the window → size persists across re-opens (frame
autosave).
- [ ] Type a description, press Return → upload succeeds, success card
with **Open GitHub Issue** + **Done** appears.
- [ ] Click **Open GitHub Issue** → browser opens with the description
pre-filled into the issue template.
- [ ] Send with empty description → upload still succeeds.
- [ ] Press Esc from the prompting state → window closes.
- [ ] On failure (e.g., offline) → error card with **Try Again** +
**Close** appears; Try Again returns to the editor with the description
preserved.
- [ ] Open the Settings window → Debug Info section is unchanged except
the Send Bug Report button is gone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:05:07 +01:00
667a3bb0e5 feat: keep-models option when uninstalling EXO (#1997)
## Summary

- Adds a **Keep downloaded models (~/.exo/models)** checkbox to the
macOS uninstall confirmation dialog (Settings → Advanced → Danger Zone).
The full `~/.exo` directory is now removed on uninstall by default; if
the checkbox is checked, `~/.exo/models` is preserved.
- The standalone `app/EXO/uninstall-exo.sh` gains a matching
`--keep-models` flag and the same `~/.exo` cleanup so GUI and CLI flows
stay in sync. Resolves the user home via `$SUDO_USER` since the script
runs under `sudo`.

Previously, "Uninstall EXO" only cleaned up system-level components
(LaunchDaemon, network location, logs, app bundle) and left the entire
`~/.exo` directory behind. Now uninstalling actually removes EXO's user
data, with a one-click opt-out for the (potentially many GB) of
downloaded models.

![Uninstall dialog with new
checkbox](https://raw.githubusercontent.com/exo-explore/exo/703b7fbbf13441217ad2903bb199f07e92af4490/uninstall-dialog.png)

> Note: the rendered icon in the screenshot above is the generic system
folder icon because it was captured from a small standalone Swift binary
(no app bundle / icon resource). When triggered from the actual EXO.app,
the EXO app icon is shown.

## Test plan

- [ ] Build EXO.app locally; open Settings → Advanced → Danger Zone →
Uninstall EXO; confirm the new "Keep downloaded models (~/.exo/models)"
checkbox is present and unchecked by default.
- [ ] Uninstall with the checkbox **checked** → `~/.exo/models/`
survives, all other entries under `~/.exo` are gone, system components
removed, app moved to Trash.
- [ ] Uninstall with the checkbox **unchecked** → `~/.exo` is fully
removed.
- [ ] `sudo app/EXO/uninstall-exo.sh --keep-models` → `~/.exo/models/`
is preserved, the rest of `~/.exo` is removed.
- [ ] `sudo app/EXO/uninstall-exo.sh` (no flag) → `~/.exo` is fully
removed.
- [ ] `app/EXO/uninstall-exo.sh --help` prints usage and exits 0;
unknown args exit 2 with a usage hint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan <evanev7@gmail.com>
2026-04-28 01:06:02 +00:00
Evan Quiney c80b10c013 implement engine abstraction for mlx and mflux (#2000)
refactor for future versions.
2026-04-28 00:58:17 +00:00
Alex CheemaandClaude Opus 4.7 18ffe1df23 fix: uninstall-exo.sh removes both current and legacy bridge scripts (#1998)
## Summary

The standalone `app/EXO/uninstall-exo.sh` only knew about the legacy
filename `disable_bridge_enable_dhcp.sh`. On machines installed with
newer EXO versions, the current `/Library/Application
Support/EXO/disable_bridge.sh` was left behind, and the script then
reported `EXO support directory not empty, leaving in place`.

This PR makes the script try both filenames, removing whichever ones
exist. Tolerates **either**, **both**, or **neither** being present
without erroring.

The Swift `NetworkSetupHelper.makeUninstallScript()` already handles
both paths correctly, so the GUI uninstall flow is unaffected — this is
a script-only fix.

Caught while running an end-to-end uninstall on a real machine for
#1997.

## Test plan

Verified the new block in isolation against all four states:

- [x] both `disable_bridge.sh` and `disable_bridge_enable_dhcp.sh`
present → both removed
- [x] only `disable_bridge.sh` present → removed cleanly
- [x] only `disable_bridge_enable_dhcp.sh` present → removed cleanly
(legacy install)
- [x] neither present → prints the existing "already removed?" warning,
exits 0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:28:12 +00:00
rltakashige f0d1371d89 MLX P/D (#1993)
## Motivation

MLX only prefill server for Apple Silicon
2026-04-28 00:12:42 +00:00
5d10188d3a fix: route by in-flight tasks only — completed tasks were skewing load balance (#1989)
The load balancer counted ALL tasks (Complete, Cancelled, TimedOut,
Failed) instead of only Pending/Running ones. With 138 accumulated tasks
and only 7 active, routing decisions were based on historical
distribution, causing one node to appear permanently 'busier' and
starving the other of work.

Co-authored-by: Adam Durham <adam@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:03:12 +00:00
ciaranbor f2a0db4e23 Extend bench/eval tooling (#1905)
## Motivation

Extend bench/eval tooling with robustness features, streaming support,
and align model configs with vllm eval for reproducible comparisons.

## Changes

- **exo_eval**: Checkpoint/resume (JSONL), instance health monitoring +
early abort, `top_k`/`min_p`/`enable_thinking` params, LCB
`--release-version`/`--offset`
- **exo_bench**: Streaming SSE (`--stream`), Kimi tokenizer fix for
transformers 5.x
- **Both tools**: Auto-detect running instances instead of requiring
`--skip-instance-setup`; `--fresh-instance` to override
- **harness**: SSE streaming client, `find_existing_instance()` shared
helper, removed download timeout, settle-timeout default 0→7200s
- **models.toml**: Added `enable_thinking`, aligned `max_tokens`/temps
with vllm, added new models
- **API**: Streaming SSE for `/bench/chat/completions`

## Why It Works

- Checkpoint/resume uses append-only JSONL + skip-on-load so interrupted
evals resume without re-running completed questions
- Health monitoring races an `asyncio.Event` against API calls for fast
abort when the instance dies
- Auto-detection queries `/state` for existing instances matching the
model ID before attempting placement
- Streaming reuses the existing `generate_chat_stream` infrastructure
from the regular chat endpoint
2026-04-27 16:53:43 +01:00
rltakashigeandEvan 37f6f4f6c2 Add DeepSeek V4 Flash/Pro (#1978)
Wait for upstream merge.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-27 15:20:50 +01:00
Adam DurhamandAdam Durham 48a922fd5c fix: map presence_penalty and frequency_penalty from ChatCompletionRequest (#1991)
Upstream PR #1947 added `presence_penalty` and `frequency_penalty` to
`TextGenerationTaskParams` and the mlx-lm generator call sites, but
missed wiring them up in the API adapter so they were silently dropped
from incoming requests. This fixes the API mapping.

Co-authored-by: Adam Durham <adam@example.com>
2026-04-27 08:58:59 +00:00
rltakashige fd707de30b Add more model cards (#1970) 2026-04-23 15:28:40 +01:00
Alex CheemaandClaude Opus 4.7 45248c5c85 chore(app): hardcode bug report presigned-URL endpoint (#1971)
## Motivation

The bug-report presigned-URL endpoint
(`https://reports.exolabs.net/presigned-urls`) was injected at build
time from the `EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` GitHub Actions
secret into `Info.plist`, then read at runtime by `BugReportService`. It
isn't actually a secret — the POST body is just `{"keys":[...]}` with no
credential (see `app/EXO/EXO/Services/BugReportService.swift:136-142`),
abuse prevention lives server-side on the lambda, and the URL is already
visible in every publicly-distributed DMG's `Info.plist`. Treating it as
a repo secret added plumbing with no security benefit and broke local
dev builds — hitting **Send Bug Report** on an uncustomised `just
build-app` raised "Bug report endpoint is invalid".

## Changes

- `app/EXO/EXO/Info.plist`: replace
`$(EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT)` with the literal URL.
- `.github/workflows/build-app.yml`: drop the
`EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` job-level env var and the
xcodebuild build-setting passthrough. No other workflow changes.

Swift code is unchanged — `BugReportService` still reads from
`Info.plist`, which leaves an escape hatch if anyone ever needs to
override via `xcodebuild EXOBugReportPresignedUrlEndpoint=...` without
recompiling.

Follow-up: the `EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` repo secret can
now be deleted in the GitHub Actions settings UI.

## Why It Works

`Info.plist` variable substitution turns `$(FOO)` into whatever build
setting `FOO` resolves to. CI was setting `FOO` via xcodebuild; local
dev wasn't, so the key resolved to an empty string, which
`BugReportService.fetchPresignedUploadUrls` rejects via the
`!trimmedEndpointString.isEmpty` guard at `BugReportService.swift:131`.
Hardcoding the literal string removes the substitution entirely, so
every build — local or CI — gets the right value.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro (macOS app build via Xcode) -->
- `just build-app` with no extra env vars (reproduces the failure path
on `main`).
- `/usr/libexec/PlistBuddy -c "Print :EXOBugReportPresignedUrlEndpoint"
app/EXO/build/Build/Products/Debug/EXO.app/Contents/Info.plist` →
returns `https://reports.exolabs.net/presigned-urls` (was empty before
this change).
- `open app/EXO/build/Build/Products/Debug/EXO.app` → menubar → **Debug
Info** → **Send Bug Report** → type a description → **Send** → upload
succeeds and the **Create GitHub Issue** button appears (was failing
with "Bug report endpoint is invalid" before).
- Cross-check on the Slack side that the uploaded `report.json` lands
under `reports/YYYY/MM/DD/<ts>/` as before.

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
- No new tests. This is a single-string change to `Info.plist` plus a
workflow cleanup. `nix flake check` in CI verifies formatting/lint for
the rest of the tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:08:16 +00:00
rltakashige 290e3fd927 Keep image cache fresh (temporary fix) (#1961)
## Motivation

When a new node joins, it might not have the cache.



Caveat: 
This is potentially fallible if a new node joins and updates real
topology, but the API topology hasn't caught up with this fact and the
user queues up a new text generation. In practice, there is only a split
second where this is the case, and this is only for users of the
dashboard interface. We should fix this properly after the release.
2026-04-23 11:39:36 +01:00
rltakashige 3894cf134e Fix Gemma 4 E2B TP + DeepSeek V32 thinking parsing (#1967) 2026-04-23 01:50:39 +00:00
Alex CheemaandClaude Opus 4.7 8993ccaf09 feat(app): add friendly context message to bug report prompt (#1959)
## Motivation

When a user clicks **Send Bug Report** in the macOS app, we already give
them the option to add more context via an optional text field. But the
current prompt is just a terse label — `"What's the issue? (optional)"`
— which doesn't tell the user why bothering to fill it in matters. A
friendly one-line explanation increases the chance they'll describe what
went wrong, which is the single most useful signal when we triage the
resulting diagnostic bundle.

## Changes

- `app/EXO/EXO/ContentView.swift`: In the `.prompting` phase of
`sendBugReportButton`, replace the single label with a two-line
hierarchy:
  - Primary: `Tell us what went wrong (optional)`
- Helper: `A quick description of what you were doing and what happened
helps us track down the bug for you.`
- The helper uses `.caption2` + `.secondary` + `.opacity(0.8)` +
`.fixedSize(horizontal: false, vertical: true)` so it stays visually
subordinate and wraps cleanly inside the 340pt popover.

No changes to `BugReportService`, the `user_description` payload, or any
other flow.

## Why It Works

The optional description is already plumbed end-to-end (text editor →
`bugReportUserDescription` state → `BugReportService.sendReport(...,
userDescription:)` → `report.json`'s `user_description` field → GitHub
issue pre-fill). The only gap was user-facing motivation, so this is
purely a copy/layout tweak inside the existing `.prompting` case — no
new state, bindings, or service changes.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro (macOS app build via Xcode) -->
- Build the macOS app in Xcode (`app/EXO/EXO.xcodeproj`) and launch it.
- Open the menubar popover → expand **Debug Info** → click **Send Bug
Report**.
- Verify the new primary label and helper sentence both appear above the
text editor and wrap cleanly within the popover width.
- Leave the field empty → click **Send** → upload should succeed (no
`user_description` in payload, same as before).
- Fill in a description → click **Send** → upload succeeds and the
success card with **Create GitHub Issue** appears; clicking it opens
GitHub with the description pre-filled.
- Click **Cancel** from the prompting state → returns to idle.

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
- No new automated tests. This is a SwiftUI copy/layout change; existing
`EXOTests` are smoke-level and don't cover `ContentView` view bodies,
and UI snapshot tests aren't worth adding for a two-line copy tweak.
- `nix fmt` reports 0 files changed after the edit; `nix flake check` in
CI will verify formatting/lint for the rest of the tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:39:36 +00:00
Nadeem Hilal Wani 4939fbe995 feat(dashboard): add Pi integration tab (#1925)
## Summary
Adds a new **Pi** tab to the Integrations page (`/#/integrations`)
alongside the existing Claude Code, OpenCode, Codex, OpenClaw, Open
WebUI, n8n, and Firefox tabs.

[pi](https://pi.dev) (`@mariozechner/pi-coding-agent`) is a terminal
coding agent that supports custom OpenAI-compatible providers via
`~/.pi/agent/models.json`.
This tab gives users a copy-pasteable config to wire pi up to their exo
cluster.

## What's in the tab
- **Model selector** (shown when multiple models are running) — picks
the default model for the generated shell command.
- **Models Config card** — generates `~/.pi/agent/models.json`
registering `exo` as a custom provider:
     - `baseUrl` → `<apiUrl>/v1`
     - `api` → `openai-completions`
     - `apiKey` → `"exo"` (placeholder; exo ignores it)
- `compat.supportsDeveloperRole: false` and
`compat.supportsReasoningEffort: false`, per pi docs recommendation for
local OpenAI-compatible servers
- Auto-populates every running model with `id`, `contextWindow` (from
`/v1/models`), and `input: ["text", "image"]` for vision-capable models
- **Shell Command card** — `pi --provider exo --model <model>` for quick
launch.

The tab gracefully falls back to `your-model-id` when no models are
running, matching the behavior of the other tabs.

   ## Usage

   1. `npm install -g @mariozechner/pi-coding-agent`
   2. Paste the generated config into `~/.pi/agent/models.json`
3. Run `pi` and pick an exo model via `/model` — or run the shell
command directly

   ## Changes

- `dashboard/src/routes/integrations/+page.svelte` — adds `"Pi"` to the
`tabs` tuple, `piModel` state, `piModelsJson` + `piShellCommand`
derivations, and the tab content block.

   Single-file, scoped change — no backend or type changes.

   ## Testing

   - `cd dashboard && npm run build` —  builds cleanly
   - `svelte-check` on the edited file — no new errors
- Manually verified the tab renders, the model selector updates the
generated JSON, and the config reflects `/v1/models` capabilities
(vision → `input: ["text","image"]`,
 `context_length` → `contextWindow`).

   ## Screenshots

<img width="1545" height="1236" alt="pi-tab"
src="https://github.com/user-attachments/assets/38aa179f-4ed9-4a1e-9783-d3baa7738263"
/>
2026-04-22 17:29:47 +00:00
rltakashige 73782ecc65 Fix event mutation causing indexed vs event mismatch (#1964)
Fixes small issue with #1957
2026-04-22 16:12:24 +00:00
rltakashige f6e418ed23 Cleanup on #1952 (#1960) 2026-04-22 17:05:49 +01:00
rltakashigeandEvan 7a312a177b Misc fixes: upstream JACCL all_sum, API, etc. + Add Kimi K2.6 (#1952)
## Motivation

This fixes a bunch of observed model quality issues introduced upstream
in JACCL, as well as API issues and prefix cache calculation.


## Test Plan

### Manual Testing
Tested a bunch

### Automated Testing
Added a test, automated eval tool calls on Kimi K2.6, Minimax M2.7, GPT
OSS and Qwen3.6 models.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-22 15:43:27 +00:00
Evan Quiney 0a549f8846 remove layer loading callback (#1890)
first part of modularising the backend is simplifying some of the
control flow. more tbd.
2026-04-22 14:03:31 +01:00
246 changed files with 17866 additions and 3500 deletions

No files matched your search

-2
View File
@@ -32,7 +32,6 @@ jobs:
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
SPARKLE_S3_BUCKET: ${{ secrets.SPARKLE_S3_BUCKET }}
SPARKLE_S3_PREFIX: ${{ secrets.SPARKLE_S3_PREFIX }}
EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT: ${{ secrets.EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT }}
AWS_REGION: ${{ secrets.AWS_REGION }}
EXO_BUILD_NUMBER: ${{ github.run_number }}
EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
@@ -347,7 +346,6 @@ jobs:
EXO_BUILD_COMMIT="$GITHUB_SHA" \
SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \
SPARKLE_ED25519_PUBLIC="$SPARKLE_ED25519_PUBLIC" \
EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT="$EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT" \
CODE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \
CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES
mkdir -p ../../output
+1
View File
@@ -40,3 +40,4 @@ bench/**/*.json
tmp/models
/build/exo
/.claude/skills
/.claude
+6 -5
View File
@@ -1767,12 +1767,12 @@ def clip(
array: The clipped array.
"""
def compile(
fun: Callable,
def compile[F: Callable[..., object]](
fun: F,
inputs: object | None = ...,
outputs: object | None = ...,
shapeless: bool = ...,
) -> Callable:
) -> F:
"""
Returns a compiled function which produces the same output as ``fun``.
@@ -2915,8 +2915,8 @@ def gather_mm(
a: array,
b: array,
/,
lhs_indices: array,
rhs_indices: array,
lhs_indices: array | None = ...,
rhs_indices: array | None = ...,
*,
sorted_indices: bool = ...,
stream: Stream | Device | None = ...,
@@ -4707,6 +4707,7 @@ def softmax(
/,
axis: int | Sequence[int] | None = ...,
*,
precise: bool = ...,
stream: Stream | Device | None = ...,
) -> array:
"""
+4
View File
@@ -57,6 +57,10 @@ class Module(dict):
def __init__(self) -> None:
"""Should be called by the subclasses of ``Module``."""
def __getitem__(self, key: str) -> mx.array | Module: ...
def get(
self, key: str, default: mx.array | Module | None = ...
) -> mx.array | Module | None: ...
@property
def training(self): # -> bool:
"""Boolean indicating if the model is in training mode."""
+5 -4
View File
@@ -383,11 +383,12 @@ class GenerationBatch:
state_machines: List[SequenceStateMachine]
max_tokens: List[int]
_current_tokens: Optional[mx.array]
_current_logprobs: List[mx.array]
_next_tokens: mx.array
_next_logprobs: List[mx.array]
_token_context: List[mx.array]
_current_logprobs: mx.array | List[mx.array]
_next_tokens: Optional[mx.array]
_next_logprobs: mx.array | List[mx.array]
_token_context: List[Any]
_num_tokens: List[int]
_matcher_states: List[Any]
def __init__(
self,
+5 -5
View File
@@ -3,7 +3,7 @@ This type stub file was generated by pyright.
"""
from dataclasses import dataclass
from typing import Optional
from typing import Any, Optional
import mlx.core as mx
@@ -37,10 +37,10 @@ def quantized_scaled_dot_product_attention(
bits: int = ...,
) -> mx.array: ...
def scaled_dot_product_attention(
queries,
keys,
values,
cache,
queries: mx.array,
keys: mx.array,
values: mx.array,
cache: Optional[Any],
scale: float,
mask: Optional[mx.array],
sinks: Optional[mx.array] = ...,
+280
View File
@@ -0,0 +1,280 @@
"""Type stubs for mlx_lm.models.deepseek_v4"""
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs
from .cache import ArraysCache, RotatingKVCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
vocab_size: int
hidden_size: int
intermediate_size: int
moe_intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
n_shared_experts: Optional[int]
n_routed_experts: int
num_experts_per_tok: int
head_dim: int
qk_rope_head_dim: int
q_lora_rank: int
o_lora_rank: int
o_groups: int
sliding_window: int
hc_mult: int
hc_sinkhorn_iters: int
hc_eps: float
compress_ratios: Optional[List[int]]
compress_rope_theta: float
rope_theta: float
rope_scaling: Optional[Dict[str, Any]]
rms_norm_eps: float
swiglu_limit: float
attention_bias: bool
max_position_embeddings: int
class DeepseekV4RoPE(nn.Module):
dims: int
freqs: mx.array
def __init__(
self,
dims: int,
base: float,
scaling_config: Optional[Dict[str, Any]] = None,
) -> None: ...
def __call__(
self,
x: mx.array,
offset: int = 0,
inverse: bool = False,
) -> mx.array: ...
class HyperConnection(nn.Module):
dim: int
hc_mult: int
norm_eps: float
def __init__(
self,
dim: int,
hc_mult: int,
norm_eps: float,
sinkhorn_iters: int,
hc_eps: float,
) -> None: ...
class HyperHead(nn.Module):
dim: int
hc_mult: int
def __init__(
self,
dim: int,
hc_mult: int,
norm_eps: float,
hc_eps: float,
) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class Compressor(nn.Module):
dim: int
head_dim: int
rope_head_dim: int
compress_ratio: int
overlap: bool
wkv_gate: nn.Linear
ape: mx.array
norm: nn.RMSNorm
rope: DeepseekV4RoPE
def __init__(
self,
dim: int,
compress_ratio: int,
head_dim: int,
rope_head_dim: int,
rms_norm_eps: float,
rope: DeepseekV4RoPE,
) -> None: ...
def __call__(
self,
x: mx.array,
cache: "DeepseekV4Cache",
offset: Any,
key: str = ...,
) -> mx.array: ...
class Indexer(nn.Module):
def __init__(
self,
args: ModelArgs,
compress_ratio: int,
rope: DeepseekV4RoPE,
) -> None: ...
class _CompressorBranch:
buffer_kv: Optional[mx.array]
buffer_gate: Optional[mx.array]
prev_kv: Optional[mx.array]
prev_gate: Optional[mx.array]
pool: Optional[mx.array]
buffer_lengths: Optional[List[int]]
pool_lengths: Optional[List[int]]
buffer_count: int
_new_pool_lengths: Optional[List[int]]
def __init__(self) -> None: ...
class DeepseekV4Cache:
local: RotatingKVCache
offset: int
keys: Optional[mx.array]
values: Optional[mx.array]
state: Any
meta_state: Any
nbytes: int
_branches: Dict[str, _CompressorBranch]
_pending_lengths: Optional[List[int]]
def __init__(self, sliding_window: int) -> None: ...
def update_and_fetch(
self, keys: mx.array, values: mx.array
) -> tuple[mx.array, mx.array]: ...
def is_trimmable(self) -> bool: ...
def trim(self, n: int) -> int: ...
def empty(self) -> bool: ...
def size(self) -> int: ...
def prepare(
self,
*,
left_padding: Optional[List[int]] = None,
lengths: Optional[List[int]] = None,
right_padding: Optional[List[int]] = None,
) -> None: ...
def finalize(self) -> None: ...
def filter(self, batch_indices: mx.array) -> None: ...
def extend(self, other: "DeepseekV4Cache") -> None: ...
def extract(self, idx: int) -> "DeepseekV4Cache": ...
@classmethod
def merge(cls, caches: List["DeepseekV4Cache"]) -> "DeepseekV4Cache": ...
class V4Attention(nn.Module):
args: ModelArgs
layer_id: int
dim: int
n_heads: int
head_dim: int
rope_head_dim: int
nope_head_dim: int
n_groups: int
q_lora_rank: int
o_lora_rank: int
window: int
eps: float
scale: float
compress_ratio: int
wqkv_a: nn.Linear
q_norm: nn.RMSNorm
wq_b: nn.Linear
kv_norm: nn.RMSNorm
attn_sink: mx.array
wo_a: nn.Linear
wo_b: nn.Linear
rope: DeepseekV4RoPE
compressor: Compressor
indexer: Indexer
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class DeepseekV4MLP(nn.Module):
gate_proj: nn.Linear
up_proj: nn.Linear
down_proj: nn.Linear
def __init__(
self,
hidden_size: int,
intermediate_size: int,
swiglu_limit: float = 0.0,
) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class MoEGate(nn.Module):
weight: mx.array
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(
self, x: mx.array, input_ids: mx.array
) -> tuple[mx.array, mx.array]: ...
class DeepseekV4MoE(nn.Module):
num_experts_per_tok: int
switch_mlp: SwitchGLU
gate: MoEGate
shared_experts: DeepseekV4MLP
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(self, x: mx.array, input_ids: mx.array) -> mx.array: ...
class DeepseekV4Block(nn.Module):
attn_norm: nn.RMSNorm
attn: V4Attention
hc_attn: HyperConnection
ffn_norm: nn.RMSNorm
ffn: DeepseekV4MoE
hc_ffn: HyperConnection
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(
self,
h: mx.array,
cache: Optional[Any],
input_ids: mx.array,
) -> mx.array: ...
class DeepseekV4Model(nn.Module):
args: ModelArgs
vocab_size: int
embed_tokens: nn.Embedding
layers: list[DeepseekV4Block]
norm: nn.RMSNorm
hc_head: HyperHead
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[List[Any]] = None,
) -> mx.array: ...
class Model(nn.Module):
args: ModelArgs
model_type: str
model: DeepseekV4Model
lm_head: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[List[Any]] = None,
) -> mx.array: ...
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
def make_cache(self) -> list[RotatingKVCache | DeepseekV4Cache]: ...
@property
def layers(self) -> list[DeepseekV4Block]: ...
+103
View File
@@ -0,0 +1,103 @@
"""Type stubs for mlx_lm.models.gpt_oss"""
from dataclasses import dataclass
from typing import Any, List, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs
from .cache import KVCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
num_local_experts: int
num_experts_per_tok: int
vocab_size: int
rms_norm_eps: float
sliding_window: int
layer_types: Optional[List[str]]
def mlx_topk(a: mx.array, k: int, axis: int = -1) -> tuple[mx.array, mx.array]: ...
class AttentionBlock(nn.Module):
head_dim: int
num_attention_heads: int
num_key_value_heads: int
num_key_value_groups: int
sinks: mx.array
q_proj: nn.Linear
k_proj: nn.Linear
v_proj: nn.Linear
o_proj: nn.Linear
sm_scale: float
rope: nn.Module
def __init__(self, config: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class TransformerBlock(nn.Module):
self_attn: AttentionBlock
mlp: MLPBlock
def __init__(self, config: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class MLPBlock(nn.Module):
hidden_size: int
num_local_experts: int
num_experts_per_tok: int
experts: SwitchGLU
router: nn.Linear
sharding_group: Optional[mx.distributed.Group]
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class GptOssMoeModel(nn.Module):
embed_tokens: nn.Embedding
norm: nn.RMSNorm
layer_types: List[str]
layers: list[TransformerBlock]
window_size: int
swa_idx: int
ga_idx: int
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
class Model(nn.Module):
model_type: str
model: GptOssMoeModel
lm_head: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
@property
def layers(self) -> list[nn.Module]: ...
def make_cache(self) -> list[KVCache]: ...
+94
View File
@@ -0,0 +1,94 @@
"""Type stubs for mlx_lm.models.minimax"""
from dataclasses import dataclass
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
num_local_experts: int
num_experts_per_tok: int
max_position_embeddings: int
class MiniMaxAttention(nn.Module):
num_heads: int
num_attention_heads: int
num_key_value_heads: int
head_dim: int
scale: float
q_proj: nn.Linear
k_proj: nn.Linear
v_proj: nn.Linear
o_proj: nn.Linear
q_norm: nn.Module
k_norm: nn.Module
rope: nn.Module
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class MiniMaxSparseMoeBlock(nn.Module):
num_experts_per_tok: int
gate: nn.Linear
switch_mlp: SwitchGLU
e_score_correction_bias: mx.array
sharding_group: Optional[mx.distributed.Group]
def __init__(self, args: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class MiniMaxDecoderLayer(nn.Module):
self_attn: MiniMaxAttention
block_sparse_moe: MiniMaxSparseMoeBlock
input_layernorm: nn.RMSNorm
post_attention_layernorm: nn.RMSNorm
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class MiniMaxModel(nn.Module):
embed_tokens: nn.Embedding
layers: list[MiniMaxDecoderLayer]
norm: nn.RMSNorm
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
class Model(nn.Module):
model_type: str
model: MiniMaxModel
lm_head: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
@property
def layers(self) -> list[MiniMaxDecoderLayer]: ...
+14
View File
@@ -92,6 +92,15 @@ class NemotronHAttention(nn.Module):
cache: Optional[KVCache] = None,
) -> mx.array: ...
class MoEGate(nn.Module):
config: ModelArgs
top_k: int
norm_topk_prob: bool
weight: mx.array
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> tuple[mx.array, mx.array]: ...
class NemotronHMLP(nn.Module):
up_proj: nn.Linear
down_proj: nn.Linear
@@ -102,9 +111,14 @@ class NemotronHMLP(nn.Module):
def __call__(self, x: mx.array) -> mx.array: ...
class NemotronHMoE(nn.Module):
config: ModelArgs
num_experts_per_tok: int
moe_latent_size: Optional[int]
switch_mlp: SwitchMLP
gate: MoEGate
shared_experts: NemotronHMLP
fc1_latent_proj: nn.Linear
fc2_latent_proj: nn.Linear
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
@@ -71,6 +71,7 @@ class Qwen3NextAttention(nn.Module):
class Qwen3NextSparseMoeBlock(nn.Module):
norm_topk_prob: bool
num_experts: int
num_experts_per_tok: int
top_k: int
gate: nn.Linear
switch_mlp: SwitchGLU
+75
View File
@@ -120,6 +120,81 @@ From .cursorrules:
Tests use pytest-asyncio with `asyncio_mode = "auto"`. Tests are in `tests/` subdirectories alongside the code they test. The `EXO_TESTS=1` env var is set during tests.
Integration tests live in `tests/` (root) and are opt-in via `--ignore=tests` in the default pytest addopts. They require an `eco`-managed cluster:
```bash
uv run pytest tests/ -v # constraint-driven host pick
uv run pytest tests/ -v --hosts s4 # explicit host override
```
## Benchmarking
Benchmarks live in `bench/`. The framework is a CLI with subcommands; each benchmark is a small library module under `bench/lib/<name>.py` plus a CLI front-end under `bench/cli/<name>.py`.
```
bench/
├── lib/ # composable, typed building blocks
│ ├── prompt.py # PromptSizer, load_tokenizer_for_bench
│ ├── completion.py # run_one_completion + typed payloads
│ ├── session.py # BenchSession (cluster + client + instance)
│ ├── results.py # RunMetadata, ResultsBundle, JSON schema
│ ├── model_meta.py # HF API: total weights size, max context, layers
│ ├── cluster.py # managed_cluster + managed_instance ctx-managers
│ └── context_scaling.py # prompt-TPS / decode-TPS vs context-size sweep
├── cli/ # CLI subcommands
│ ├── _common.py # shared argparse args + SharedOptions
│ ├── context_scaling.py # `python -m bench.cli context-scaling …`
│ └── __main__.py # subcommand dispatcher
└── exo_bench.py, prefill_decode_bench.py
# legacy CLI scripts; PromptSizer / run_one_completion
# / load_tokenizer_for_bench are re-exports of bench.lib.
```
Run a benchmark:
```bash
# Defaults assume a multi-node, Thunderbolt-connected cluster with tensor
# parallelism + JACCL: --sharding Tensor --comm MlxJaccl --thunderbolt a2a.
# Memory + disk minimums are auto-derived from HF metadata.
uv run python -m bench.cli context-scaling \
--model mlx-community/Qwen3-30B-A3B-4bit --nodes 2 --num-steps 32
# Single-node smoke: opt out of TB / tensor / jaccl
uv run python -m bench.cli context-scaling --hosts s4 \
--model mlx-community/Llama-3.2-1B-Instruct-4bit --num-steps 4 \
--sharding Pipeline --comm MlxRing --thunderbolt none
# From a TOML config (CLI flags override config values)
uv run python -m bench.cli context-scaling \
--config bench/configs/context_scaling.example.toml --hosts s4,s9
```
Shared CLI flags (every subcommand inherits these via `bench/cli/_common.py`):
- `--config <path>.toml` — load run parameters from a TOML file
- `--model`, `--sharding {Pipeline,Tensor}` (default Tensor), `--comm {MlxRing,MlxJaccl}` (default MlxJaccl), `--min-nodes` — placement
- `--hosts`, `--nodes` (number of cluster hosts; distinct from `--min-nodes`), `--thunderbolt {a2a,ring,none}` (default a2a), `--chip` — host pool
- `--min-memory-gb`, `--max-memory-gb`, `--min-disk-gb`, `--max-disk-gb` (minimums auto-derived from HF model size when not supplied)
- `--evict-downloads` (default on; auto-evicts smallest-first when disk is short)
- `--cleanup-instance` (default on; deletes the instance on exit)
- `--output-dir`, `--tag key=value` (repeatable)
Run a multi-run campaign from a single TOML file (each `[[runs]]` = its own cluster deploy + bench + teardown; `[defaults]` is shared, per-run keys override; `[plot]` triggers a comparison PNG):
```bash
uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
```
Plot any results JSON to a PNG (auto-detects benchmark type from `metadata.benchmark`):
```bash
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
uv run python -m bench.cli plot a.json b.json --label-tag operator # multi-run comparison
```
Adding a new benchmark = (1) write a `bench/lib/<name>.py` exposing a typed `run(session, params, bundle)` callable; (2) add a `bench/cli/<name>.py` with `add_subparser(...)` + `run(args) -> Path`; (3) register the imports in `bench/cli/__main__.py`. To enable plotting for the new benchmark, add a `render_<name>(inputs)` function in `bench/lib/plotting.py` and a dispatch entry in `bench/cli/plot.py::run`.
Results land at `bench/results/<benchmark>/<run_id>.json` (with a `latest.json` symlink alongside) containing metadata (exo SHA, hostname, platform, ISO timestamps, methodology version, user tags), full cluster snapshot, the resolved + derived params, per-step rows, optional cold-control rows, and any derived summaries (e.g. `t_cum_seconds[]` for context-scaling).
## Dashboard UI Testing & Screenshots
### Building and Running the Dashboard
Generated
+39 -3
View File
@@ -916,11 +916,13 @@ dependencies = [
"libp2p",
"log",
"networking",
"pidfile-rs",
"pin-project",
"pyo3",
"pyo3-async-runtimes",
"pyo3-log",
"pyo3-stub-gen",
"thiserror 2.0.17",
"tokio",
"util",
]
@@ -964,6 +966,16 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "flopen"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbfb8b5fbd1f27929f216650081a07b6ceb0741f0542c8c43ff7ef8e93a35a5d"
dependencies = [
"libc",
"nix 0.31.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1789,9 +1801,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.178"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libp2p"
@@ -2807,6 +2819,18 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -3060,6 +3084,18 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pidfile-rs"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1a8aa9a30b1b65ef48b333931b80f2324a14e00208eb2b8f5788f1180791bcc"
dependencies = [
"flopen",
"libc",
"log",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -3668,7 +3704,7 @@ dependencies = [
"netlink-packet-utils",
"netlink-proto",
"netlink-sys",
"nix",
"nix 0.26.4",
"thiserror 1.0.69",
"tokio",
]
+114
View File
@@ -550,6 +550,120 @@ uv run bench/exo_bench.py \
The tool outputs performance metrics including prompt tokens per second (prompt_tps), generation tokens per second (generation_tps), and peak memory usage for each configuration.
### Composable benchmarks (CLI)
For benchmarks that need an `eco`-managed cluster and a stable JSON result format, exo ships a CLI under `bench/cli/`. The CLI handles cluster lifecycle, instance placement, model-metadata resolution (HuggingFace), and result capture; benchmark logic lives in `bench/lib/` so each new benchmark is a small library module + a CLI subcommand.
**Run the prompt-TPS / decode-TPS vs context-size sweep:**
The defaults assume a multi-node, Thunderbolt-connected cluster with tensor parallelism + JACCL — the typical exo benchmarking setup:
```bash
# Defaults: --sharding Tensor --comm MlxJaccl --thunderbolt a2a, with
# memory/disk minimums auto-derived from the HF model size. eco picks
# `--nodes` hosts from its inventory that form a TB clique and satisfy
# those constraints.
uv run python -m bench.cli context-scaling \
--model mlx-community/Qwen3-30B-A3B-4bit --nodes 2 --num-steps 32
# Pin to specific hosts (defaults still apply for sharding/comm/topology)
uv run python -m bench.cli context-scaling --hosts s4,s9 \
--model mlx-community/Qwen3-30B-A3B-4bit --num-steps 16
# Single-node smoke test: explicit single-node placement overrides
uv run python -m bench.cli context-scaling --hosts s4 \
--model mlx-community/Llama-3.2-1B-Instruct-4bit --num-steps 4 \
--sharding Pipeline --comm MlxRing --thunderbolt none
# Override the auto-derived ramp / cold controls
uv run python -m bench.cli context-scaling --hosts s4,s9 --model X \
--pp-step 4096 --num-steps 32 --cold-controls 8192,32768,65536,131072
# Custom output dir + tags
uv run python -m bench.cli context-scaling --hosts s4,s9 --model X \
--output-dir bench/results/2026-05-10/ --tag operator=$USER --tag run=full
# Run from a TOML config (CLI flags override values from the file)
uv run python -m bench.cli context-scaling \
--config bench/configs/context_scaling.example.toml
```
**Shared flags (every benchmark subcommand has these):**
- `--model` — HuggingFace model id (required)
- `--config <path>.toml` — load run parameters from a TOML file
- `--sharding {Pipeline,Tensor}` (default **Tensor**) — sharding mode
- `--comm {MlxRing,MlxJaccl}` (default **MlxJaccl**) — inter-node comm mode
- `--min-nodes N` (default 1) — minimum nodes for the placement
- `--hosts s4,s9` — pin to specific hosts; bypasses constraint search
- `--nodes N` (default 1) — number of cluster hosts to reserve when `--hosts` is unset (distinct from `--min-nodes`, which controls the model's instance placement)
- `--thunderbolt {a2a,ring,none}` (default **a2a**) — required Thunderbolt topology
- `--chip "M3 Ultra"` — required chip (substring match; comment to allow any)
- `--min-memory-gb`, `--max-memory-gb`, `--min-disk-gb`, `--max-disk-gb` — host RAM / disk constraints. The minimums are auto-derived from the HF model size (×1.30 + 1 GiB for memory, ×1.10 + 1 GiB for disk) when not supplied; explicit values always win.
- `--evict-downloads` (default **on**) — auto-evict existing models smallest-first on disk-full; pass `--no-evict-downloads` to keep
- `--cleanup-instance` (default **on**) — delete the placed instance after exit; pass `--no-cleanup-instance` to leave it running for debugging
- `--output-dir bench/results` — base directory for JSON results (subcommands add their own subfolder)
- `--tag key=value` — append to `metadata.tags` (repeatable)
**Context-scaling-specific flags:**
- `--num-steps N` — number of equally-spaced ramp points (default 32)
- `--pp-step Δ` — explicit Δ in tokens (overrides auto-derivation from `max_position_embeddings`)
- `--fraction-of-max F` — when Δ is auto-derived, use `F × max_context` as the upper bound
- `--tg` — tokens generated per step (default 64)
- `--warmup` — warmup requests at `pp=Δ` (default 1)
- `--cold-controls auto` (4 evenly-spaced points across the ramp) **or** `--cold-controls 8192,32768,…` (explicit pp values). Default: no cold controls.
**Output:** each run writes `bench/results/<benchmark>/<run_id>.json` plus a `latest.json` symlink. The JSON contains metadata (exo SHA, hostname, platform, user tags), the full cluster snapshot at run start, the resolved + derived params, per-step rows, cold-control rows, and derived summaries (`t_cum_seconds`, `control_gaps`).
**Multi-run campaigns** — `bench campaign` runs a list of bench invocations from a single TOML file. Each `[[runs]]` entry is its own cluster deploy + bench + teardown, with a shared `[defaults]` table for DRY config:
```toml
# bench/configs/llama-family-smoke.toml
[defaults]
nodes = 4
num_steps = 8
fraction_of_max = 0.5
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.2-3b-4bit"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.1-8b-4bit"
[plot]
label_tag = "model_short"
```
```bash
uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
```
After all runs finish, an optional `[plot]` table triggers a comparison plot per benchmark group (one PNG per benchmark type with ≥2 runs).
**Plotting** — `bench plot` renders any results JSON to a 2-panel PNG (prompt_tps + generation_tps vs pp_tokens, cold controls overlaid as 'x' markers):
```bash
# Plot the most recent run next to its JSON
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
# Compare multiple runs (one line per run; legend label = the chosen tag)
uv run python -m bench.cli plot run_a.json run_b.json --label-tag operator
# Custom output path + title
uv run python -m bench.cli plot run.json --output /tmp/scaling.png --title "30B 4-node sweep"
```
The benchmark type is detected from each JSON's `metadata.benchmark`, so the same `plot` command will work for future benchmarks once their renderer is registered in `bench/lib/plotting.py`.
Methodology for the context-scaling benchmark is documented in detail in `bench/lib/context_scaling.py`'s module docstring and in `bench/METHODOLOGY.md`.
---
## Hardware Accelerator Support
+8 -218
View File
@@ -16,22 +16,13 @@ struct ContentView: View {
@EnvironmentObject private var updater: SparkleUpdater
@EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService
@EnvironmentObject private var settingsWindowController: SettingsWindowController
@EnvironmentObject private var bugReportWindowController: BugReportWindowController
@State private var focusedNode: NodeViewModel?
@State private var deletingInstanceIDs: Set<String> = []
@State private var showAllNodes = false
@State private var showAllInstances = false
@State private var baseURLCopied = false
@State private var showAdvanced = false
@State private var showDebugInfo = false
private enum BugReportPhase: Equatable {
case idle
case prompting
case sending(String)
case success(String)
case failure(String)
}
@State private var bugReportPhase: BugReportPhase = .idle
@State private var bugReportUserDescription: String = ""
@State private var uninstallInProgress = false
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@@ -294,6 +285,13 @@ struct ContentView: View {
) {
updater.checkForUpdates()
}
HoverButton(
title: "Share Bug Report…",
tint: .primary,
trailingSystemImage: "ladybug"
) {
bugReportWindowController.open()
}
.padding(.bottom, 8)
HoverButton(title: "Quit", tint: .secondary) {
controller.stop()
@@ -477,40 +475,6 @@ struct ContentView: View {
}
}
private var debugSection: some View {
VStack(alignment: .leading, spacing: 4) {
HoverButton(
title: "Debug Info",
tint: .primary,
trailingSystemImage: showDebugInfo ? "chevron.up" : "chevron.down",
small: true
) {
showDebugInfo.toggle()
}
if showDebugInfo {
VStack(alignment: .leading, spacing: 4) {
Text("Version: \(buildTag)")
.font(.caption2)
.foregroundColor(.secondary)
Text("Commit: \(buildCommit)")
.font(.caption2)
.foregroundColor(.secondary)
Text(thunderboltStatusText)
.font(.caption2)
.foregroundColor(thunderboltStatusColor)
clusterThunderboltBridgeView
interfaceIpList
rdmaStatusView
sendBugReportButton
.padding(.top, 6)
}
.padding(.leading, 8)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: showDebugInfo)
}
private var rdmaStatusView: some View {
let rdmaStatuses = stateService.latestSnapshot?.nodeRdmaCtl ?? [:]
let localNodeId = stateService.localNodeId
@@ -559,118 +523,6 @@ struct ContentView: View {
}
}
private var sendBugReportButton: some View {
VStack(alignment: .leading, spacing: 6) {
switch bugReportPhase {
case .idle:
Button {
bugReportPhase = .prompting
bugReportUserDescription = ""
} label: {
HStack {
Text("Send Bug Report")
.font(.caption)
.fontWeight(.semibold)
Spacer()
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.12))
)
}
.buttonStyle(.plain)
case .prompting:
VStack(alignment: .leading, spacing: 6) {
Text("What's the issue? (optional)")
.font(.caption2)
.foregroundColor(.secondary)
TextEditor(text: $bugReportUserDescription)
.font(.caption2)
.frame(height: 60)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
HStack(spacing: 8) {
Button("Send") {
Task {
await sendBugReport()
}
}
.font(.caption2)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Cancel") {
bugReportPhase = .idle
}
.font(.caption2)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.06))
)
case .sending(let message):
HStack(spacing: 6) {
ProgressView()
.scaleEffect(0.6)
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
}
case .success(let message):
VStack(alignment: .leading, spacing: 6) {
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button {
openGitHubIssue()
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.right.square")
.imageScale(.small)
Text("Create GitHub Issue")
.font(.caption2)
}
}
.buttonStyle(.bordered)
.controlSize(.small)
Button("Done") {
bugReportPhase = .idle
bugReportUserDescription = ""
}
.font(.caption2)
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
case .failure(let message):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.caption2)
.foregroundColor(.red)
.fixedSize(horizontal: false, vertical: true)
Button("Dismiss") {
bugReportPhase = .idle
}
.font(.caption2)
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
}
}
.animation(.easeInOut(duration: 0.2), value: bugReportPhase)
}
private var processToggleBinding: Binding<Bool> {
Binding(
get: {
@@ -711,61 +563,6 @@ struct ContentView: View {
)
}
private func sendBugReport() async {
bugReportPhase = .sending("Collecting logs...")
let service = BugReportService()
let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let outcome = try await service.sendReport(
isManual: true,
userDescription: description.isEmpty ? nil : description
)
if outcome.success {
bugReportPhase = .success(outcome.message)
} else {
bugReportPhase = .failure(outcome.message)
}
} catch {
bugReportPhase = .failure(error.localizedDescription)
}
}
private func openGitHubIssue() {
let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
var bodyParts: [String] = []
bodyParts.append("## Describe the bug")
bodyParts.append("")
if !description.isEmpty {
bodyParts.append(description)
} else {
bodyParts.append("A clear and concise description of what the bug is.")
}
bodyParts.append("")
bodyParts.append("## Environment")
bodyParts.append("")
bodyParts.append("- macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString)")
bodyParts.append("- EXO Version: \(buildTag) (\(buildCommit))")
bodyParts.append("")
bodyParts.append("## Additional context")
bodyParts.append("")
bodyParts.append("A bug report with diagnostic logs was submitted via the app.")
let body = bodyParts.joined(separator: "\n")
var components = URLComponents(string: "https://github.com/exo-explore/exo/issues/new")!
components.queryItems = [
URLQueryItem(name: "template", value: "bug_report.md"),
URLQueryItem(name: "title", value: "[BUG] "),
URLQueryItem(name: "body", value: body),
URLQueryItem(name: "labels", value: "bug"),
]
if let url = components.url {
NSWorkspace.shared.open(url)
}
}
private func showUninstallConfirmationAlert() {
let alert = NSAlert()
alert.messageText = "Uninstall EXO"
@@ -848,13 +645,6 @@ struct ContentView: View {
}
}
private var buildTag: String {
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
}
private var buildCommit: String {
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
}
}
private struct HoverButton: View {
+3
View File
@@ -22,6 +22,7 @@ struct EXOApp: App {
@StateObject private var updater: SparkleUpdater
@StateObject private var thunderboltBridgeService: ThunderboltBridgeService
@StateObject private var settingsWindowController: SettingsWindowController
@StateObject private var bugReportWindowController: BugReportWindowController
private let terminationObserver: TerminationObserver
private let firstLaunchPopout = FirstLaunchPopout()
private let ciContext = CIContext(options: nil)
@@ -46,6 +47,7 @@ struct EXOApp: App {
let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service)
_thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge)
_settingsWindowController = StateObject(wrappedValue: SettingsWindowController())
_bugReportWindowController = StateObject(wrappedValue: BugReportWindowController())
enableLaunchAtLoginIfNeeded()
// Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops)
NetworkSetupHelper.promptAndInstallIfNeeded()
@@ -66,6 +68,7 @@ struct EXOApp: App {
.environmentObject(updater)
.environmentObject(thunderboltBridgeService)
.environmentObject(settingsWindowController)
.environmentObject(bugReportWindowController)
} label: {
menuBarIcon
.onReceive(controller.$isFirstLaunchReady) { ready in
+1 -1
View File
@@ -9,7 +9,7 @@
<key>EXOBuildCommit</key>
<string>$(EXO_BUILD_COMMIT)</string>
<key>EXOBugReportPresignedUrlEndpoint</key>
<string>$(EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT)</string>
<string>https://reports.exolabs.net/presigned-urls</string>
<key>NSLocalNetworkUsageDescription</key>
<string>EXO needs local network access to discover and connect to other devices in your cluster for distributed AI inference.</string>
<key>NSBonjourServices</key>
+18 -1
View File
@@ -17,7 +17,7 @@ final class ClusterStateService: ObservableObject {
init(
baseURL: URL = URL(string: "http://127.0.0.1:52415")!,
session: URLSession = .shared
session: URLSession = ClusterStateService.makeNonCachingSession()
) {
self.baseURL = baseURL
self.endpoint = baseURL.appendingPathComponent("state")
@@ -27,6 +27,23 @@ final class ClusterStateService: ObservableObject {
self.decoder = decoder
}
/// `URLSession.shared` carries an on-disk `URLCache` that persists every
/// response body under `~/Library/Caches/exolabs.EXO/`. We poll `/state`
/// at 2 Hz from `startPolling`, so leaving the shared cache attached
/// dirties ~500620 KB/sec of file-backed memory and trips macOS's
/// per-process `disk writes` resource limit (microstackshot reports
/// observed on M3 Ultra producing GBs of cached responses per hour).
/// Cluster-state polling responses are time-sensitive and small; they
/// gain nothing from being cached on disk. Use an ephemeral session
/// with `urlCache = nil` so neither response bodies nor metadata
/// touch disk.
private static func makeNonCachingSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
return URLSession(configuration: config)
}
func startPolling(interval: TimeInterval = 0.5) {
stopPolling()
Task {
@@ -0,0 +1,242 @@
import AppKit
import SwiftUI
/// Manages a standalone window for the bug-report flow.
/// Ensures only one instance exists and brings it to front on repeated opens.
@MainActor
final class BugReportWindowController: ObservableObject {
private var window: NSWindow?
func open() {
if let existing = window, existing.isVisible {
existing.makeKeyAndOrderFront(nil)
NSApp.activate()
return
}
let view = BugReportView(onDismiss: { [weak self] in
self?.window?.close()
})
let hostingController = NSHostingController(rootView: view)
hostingController.sizingOptions = [.preferredContentSize, .minSize]
let newWindow = NSWindow(contentViewController: hostingController)
newWindow.styleMask = [.titled, .closable, .resizable]
newWindow.title = "Send a Bug Report"
newWindow.center()
newWindow.setFrameAutosaveName("ExoBugReportWindow")
newWindow.isReleasedWhenClosed = false
newWindow.makeKeyAndOrderFront(nil)
NSApp.activate()
window = newWindow
}
}
private struct BugReportView: View {
fileprivate enum Phase: Equatable {
case prompting
case sending(String)
case success(String)
case failure(String)
}
let onDismiss: () -> Void
@State private var phase: Phase = .prompting
@State private var userDescription: String = ""
@FocusState private var descriptionFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
switch phase {
case .prompting:
promptingView
case .sending(let message):
sendingView(message: message)
case .success(let message):
successView(message: message)
case .failure(let message):
failureView(message: message)
}
}
.padding(16)
.frame(minWidth: 380)
.animation(.easeInOut(duration: 0.2), value: phase)
.onAppear { descriptionFocused = true }
}
private var promptingView: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Description (optional)")
.font(.subheadline)
.foregroundColor(.secondary)
ZStack(alignment: .topLeading) {
if userDescription.isEmpty {
Text("What were you doing when it broke?")
.font(.body)
.foregroundColor(Color(nsColor: .placeholderTextColor))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.allowsHitTesting(false)
}
TextEditor(text: $userDescription)
.font(.body)
.scrollContentBackground(.hidden)
.padding(4)
.frame(height: 72)
.focused($descriptionFocused)
}
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .textBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1)
)
Text("Diagnostic logs will be uploaded with your report.")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Cancel") { onDismiss() }
.keyboardShortcut(.cancelAction)
Button("Send") {
Task { await send() }
}
.keyboardShortcut(.defaultAction)
}
.padding(.top, 4)
}
}
private func sendingView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text(message)
.foregroundColor(.secondary)
}
HStack {
Spacer()
Button("Cancel") { onDismiss() }
.keyboardShortcut(.cancelAction)
.disabled(true)
Button("Send") {}
.disabled(true)
}
}
}
private func successView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title2)
Text(message)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button {
openGitHubIssue()
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.right.square")
Text("Open GitHub Issue")
}
}
Spacer()
Button("Done") { onDismiss() }
.keyboardShortcut(.defaultAction)
}
}
}
private func failureView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.title2)
Text(message)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("Try Again") {
phase = .prompting
}
Button("Close") { onDismiss() }
.keyboardShortcut(.defaultAction)
}
}
}
private func send() async {
phase = .sending("Collecting logs and uploading…")
let service = BugReportService()
let description = userDescription.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let outcome = try await service.sendReport(
isManual: true,
userDescription: description.isEmpty ? nil : description
)
if outcome.success {
phase = .success(outcome.message)
} else {
phase = .failure(outcome.message)
}
} catch {
phase = .failure(error.localizedDescription)
}
}
private func openGitHubIssue() {
let description = userDescription.trimmingCharacters(in: .whitespacesAndNewlines)
var bodyParts: [String] = []
bodyParts.append("## Describe the bug")
bodyParts.append("")
if !description.isEmpty {
bodyParts.append(description)
} else {
bodyParts.append("A clear and concise description of what the bug is.")
}
bodyParts.append("")
bodyParts.append("## Environment")
bodyParts.append("")
bodyParts.append("- macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString)")
bodyParts.append("- EXO Version: \(buildTag) (\(buildCommit))")
bodyParts.append("")
bodyParts.append("## Additional context")
bodyParts.append("")
bodyParts.append("A bug report with diagnostic logs was submitted via the app.")
let body = bodyParts.joined(separator: "\n")
var components = URLComponents(string: "https://github.com/exo-explore/exo/issues/new")!
components.queryItems = [
URLQueryItem(name: "template", value: "bug_report.md"),
URLQueryItem(name: "title", value: "[BUG] "),
URLQueryItem(name: "body", value: body),
URLQueryItem(name: "labels", value: "bug"),
]
if let url = components.url {
NSWorkspace.shared.open(url)
}
}
private var buildTag: String {
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
}
private var buildCommit: String {
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
}
}
+30 -49
View File
@@ -21,8 +21,6 @@ struct SettingsView: View {
@State private var pendingReadOnlyModelsDirs: String = ""
@State private var pendingCustomEnvironmentVariables: [CustomEnvironmentVariable] = []
@State private var needsRestart = false
@State private var bugReportInFlight = false
@State private var bugReportMessage: String?
@State private var uninstallInProgress = false
var body: some View {
@@ -202,8 +200,6 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 2) {
rdmaStatusView
}
sendBugReportButton
}
Section("Danger Zone") {
@@ -504,63 +500,30 @@ struct SettingsView: View {
}
}
private var sendBugReportButton: some View {
VStack(alignment: .leading, spacing: 4) {
Button {
Task {
await sendBugReport()
}
} label: {
HStack {
if bugReportInFlight {
ProgressView()
.scaleEffect(0.6)
}
Text("Send Bug Report")
.font(.caption)
.fontWeight(.semibold)
Spacer()
}
}
.disabled(bugReportInFlight)
if let message = bugReportMessage {
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Actions
private func sendBugReport() async {
bugReportInFlight = true
bugReportMessage = "Collecting logs..."
let service = BugReportService()
do {
let outcome = try await service.sendReport(isManual: true)
bugReportMessage = outcome.message
} catch {
bugReportMessage = error.localizedDescription
}
bugReportInFlight = false
}
private func showUninstallConfirmationAlert() {
let alert = NSAlert()
alert.messageText = "Uninstall EXO"
alert.informativeText = """
This will remove EXO and all its system components:
This will remove EXO and all its components:
• Network configuration daemon
• Launch at login registration
• EXO network location
• EXO data directory (~/.exo)
The app will be moved to Trash.
"""
alert.alertStyle = .warning
let checkbox = NSButton(
checkboxWithTitle: "Keep downloaded models (~/.exo/models)",
target: nil, action: nil)
checkbox.state = .off
checkbox.sizeToFit()
alert.accessoryView = checkbox
alert.addButton(withTitle: "Uninstall")
alert.addButton(withTitle: "Cancel")
@@ -570,11 +533,11 @@ struct SettingsView: View {
let response = alert.runModal()
if response == .alertFirstButtonReturn {
performUninstall()
performUninstall(keepModels: checkbox.state == .on)
}
}
private func performUninstall() {
private func performUninstall(keepModels: Bool) {
uninstallInProgress = true
controller.cancelPendingLaunch()
@@ -584,6 +547,7 @@ struct SettingsView: View {
DispatchQueue.global(qos: .utility).async {
do {
try NetworkSetupHelper.uninstall()
try Self.removeExoDirectory(keepModels: keepModels)
DispatchQueue.main.async {
LaunchAtLoginHelper.disable()
@@ -607,6 +571,23 @@ struct SettingsView: View {
}
}
private static func removeExoDirectory(keepModels: Bool) throws {
let fm = FileManager.default
let exoDir = ExoProcessController.exoDirectoryURL
guard fm.fileExists(atPath: exoDir.path) else { return }
if !keepModels {
try fm.removeItem(at: exoDir)
return
}
let contents = try fm.contentsOfDirectory(
at: exoDir, includingPropertiesForKeys: nil, options: [])
for entry in contents where entry.lastPathComponent != "models" {
try? fm.removeItem(at: entry)
}
}
private func moveAppToTrash() {
guard let appURL = Bundle.main.bundleURL as URL? else { return }
do {
+63 -7
View File
@@ -3,25 +3,55 @@
# EXO Uninstaller Script
#
# This script removes all EXO system components that persist after deleting the app.
# Run with: sudo ./uninstall-exo.sh
# Run with: sudo ./uninstall-exo.sh [--keep-models]
#
# Options:
# --keep-models Preserve ~/.exo/models when removing the EXO data directory.
#
# Components removed:
# - LaunchDaemon: /Library/LaunchDaemons/io.exo.networksetup.plist
# - Network script: /Library/Application Support/EXO/
# - Log files: /var/log/io.exo.networksetup.*
# - Network location: "exo"
# - EXO data directory: ~/.exo (or all of ~/.exo except models/ when --keep-models is set)
# - Launch at login registration
#
set -euo pipefail
KEEP_MODELS=0
for arg in "$@"; do
case "$arg" in
--keep-models)
KEEP_MODELS=1
;;
-h | --help)
echo "Usage: sudo ./uninstall-exo.sh [--keep-models]"
echo " --keep-models Preserve ~/.exo/models when removing the EXO data directory."
exit 0
;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: sudo ./uninstall-exo.sh [--keep-models]" >&2
exit 2
;;
esac
done
LABEL="io.exo.networksetup"
SCRIPT_DEST="/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh"
# Current script path. Older installs used a different filename; keep the
# legacy path here so a fresh uninstall still cleans up upgraded machines.
CURRENT_SCRIPT_DEST="/Library/Application Support/EXO/disable_bridge.sh"
LEGACY_SCRIPT_DEST="/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh"
PLIST_DEST="/Library/LaunchDaemons/io.exo.networksetup.plist"
LOG_OUT="/var/log/${LABEL}.log"
LOG_ERR="/var/log/${LABEL}.err.log"
APP_BUNDLE_ID="io.exo.EXO"
# Resolve the invoking user's home, even when run via sudo.
USER_HOME="$(eval echo "~${SUDO_USER:-$USER}")"
EXO_DIR="$USER_HOME/.exo"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -69,11 +99,17 @@ else
echo_warn "LaunchDaemon plist not found (already removed?)"
fi
# Remove the script and parent directory
if [[ -f $SCRIPT_DEST ]]; then
rm -f "$SCRIPT_DEST"
echo_info "Removed network setup script"
else
# Remove the script (current and legacy filenames) — backwards-compatible:
# tolerate either, both, or neither being present.
removed_any_script=0
for script in "$CURRENT_SCRIPT_DEST" "$LEGACY_SCRIPT_DEST"; do
if [[ -f $script ]]; then
rm -f "$script"
echo_info "Removed network setup script: $script"
removed_any_script=1
fi
done
if [[ $removed_any_script -eq 0 ]]; then
echo_warn "Network setup script not found (already removed?)"
fi
@@ -115,6 +151,22 @@ if networksetup -listnetworkservices 2>/dev/null | grep -q "Thunderbolt Bridge";
echo_info "Re-enabled Thunderbolt Bridge"
fi
# Remove EXO data directory (~/.exo)
EXO_DIR_REMOVED=""
if [[ -d $EXO_DIR ]]; then
if [[ $KEEP_MODELS == "1" && -d "$EXO_DIR/models" ]]; then
find "$EXO_DIR" -mindepth 1 -maxdepth 1 ! -name models -exec rm -rf {} +
EXO_DIR_REMOVED="kept_models"
echo_info "Removed ~/.exo (preserved models/)"
else
rm -rf "$EXO_DIR"
EXO_DIR_REMOVED="full"
echo_info "Removed ~/.exo"
fi
else
echo_warn "~/.exo not found (already removed?)"
fi
# Note about launch at login registration
# SMAppService-based login items cannot be removed from a shell script.
# They can only be unregistered from within the app itself or manually via System Settings.
@@ -144,6 +196,10 @@ echo " • Network setup LaunchDaemon"
echo " • Network configuration script"
echo " • Log files"
echo " • 'exo' network location"
case "$EXO_DIR_REMOVED" in
full) echo " • EXO data directory (~/.exo)" ;;
kept_models) echo " • EXO data directory (~/.exo, models preserved)" ;;
esac
echo ""
echo "Your network has been restored to use the 'Automatic' location."
echo "Thunderbolt Bridge has been re-enabled (if present)."
+14
View File
@@ -0,0 +1,14 @@
"""CLI front-ends for bench library benchmarks.
Each benchmark is a sub-package / module with two pieces:
- a ``run(...)`` callable in ``bench.lib.<name>`` that does the actual
measurement (no argparse, no eco, no I/O)
- an ``add_subparser(subparsers)`` helper here that wires CLI args to a
handler invoking the lib
The main entry point dispatches to the requested subcommand:
uv run python -m bench.cli context-scaling --hosts s4 \\
--model mlx-community/Qwen3-30B-A3B-4bit
"""
+56
View File
@@ -0,0 +1,56 @@
"""``python -m bench.cli`` — dispatcher for benchmark subcommands.
To add a new benchmark:
1. Implement the methodology in ``bench.lib.<name>`` exposing a typed
``run(session, params, bundle)`` callable (no argparse, no eco I/O).
2. Implement a ``bench.cli.<name>`` module with an ``add_subparser`` and
a ``run(args) -> Path`` handler.
3. Add an ``import + add_subparser(subparsers)`` line below.
"""
from __future__ import annotations
import argparse
import sys
from collections.abc import Callable
from pathlib import Path
from typing import cast
from bench.cli import campaign, context_scaling, plot
from bench.cli._common import expand_config_in_argv
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m bench.cli",
description=(
"Composable, eco-managed benchmarks for exo. "
"Pick a subcommand and pass its model / cluster options."
),
)
subparsers = parser.add_subparsers(
dest="subcommand",
required=True,
metavar="SUBCOMMAND",
)
context_scaling.add_subparser(subparsers)
plot.add_subparser(subparsers)
campaign.add_subparser(subparsers)
return parser
def main(argv: list[str] | None = None) -> int:
raw_argv = list(argv if argv is not None else sys.argv[1:])
expanded = expand_config_in_argv(raw_argv)
args = _build_parser().parse_args(expanded)
handler = getattr(args, "handler", None)
if not callable(handler):
subcommand = getattr(args, "subcommand", "<unknown>")
raise SystemExit(f"subcommand {subcommand!r} did not register a handler")
cast("Callable[[argparse.Namespace], Path]", handler)(args)
return 0
if __name__ == "__main__":
sys.exit(main())
+345
View File
@@ -0,0 +1,345 @@
"""Shared CLI argument parsing for the bench command-line interface.
Every benchmark subcommand inherits the same model / cluster / output
arguments via :func:`add_shared_args` and consumes them through
:class:`SharedOptions`. argparse's ``Namespace.<attr>`` is fundamentally
typed ``Any``; the :func:`get_arg` / :func:`get_arg_optional` helpers are
the single boundary where we coerce to typed values.
A ``--config <path>.toml`` flag lets the caller capture a run definition
in a TOML file. :func:`expand_config_in_argv` rewrites argv in place,
substituting the config's keys as CLI flags placed *before* any explicit
user args so that explicit CLI flags always win.
"""
from __future__ import annotations
import argparse
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TypeVar
from exo_tools.cluster import Chip, Thunderbolt
from exo_tools.harness import Comm, Sharding
_T = TypeVar("_T")
def get_arg(args: argparse.Namespace, name: str, type_: type[_T]) -> _T:
"""Return ``args.<name>``, asserting it's an instance of ``type_``.
For ``int`` and ``float`` we additionally accept inputs that ``int(.)`` /
``float(.)`` would parse, since argparse's ``type=int`` already coerces
cleanly on input but post-`set_defaults` callers may pass raw values.
"""
raw: Any = getattr(args, name) # type: ignore[reportAny]
if isinstance(raw, type_):
return raw
if type_ is int and isinstance(raw, (int, str)):
return int(raw) # type: ignore[return-value]
if type_ is float and isinstance(raw, (int, float, str)):
return float(raw) # type: ignore[return-value]
raise TypeError(
f"argparse field {name!r} expected {type_.__name__}, got {type(raw).__name__}" # type: ignore[reportUnknownArgumentType]
)
def get_arg_optional(args: argparse.Namespace, name: str, type_: type[_T]) -> _T | None:
"""Like :func:`get_arg` but allows the field to be missing or None."""
raw = getattr(args, name, None)
if raw is None:
return None
if isinstance(raw, type_):
return raw
if type_ is int and isinstance(raw, (int, str)):
return int(raw) # type: ignore[return-value]
if type_ is float and isinstance(raw, (int, float, str)):
return float(raw) # type: ignore[return-value]
raise TypeError(
f"argparse field {name!r} expected {type_.__name__} or None, "
f"got {type(raw).__name__}" # type: ignore[reportUnknownArgumentType]
)
@dataclass(frozen=True)
class SharedOptions:
"""Parsed shared CLI options for any benchmark."""
model: str
hosts: tuple[str, ...]
nodes: int
thunderbolt: Thunderbolt | None
chip: Chip | None
min_memory_gb: float | None
max_memory_gb: float | None
min_disk_gb: float | None
max_disk_gb: float | None
evict_downloads: bool
sharding: Sharding
comm: Comm
min_nodes: int
output_dir: Path
tags: dict[str, str]
cleanup_instance: bool
user_prefix: str
@classmethod
def from_namespace(cls, args: argparse.Namespace) -> SharedOptions:
hosts_raw = get_arg_optional(args, "hosts", str)
thunderbolt_raw = get_arg_optional(args, "thunderbolt", str)
chip_raw = get_arg_optional(args, "chip", str)
tag_list_raw: object = getattr(args, "tag", None) or []
if isinstance(tag_list_raw, list):
tag_list: list[str] = [
str(t) # type: ignore[reportUnknownArgumentType]
for t in tag_list_raw # type: ignore[reportUnknownVariableType]
]
else:
tag_list = []
return cls(
model=get_arg(args, "model", str),
hosts=tuple(_parse_csv(hosts_raw)) if hosts_raw else (),
nodes=get_arg(args, "nodes", int),
thunderbolt=Thunderbolt(thunderbolt_raw) if thunderbolt_raw else None,
chip=Chip(chip_raw) if chip_raw else None,
min_memory_gb=get_arg_optional(args, "min_memory_gb", float),
max_memory_gb=get_arg_optional(args, "max_memory_gb", float),
min_disk_gb=get_arg_optional(args, "min_disk_gb", float),
max_disk_gb=get_arg_optional(args, "max_disk_gb", float),
evict_downloads=get_arg(args, "evict_downloads", bool),
sharding=Sharding(get_arg(args, "sharding", str)),
comm=Comm(get_arg(args, "comm", str)),
min_nodes=get_arg(args, "min_nodes", int),
output_dir=Path(get_arg(args, "output_dir", str)),
tags=_parse_tags(tag_list),
cleanup_instance=get_arg(args, "cleanup_instance", bool),
user_prefix=get_arg(args, "eco_user_prefix", str),
)
def add_shared_args(parser: argparse.ArgumentParser) -> None:
"""Register the shared-arg group on ``parser``.
The bool flags (``--auto-constrain``, ``--evict-downloads``,
``--cleanup-instance``) all default to True and use
:class:`argparse.BooleanOptionalAction` so callers opt out via the
``--no-X`` form (or set ``X = false`` in a TOML config).
"""
g_config = parser.add_argument_group("config file")
g_config.add_argument(
"--config",
default=None,
help="TOML file with run parameters. CLI flags placed after --config "
"override values from the file.",
)
g_model = parser.add_argument_group("model")
g_model.add_argument(
"--model",
required=True,
help="HuggingFace model id. To run multiple models in one go, use "
"the 'campaign' subcommand with a TOML file listing each as a "
"separate [[runs]] entry.",
)
g_model.add_argument(
"--sharding",
default=Sharding.TENSOR.value,
choices=[s.value for s in Sharding],
help="Sharding mode for the placed instance. Default 'Tensor' (splits "
"layers within nodes; pairs with --comm MlxJaccl for high throughput "
"on TB-connected clusters). Use 'Pipeline' for layer-per-node sharding "
"(typical for single-node smoke tests).",
)
g_model.add_argument(
"--comm",
default=Comm.JACCL.value,
choices=[c.value for c in Comm],
help="Inter-node communication mode. Default 'MlxJaccl' (RDMA over "
"Thunderbolt; pairs with --sharding Tensor and --thunderbolt a2a). "
"Use 'MlxRing' for ring all-reduce over the regular network.",
)
g_model.add_argument("--min-nodes", type=int, default=1)
g_cluster = parser.add_argument_group("cluster")
g_cluster.add_argument(
"--hosts",
default=None,
help="Comma-separated host list (e.g. s4,s9). Bypasses constraint search.",
)
g_cluster.add_argument(
"--nodes",
type=int,
default=1,
help="Number of cluster nodes (hosts) to deploy on. "
"Distinct from --min-nodes which controls the model's instance placement.",
)
g_cluster.add_argument(
"--thunderbolt",
default=Thunderbolt.A2A.value,
choices=[t.value for t in Thunderbolt],
help="Thunderbolt topology required: 'a2a' (clique, default; needed "
"for tensor parallelism + JACCL), 'ring' (cycle; for pipeline + JACCL), "
"or 'none' (exclude TB-connected hosts; pair with --sharding Pipeline "
"--comm MlxRing).",
)
g_cluster.add_argument(
"--chip",
default=None,
choices=[c.value for c in Chip],
help="Chip required (e.g. 'M3 Ultra')",
)
g_cluster.add_argument(
"--min-memory-gb",
type=float,
default=None,
help="Min RAM (GB) on each host. If unset, auto-derived from the HF "
"model size (×1.30 + 1 GiB).",
)
g_cluster.add_argument(
"--max-memory-gb",
type=float,
default=None,
help="Max RAM (GB) on each host. Useful to leave bigger machines "
"free for other workloads.",
)
g_cluster.add_argument(
"--min-disk-gb",
type=float,
default=None,
help="Min free disk (GB) on each host. If unset, auto-derived from "
"the HF model size (×1.10 + 1 GiB).",
)
g_cluster.add_argument(
"--max-disk-gb",
type=float,
default=None,
help="Max disk (GB) on each host.",
)
g_runtime = parser.add_argument_group("runtime")
g_runtime.add_argument(
"--evict-downloads",
action=argparse.BooleanOptionalAction,
default=True,
help="Auto-evict existing models (smallest first) when disk is short to "
"make room for the bench model. Default on; pass --no-evict-downloads "
"to keep existing downloads.",
)
g_runtime.add_argument(
"--cleanup-instance",
action=argparse.BooleanOptionalAction,
default=True,
help="Clean up the placed instance after the benchmark exits. "
"Default on; pass --no-cleanup-instance to leave it running for debugging.",
)
g_runtime.add_argument(
"--eco-user-prefix",
default="bench",
help="USER prefix for the eco session (default: 'bench').",
)
g_output = parser.add_argument_group("output")
g_output.add_argument(
"--output-dir",
default="bench/results",
help="Base directory for JSON results. Subcommands may add a sub-folder.",
)
g_output.add_argument(
"--tag",
action="append",
default=[],
help="Add a 'key=value' tag to metadata.tags (repeatable).",
)
# ---------------------------------------------------------------------------
# TOML config expansion
# ---------------------------------------------------------------------------
def expand_config_in_argv(argv: list[str]) -> list[str]:
"""If ``--config <path>`` appears in ``argv``, splice the TOML's contents in.
The TOML file's keys are converted to CLI flags (``foo_bar`` →
``--foo-bar``) and inserted *before* the user's other args, so explicit
CLI flags always override the config. The ``--config <path>`` pair
itself is removed from argv. The first arg (the subcommand name) is
preserved at index 0.
Special handling:
- ``[tags]`` table → repeated ``--tag key=value`` occurrences
- lists → joined as a comma-separated value (matches the parser's
CSV handling for ``--hosts``)
- bool true/false → ``--key`` / ``--no-key`` (assumes the underlying
flag uses :class:`argparse.BooleanOptionalAction`)
"""
if "--config" not in argv:
return list(argv)
idx = argv.index("--config")
if idx + 1 >= len(argv):
raise ValueError("--config requires a path argument")
config_path = Path(argv[idx + 1])
if not config_path.is_file():
raise FileNotFoundError(f"Config file not found: {config_path}")
with config_path.open("rb") as f:
config_data: dict[str, Any] = tomllib.load(f)
expanded = _config_to_argv(config_data)
stripped = list(argv[:idx]) + list(argv[idx + 2 :])
if not stripped:
return expanded
# The subcommand name must come first; insert config-derived args
# right after it so that the user's later explicit args override.
return [stripped[0]] + expanded + stripped[1:]
def _config_to_argv(data: dict[str, Any]) -> list[str]:
"""Convert a TOML-loaded dict to a list of argv-style CLI flags."""
out: list[str] = []
for key in data:
value: Any = data[key] # type: ignore[reportAny]
if key == "tags" and isinstance(value, dict):
for tag_key, tag_value in value.items(): # type: ignore[reportUnknownVariableType]
out.extend(["--tag", f"{tag_key}={tag_value}"])
continue
if value is None:
continue
flag = "--" + key.replace("_", "-")
if isinstance(value, bool):
out.append(flag if value else f"--no-{key.replace('_', '-')}")
elif isinstance(value, list):
joined = ",".join(
str(x) # type: ignore[reportUnknownArgumentType]
for x in value # type: ignore[reportUnknownVariableType]
)
out.extend([flag, joined])
else:
out.extend([flag, str(value)]) # type: ignore[reportAny]
return out
def _parse_csv(raw: str) -> list[str]:
return [s.strip() for s in raw.split(",") if s.strip()]
def _parse_tags(raw: list[str]) -> dict[str, str]:
out: dict[str, str] = {}
for entry in raw:
if "=" not in entry:
raise argparse.ArgumentTypeError(
f"--tag must be 'key=value', got {entry!r}"
)
k, v = entry.split("=", 1)
out[k.strip()] = v.strip()
return out
@dataclass
class CommandResult:
"""Return value from a benchmark CLI handler."""
output_path: Path | None = None
extra: dict[str, str] = field(default_factory=dict)
+220
View File
@@ -0,0 +1,220 @@
"""Run a campaign of bench invocations from a single TOML file.
A campaign config has a ``[defaults]`` table (applied to every run) and a
list of ``[[runs]]`` entries (each a fully-formed invocation with its own
``subcommand``). The campaign runner merges defaults with each run's
overrides, dispatches to the matching subcommand handler, and collects
the output JSON paths.
Each run gets its own cluster — the deploy / teardown happens per-run.
After all runs finish, an optional ``[plot]`` table triggers a comparison
plot per benchmark group.
Schema::
[defaults]
nodes = 4
num_steps = 8
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.2-3b"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.1-8b"
[plot]
label_tag = "model_short"
"""
from __future__ import annotations
import argparse
import tomllib
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
from loguru import logger
from bench.cli import context_scaling
from bench.cli._common import (
_config_to_argv, # type: ignore[reportPrivateUsage]
get_arg,
)
from bench.lib.plotting import PlotInputs, render_context_scaling
# Each subcommand exposes its argparse via add_subparser. The campaign
# runner builds a one-off parser per run with only the chosen subcommand
# registered, parses the run-derived argv, and invokes the handler.
_SUBCOMMAND_PARSERS: dict[
str,
Callable[
[Any], None
], # subparsers action — argparse private; Any-typed at boundary
] = {
"context-scaling": context_scaling.add_subparser,
}
def add_subparser(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
) -> None:
parser = subparsers.add_parser(
"campaign",
help="Run a list of bench invocations from a single TOML config.",
description=__doc__,
)
parser.add_argument(
"config",
type=str,
help="TOML campaign file (with [defaults] + [[runs]] tables).",
)
parser.add_argument(
"--no-plot",
action="store_true",
help="Skip the optional comparison plot at the end of the campaign.",
)
parser.set_defaults(handler=run)
# ---------------------------------------------------------------------------
def run(args: argparse.Namespace) -> Path | None:
config_path = Path(get_arg(args, "config", str))
if not config_path.is_file():
raise SystemExit(f"campaign: file not found: {config_path}")
with config_path.open("rb") as f:
raw = tomllib.load(f)
defaults = _table(raw, "defaults")
runs_obj = raw.get("runs")
if not isinstance(runs_obj, list) or not runs_obj:
raise SystemExit(f"campaign: {config_path}: missing or empty [[runs]] list")
runs_raw: list[Any] = cast("list[Any]", runs_obj)
plot_cfg = _table(raw, "plot")
n_runs = len(runs_raw)
output_paths: dict[str, list[Path]] = {}
for i, run_obj in enumerate(runs_raw): # type: ignore[reportAny]
if not isinstance(run_obj, dict):
raise SystemExit(
f"campaign: run #{i + 1}: expected a TOML table, "
f"got {type(run_obj).__name__}" # type: ignore[reportUnknownArgumentType]
)
run_cfg = cast("dict[str, Any]", run_obj)
merged = _merge(defaults, run_cfg)
subcommand_obj: Any = merged.pop("subcommand", None) # type: ignore[reportAny]
if not isinstance(subcommand_obj, str):
raise SystemExit(
f"campaign: run #{i + 1}: 'subcommand' field is required (str)"
)
if subcommand_obj not in _SUBCOMMAND_PARSERS:
raise SystemExit(
f"campaign: run #{i + 1}: unknown subcommand "
f"{subcommand_obj!r} (have {sorted(_SUBCOMMAND_PARSERS)})"
)
argv_for_run = _config_to_argv(merged)
sub_args = _parse_for_subcommand(subcommand_obj, argv_for_run)
handler = getattr(sub_args, "handler", None)
if not callable(handler):
raise SystemExit(f"campaign: subcommand {subcommand_obj!r} has no handler")
logger.info(
f"campaign: starting run {i + 1}/{n_runs} "
f"({subcommand_obj}; {len(merged)} flags)"
)
out = cast("Callable[[argparse.Namespace], Path]", handler)(sub_args)
output_paths.setdefault(subcommand_obj, []).append(Path(out))
logger.info(f"campaign: finished run {i + 1}/{n_runs}{out}")
last_path: Path | None = None
for paths in output_paths.values():
if paths:
last_path = paths[-1]
if get_arg(args, "no_plot", bool):
return last_path
comparison = _render_comparisons(output_paths, plot_cfg)
return comparison or last_path
# ---------------------------------------------------------------------------
def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
"""Return ``data[key]`` if it's a table, else an empty dict."""
val: Any = data.get(key)
return cast("dict[str, Any]", val) if isinstance(val, dict) else {}
def _merge(defaults: dict[str, Any], run: dict[str, Any]) -> dict[str, Any]:
"""Shallow-merge ``defaults`` with ``run``; ``run`` wins on conflict.
The ``tags`` table is deep-merged (defaults' tags + run's tags) so a
campaign-level operator tag and a per-run model_short tag both survive.
"""
merged: dict[str, Any] = {**defaults, **run}
default_tags = _table(defaults, "tags")
run_tags = _table(run, "tags")
if default_tags or run_tags:
merged["tags"] = {**default_tags, **run_tags}
return merged
def _parse_for_subcommand(
subcommand: str, argv_for_run: list[str]
) -> argparse.Namespace:
"""Build a one-off parser with ``subcommand`` registered + parse argv."""
parser = argparse.ArgumentParser(prog=f"bench campaign:{subcommand}")
subparsers = parser.add_subparsers(dest="subcommand", required=True)
_SUBCOMMAND_PARSERS[subcommand](subparsers)
return parser.parse_args([subcommand] + argv_for_run)
def _render_comparisons(
output_paths: dict[str, list[Path]],
plot_cfg: dict[str, Any],
) -> Path | None:
"""Render one comparison plot per benchmark group with ≥2 outputs."""
label_tag = _str_or_none(plot_cfg.get("label_tag"))
title = _str_or_none(plot_cfg.get("title"))
last: Path | None = None
for subcommand, paths in output_paths.items():
if len(paths) < 2:
continue
if subcommand != "context-scaling":
logger.warning(
f"campaign: no comparison renderer registered for {subcommand!r}; "
"skipping comparison plot"
)
continue
out = paths[0].with_name(f"campaign_{subcommand}_compare.png")
last = render_context_scaling(
PlotInputs(
results=paths,
output=out,
label_tag=label_tag,
title=title,
)
)
logger.info(f"campaign: wrote comparison plot {last}")
return last
def _str_or_none(value: Any) -> str | None: # type: ignore[reportAny]
return value if isinstance(value, str) else None
__all__ = ["add_subparser", "run"]
+270
View File
@@ -0,0 +1,270 @@
"""Context-scaling benchmark — CLI subcommand.
Wraps :func:`bench.lib.context_scaling.run` with:
- HF model-metadata resolution
- Auto-derived constraints (memory, disk) and context ramp (Δ, K)
- eco cluster + instance lifecycle (managed_cluster + managed_instance)
- Cold-control isolation (delete sweep instance before controls)
- JSON results + ``latest.json`` symlink under ``<output-dir>/context_scaling/``
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from exo_tools.cluster import EcoSession
from loguru import logger
from bench.cli._common import (
SharedOptions,
add_shared_args,
get_arg,
get_arg_optional,
)
from bench.lib import context_scaling
from bench.lib.cluster import managed_cluster, managed_instance
from bench.lib.context_scaling import (
ContextScalingParams,
make_cold_control_factory,
)
from bench.lib.model_meta import (
ModelMeta,
derive_cold_controls,
derive_context_ramp,
fetch_model_meta,
)
from bench.lib.results import ResultsBundle, RunMetadata, find_repo_root
def add_subparser(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
) -> None:
parser = subparsers.add_parser(
"context-scaling",
help="Prompt-TPS / decode-TPS vs context-size sweep",
description=__doc__,
)
add_shared_args(parser)
g = parser.add_argument_group("context-scaling")
g.add_argument(
"--num-steps",
type=int,
default=32,
help="Number of equally-spaced PP points in the ramp (K).",
)
g.add_argument(
"--pp-step",
type=int,
default=None,
help="Δ (token step). If unset, derived from the model's max context.",
)
g.add_argument(
"--fraction-of-max",
type=float,
default=1.0,
help="When Δ is auto-derived, use this fraction of the model's "
"max_position_embeddings as the ramp's upper bound (0 < f ≤ 1).",
)
g.add_argument(
"--tg",
type=int,
default=64,
help="Tokens to generate per step (decode duration; constant across ramp).",
)
g.add_argument(
"--warmup",
type=int,
default=2,
help="Warmup requests at pp=Δ before the measured ramp. "
"First warmup is cache-disabled (kernel JIT only); subsequent "
"warmups are cache-enabled (the second is the one that primes "
"the cache entry with a hot-kernel rate). Default 2 is the "
"sweet spot: warmup=0 leaves JIT cost in step 0; warmup=1 has "
"step 0 as a 'none' hit (still hot-kernel cold prefill, just "
"classified differently).",
)
g.add_argument(
"--cold-controls",
type=str,
default=None,
help="Cold-control pp values to take after the cached sweep. Either "
"'auto' (4 evenly-spaced points across the ramp) or a comma-separated "
"list of explicit pp values (e.g. '8192,32768,65536'). "
"Default: no cold controls.",
)
g.add_argument(
"--sleep-between-s",
type=float,
default=1.0,
help="Seconds to sleep between consecutive sweep requests.",
)
parser.set_defaults(handler=run)
# ---------------------------------------------------------------------------
def run(args: argparse.Namespace) -> Path:
"""Execute the context-scaling benchmark per the parsed args.
Returns the path of the JSON results file.
"""
shared = SharedOptions.from_namespace(args)
repo_root = find_repo_root()
# 1. Fetch HF metadata up-front; everything else can be derived from it.
logger.info(f"fetching HuggingFace metadata for {shared.model}")
meta = fetch_model_meta(shared.model)
logger.info(
f" weights: {meta.total_weight_gb:.1f}GB; "
f"max context: {meta.max_position_embeddings} tokens; "
f"layers: {meta.num_hidden_layers}"
)
# 2. Derive constraints (user values always win; otherwise fall back to
# ModelMeta heuristics for the *minimums*).
min_memory_gb = (
shared.min_memory_gb
if shared.min_memory_gb is not None
else meta.memory_constraint_gb
)
min_disk_gb = (
shared.min_disk_gb
if shared.min_disk_gb is not None
else meta.disk_constraint_gb
)
logger.info(f" cluster constraint: min memory {min_memory_gb:.1f}GB")
logger.info(f" cluster constraint: min disk {min_disk_gb:.1f}GB")
if shared.max_memory_gb is not None:
logger.info(f" cluster constraint: max memory {shared.max_memory_gb:.1f}GB")
if shared.max_disk_gb is not None:
logger.info(f" cluster constraint: max disk {shared.max_disk_gb:.1f}GB")
explicit_pp_step = get_arg_optional(args, "pp_step", int)
num_steps = get_arg(args, "num_steps", int)
if explicit_pp_step is not None:
pp_step = explicit_pp_step
else:
pp_step, num_steps = derive_context_ramp(
meta,
num_steps=num_steps,
fraction_of_max=get_arg(args, "fraction_of_max", float),
)
logger.info(
f" derived ramp: Δ={pp_step} × K={num_steps} "
f"= {pp_step * num_steps} tokens (max {meta.max_position_embeddings})"
)
cold_controls = _resolve_cold_controls(
args, meta, pp_step=pp_step, num_steps=num_steps
)
if cold_controls:
logger.info(f" cold controls: {list(cold_controls)}")
# 3. Spin up cluster + instance + run.
eco = EcoSession(user_prefix=shared.user_prefix)
output_dir = (shared.output_dir / "context_scaling").resolve()
metadata = RunMetadata.new(
benchmark="context_scaling",
repo_root=repo_root,
tags={**shared.tags, "host_pool": ",".join(shared.hosts) or "<auto>"},
)
bundle = ResultsBundle(metadata=metadata)
with (
managed_cluster(
eco,
hosts=list(shared.hosts) or None,
count=shared.nodes,
thunderbolt=shared.thunderbolt,
chip=shared.chip,
min_memory_gb=min_memory_gb,
max_memory_gb=shared.max_memory_gb,
min_disk_gb=min_disk_gb,
max_disk_gb=shared.max_disk_gb,
) as cluster,
managed_instance(
cluster,
eco,
shared.model,
sharding=shared.sharding,
comm=shared.comm,
min_nodes=shared.min_nodes,
evict_downloads=shared.evict_downloads,
cleanup_on_exit=shared.cleanup_instance,
) as session,
):
params = ContextScalingParams(
pp_step=pp_step,
num_steps=num_steps,
tg=get_arg(args, "tg", int),
warmup=get_arg(args, "warmup", int),
cold_controls=cold_controls,
sleep_between_s=get_arg(args, "sleep_between_s", float),
)
factory = (
make_cold_control_factory(
session, shared.sharding, shared.comm, shared.min_nodes
)
if cold_controls
else None
)
context_scaling.run(session, params, bundle, cold_control_factory=factory)
out_path = bundle.write_json(output_dir)
_update_latest_symlink(out_path)
logger.info(f"wrote results → {out_path}")
_validate_partial_hits(bundle)
return out_path
# ---------------------------------------------------------------------------
def _resolve_cold_controls(
args: argparse.Namespace,
meta: ModelMeta,
*,
pp_step: int,
num_steps: int,
) -> tuple[int, ...]:
raw = get_arg_optional(args, "cold_controls", str)
if raw is None or not raw.strip():
return ()
if raw.strip().lower() == "auto":
return derive_cold_controls(meta, pp_step=pp_step, num_steps=num_steps, count=4)
return tuple(int(s.strip()) for s in raw.split(",") if s.strip())
def _update_latest_symlink(out_path: Path) -> None:
"""Update ``<dir>/latest.json`` to point at the newly-written file."""
link = out_path.parent / "latest.json"
try:
if link.is_symlink() or link.exists():
link.unlink()
os.symlink(out_path.name, link)
except OSError as e:
logger.warning(f"could not update latest.json symlink: {e}")
def _validate_partial_hits(bundle: ResultsBundle) -> None:
"""Hard-fail if the cached sweep didn't see ``partial`` on every step ≥ 1.
Step 0 is allowed to be ``exact`` (warmup primed the cache at pp=Δ); a
later ``exact`` means Δ was effectively absorbed into the cache and the
cold-rate measurement is meaningless. ``none`` means the cache was
discarded mid-sweep and ``T_cum`` is unreliable.
"""
cached = [r for r in bundle.runs if r.get("phase") == "cached_sweep"]
bad = [r for r in cached[1:] if r.get("prefix_cache_hit") != "partial"]
if bad:
bad_summary = [(r["step_index"], r["prefix_cache_hit"]) for r in bad]
raise RuntimeError(
f"{len(bad)} cached-sweep step(s) reported "
f"prefix_cache_hit != 'partial': {bad_summary!r}; "
"T_cum is unreliable."
)
+125
View File
@@ -0,0 +1,125 @@
"""Plot benchmark results — CLI subcommand.
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
uv run python -m bench.cli plot run_a.json run_b.json --label-tag operator
uv run python -m bench.cli plot latest.json --output /tmp/scaling.png
The benchmark type is detected from each JSON's ``metadata.benchmark`` —
all input files must share the same benchmark.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any, cast
from loguru import logger
from bench.cli._common import get_arg_optional
from bench.lib.plotting import PlotInputs, render_context_scaling
def add_subparser(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
) -> None:
parser = subparsers.add_parser(
"plot",
help="Render benchmark JSON result(s) as a PNG.",
description=__doc__,
)
parser.add_argument(
"paths",
nargs="+",
type=str,
help="One or more bench results JSON files. Multiple files are "
"rendered as a comparison plot (one line per file).",
)
parser.add_argument(
"--output",
type=str,
default=None,
help="PNG output path. Default: replace the first JSON's '.json' "
"suffix with '.png' (or '.compare.png' when multiple inputs).",
)
parser.add_argument(
"--label-tag",
type=str,
default=None,
help="Use metadata.tags[<KEY>] as the legend label for each run "
"(falls back to run_id if unset or missing).",
)
parser.add_argument(
"--title",
type=str,
default=None,
help="Override the auto-generated figure title.",
)
parser.set_defaults(handler=run)
def run(args: argparse.Namespace) -> Path:
paths_raw = getattr(args, "paths", None)
if not isinstance(paths_raw, list) or not paths_raw:
raise SystemExit("plot: at least one JSON path is required")
paths = [
Path(str(p)) # type: ignore[reportUnknownArgumentType]
for p in cast("list[Any]", paths_raw) # type: ignore[reportAny]
]
for p in paths:
if not p.is_file():
raise SystemExit(f"plot: file not found: {p}")
benchmarks = {_benchmark_for(p) for p in paths}
if len(benchmarks) != 1:
raise SystemExit(
f"plot: all input JSONs must share the same benchmark, got {benchmarks!r}"
)
benchmark = next(iter(benchmarks))
output_arg = get_arg_optional(args, "output", str)
output = Path(output_arg) if output_arg is not None else _default_output(paths)
inputs = PlotInputs(
results=paths,
output=output,
label_tag=get_arg_optional(args, "label_tag", str),
title=get_arg_optional(args, "title", str),
)
if benchmark == "context_scaling":
out_path = render_context_scaling(inputs)
else:
raise SystemExit(f"plot: no renderer registered for benchmark {benchmark!r}")
logger.info(f"plot: wrote {out_path}")
return out_path
# ---------------------------------------------------------------------------
def _benchmark_for(path: Path) -> str:
with path.open() as f:
loaded: Any = json.load(f) # type: ignore[reportAny]
if not isinstance(loaded, dict):
raise SystemExit(f"plot: {path}: expected top-level JSON object")
metadata: Any = loaded.get("metadata", {}) # type: ignore[reportAny]
if not isinstance(metadata, dict):
raise SystemExit(f"plot: {path}: metadata is not an object")
benchmark: Any = metadata.get("benchmark") # type: ignore[reportAny]
if not isinstance(benchmark, str):
raise SystemExit(f"plot: {path}: metadata.benchmark missing or not a string")
return benchmark
def _default_output(paths: list[Path]) -> Path:
"""Auto-derive a PNG path next to the first JSON.
Single input → ``<path>.png`` (replaces ``.json``).
Multiple inputs → ``<path>.compare.png`` next to the first JSON.
"""
first = paths[0]
if len(paths) == 1:
return first.with_suffix(".png")
return first.with_name(first.stem + ".compare.png")
File renamed without changes.
+109
View File
@@ -0,0 +1,109 @@
"""Unit tests for ``bench.cli.campaign``.
The pure helpers (defaults+run merge, table-lookup, str-or-none) are
tested here. End-to-end campaign execution requires a real eco cluster
and is exercised manually via ``bench campaign <toml>``.
"""
from __future__ import annotations
from bench.cli.campaign import (
_merge, # type: ignore[reportPrivateUsage]
_str_or_none, # type: ignore[reportPrivateUsage]
_table, # type: ignore[reportPrivateUsage]
)
# ---------------------------------------------------------------------------
# _table
# ---------------------------------------------------------------------------
class TestTable:
def test_present_table(self) -> None:
data = {"defaults": {"nodes": 4}}
assert _table(data, "defaults") == {"nodes": 4}
def test_missing_key_returns_empty(self) -> None:
assert _table({}, "absent") == {}
def test_non_table_value_returns_empty(self) -> None:
# `nodes = 4` is an int at top level, not a table; treat as empty.
assert _table({"nodes": 4}, "nodes") == {}
def test_list_value_returns_empty(self) -> None:
assert _table({"runs": [{"a": 1}]}, "runs") == {}
# ---------------------------------------------------------------------------
# _merge
# ---------------------------------------------------------------------------
class TestMerge:
def test_run_wins_on_conflict(self) -> None:
defaults = {"nodes": 4, "tg": 64}
run = {"nodes": 2}
assert _merge(defaults, run) == {"nodes": 2, "tg": 64}
def test_disjoint_keys(self) -> None:
defaults = {"nodes": 4}
run = {"model": "test/foo"}
assert _merge(defaults, run) == {"nodes": 4, "model": "test/foo"}
def test_run_only(self) -> None:
assert _merge({}, {"a": 1, "b": 2}) == {"a": 1, "b": 2}
def test_defaults_only(self) -> None:
assert _merge({"a": 1}, {}) == {"a": 1}
def test_tags_deep_merged_defaults_only(self) -> None:
defaults = {"tags": {"operator": "ciaranbor"}}
run = {"model": "test/foo"}
merged = _merge(defaults, run)
assert merged["tags"] == {"operator": "ciaranbor"}
def test_tags_deep_merged_run_only(self) -> None:
defaults = {"nodes": 4}
run = {"tags": {"model_short": "llama-3b"}}
merged = _merge(defaults, run)
assert merged["tags"] == {"model_short": "llama-3b"}
def test_tags_deep_merged_both(self) -> None:
defaults = {"tags": {"operator": "ciaranbor", "campaign": "smoke"}}
run = {"tags": {"model_short": "llama-3b"}}
merged = _merge(defaults, run)
assert merged["tags"] == {
"operator": "ciaranbor",
"campaign": "smoke",
"model_short": "llama-3b",
}
def test_run_tags_override_defaults_tags(self) -> None:
defaults = {"tags": {"operator": "ciaranbor"}}
run = {"tags": {"operator": "alice"}}
merged = _merge(defaults, run)
assert merged["tags"] == {"operator": "alice"}
def test_no_tags_table_means_no_tags_key(self) -> None:
# When neither side has tags, we don't synthesise an empty dict.
merged = _merge({"nodes": 4}, {"model": "test/foo"})
assert "tags" not in merged
# ---------------------------------------------------------------------------
# _str_or_none
# ---------------------------------------------------------------------------
class TestStrOrNone:
def test_str_passes_through(self) -> None:
assert _str_or_none("hello") == "hello"
def test_none_returns_none(self) -> None:
assert _str_or_none(None) is None
def test_int_returns_none(self) -> None:
assert _str_or_none(42) is None
def test_list_returns_none(self) -> None:
assert _str_or_none(["a", "b"]) is None
+275
View File
@@ -0,0 +1,275 @@
"""Unit tests for the argparse boundary helpers in ``bench.cli._common``."""
from __future__ import annotations
import argparse
from pathlib import Path
import pytest
from bench.cli._common import (
_config_to_argv, # type: ignore[reportPrivateUsage]
_parse_csv, # type: ignore[reportPrivateUsage]
_parse_tags, # type: ignore[reportPrivateUsage]
expand_config_in_argv,
get_arg,
get_arg_optional,
)
# ---------------------------------------------------------------------------
# _parse_csv
# ---------------------------------------------------------------------------
class TestParseCsv:
def test_simple_list(self) -> None:
assert _parse_csv("a,b,c") == ["a", "b", "c"]
def test_strips_whitespace(self) -> None:
assert _parse_csv(" a , b , c ") == ["a", "b", "c"]
def test_skips_empty_entries(self) -> None:
assert _parse_csv("a,,b,") == ["a", "b"]
assert _parse_csv(",,,") == []
def test_empty_string_returns_empty(self) -> None:
assert _parse_csv("") == []
def test_single_value(self) -> None:
assert _parse_csv("only") == ["only"]
# ---------------------------------------------------------------------------
# _parse_tags
# ---------------------------------------------------------------------------
class TestParseTags:
def test_empty_input_returns_empty_dict(self) -> None:
assert _parse_tags([]) == {}
def test_single_tag(self) -> None:
assert _parse_tags(["operator=ciaranbor"]) == {"operator": "ciaranbor"}
def test_multiple_tags(self) -> None:
assert _parse_tags(["a=1", "b=2", "c=3"]) == {"a": "1", "b": "2", "c": "3"}
def test_strips_whitespace_around_key_and_value(self) -> None:
assert _parse_tags([" key = value "]) == {"key": "value"}
def test_value_can_contain_equals(self) -> None:
assert _parse_tags(["url=http://example.com/?a=b"]) == {
"url": "http://example.com/?a=b"
}
def test_later_duplicate_key_wins(self) -> None:
# Standard dict behaviour; explicit so we notice if it changes.
assert _parse_tags(["k=v1", "k=v2"]) == {"k": "v2"}
def test_missing_equals_raises(self) -> None:
with pytest.raises(argparse.ArgumentTypeError, match="key=value"):
_ = _parse_tags(["malformed"])
def test_one_malformed_in_list_raises(self) -> None:
with pytest.raises(argparse.ArgumentTypeError):
_ = _parse_tags(["good=1", "bad", "alsogood=2"])
# ---------------------------------------------------------------------------
# get_arg / get_arg_optional
# ---------------------------------------------------------------------------
class TestGetArg:
def test_str_passes_through(self) -> None:
ns = argparse.Namespace(name="hello")
assert get_arg(ns, "name", str) == "hello"
def test_int_passes_through(self) -> None:
ns = argparse.Namespace(count=42)
assert get_arg(ns, "count", int) == 42
def test_int_coerces_from_string(self) -> None:
ns = argparse.Namespace(count="42")
assert get_arg(ns, "count", int) == 42
def test_float_passes_through(self) -> None:
ns = argparse.Namespace(rate=3.14)
assert get_arg(ns, "rate", float) == 3.14
def test_float_coerces_from_int(self) -> None:
ns = argparse.Namespace(rate=3)
assert get_arg(ns, "rate", float) == 3.0
def test_float_coerces_from_string(self) -> None:
ns = argparse.Namespace(rate="3.14")
assert get_arg(ns, "rate", float) == 3.14
def test_bool_passes_through(self) -> None:
ns = argparse.Namespace(flag=True)
assert get_arg(ns, "flag", bool) is True
def test_wrong_type_raises(self) -> None:
ns = argparse.Namespace(name=42)
with pytest.raises(TypeError, match="expected str"):
_ = get_arg(ns, "name", str)
def test_missing_attribute_raises(self) -> None:
ns = argparse.Namespace()
with pytest.raises(AttributeError):
_ = get_arg(ns, "missing", str)
class TestGetArgOptional:
def test_missing_returns_none(self) -> None:
ns = argparse.Namespace()
assert get_arg_optional(ns, "missing", str) is None
def test_explicit_none_returns_none(self) -> None:
ns = argparse.Namespace(value=None)
assert get_arg_optional(ns, "value", str) is None
def test_present_value_returns_typed(self) -> None:
ns = argparse.Namespace(value="present")
assert get_arg_optional(ns, "value", str) == "present"
def test_int_coerces_from_string(self) -> None:
ns = argparse.Namespace(value="42")
assert get_arg_optional(ns, "value", int) == 42
def test_float_coerces_from_int(self) -> None:
ns = argparse.Namespace(value=42)
assert get_arg_optional(ns, "value", float) == 42.0
def test_wrong_type_raises(self) -> None:
ns = argparse.Namespace(value=[1, 2, 3])
with pytest.raises(TypeError, match="expected str or None"):
_ = get_arg_optional(ns, "value", str)
# ---------------------------------------------------------------------------
# _config_to_argv
# ---------------------------------------------------------------------------
class TestConfigToArgv:
def test_empty(self) -> None:
assert _config_to_argv({}) == []
def test_string_value(self) -> None:
assert _config_to_argv({"model": "mlx/foo"}) == ["--model", "mlx/foo"]
def test_int_and_float_values(self) -> None:
out = _config_to_argv({"num_steps": 32, "fraction_of_max": 0.5})
assert out == ["--num-steps", "32", "--fraction-of-max", "0.5"]
def test_underscore_keys_become_hyphenated_flags(self) -> None:
out = _config_to_argv({"min_memory_gb": 21.0})
assert out == ["--min-memory-gb", "21.0"]
def test_bool_true_emits_flag(self) -> None:
assert _config_to_argv({"auto_constrain": True}) == ["--auto-constrain"]
def test_bool_false_emits_no_form(self) -> None:
assert _config_to_argv({"auto_constrain": False}) == ["--no-auto-constrain"]
def test_none_value_skipped(self) -> None:
assert _config_to_argv({"chip": None, "model": "foo"}) == [
"--model",
"foo",
]
def test_list_joined_as_csv(self) -> None:
out = _config_to_argv({"hosts": ["s4", "s9"], "cold_controls": [1024, 2048]})
assert out == [
"--hosts",
"s4,s9",
"--cold-controls",
"1024,2048",
]
def test_tags_table_expands_to_repeated_tag_args(self) -> None:
out = _config_to_argv({"tags": {"operator": "ciaranbor", "run": "full"}})
# Order within a TOML table is preserved by tomllib
assert out == [
"--tag",
"operator=ciaranbor",
"--tag",
"run=full",
]
# ---------------------------------------------------------------------------
# expand_config_in_argv
# ---------------------------------------------------------------------------
class TestExpandConfigInArgv:
def test_no_config_flag_passthrough(self) -> None:
argv = ["context-scaling", "--model", "foo"]
assert expand_config_in_argv(argv) == argv
def test_config_at_end(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text('model = "from_config"\nnum_steps = 16\n')
argv = ["context-scaling", "--config", str(cfg)]
# Config flags are inserted right after the subcommand
assert expand_config_in_argv(argv) == [
"context-scaling",
"--model",
"from_config",
"--num-steps",
"16",
]
def test_explicit_cli_overrides_config(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text('model = "from_config"\nnum_steps = 16\n')
# User overrides --num-steps explicitly. Argparse takes the last
# occurrence for non-append actions, so the user's 32 wins.
argv = ["context-scaling", "--config", str(cfg), "--num-steps", "32"]
out = expand_config_in_argv(argv)
assert out == [
"context-scaling",
"--model",
"from_config",
"--num-steps",
"16",
"--num-steps",
"32",
]
def test_missing_path_arg_raises(self) -> None:
with pytest.raises(ValueError, match="--config requires a path"):
_ = expand_config_in_argv(["context-scaling", "--config"])
def test_nonexistent_file_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="Config file not found"):
_ = expand_config_in_argv(
["context-scaling", "--config", str(tmp_path / "missing.toml")]
)
def test_bool_false_in_config(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text("auto_constrain = false\n")
argv = ["context-scaling", "--config", str(cfg)]
assert expand_config_in_argv(argv) == [
"context-scaling",
"--no-auto-constrain",
]
def test_tags_table(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text(
'model = "foo"\n[tags]\noperator = "ciaranbor"\nrun = "full"\n'
)
argv = ["context-scaling", "--config", str(cfg)]
assert expand_config_in_argv(argv) == [
"context-scaling",
"--model",
"foo",
"--tag",
"operator=ciaranbor",
"--tag",
"run=full",
]
@@ -0,0 +1,70 @@
# Example context-scaling run configuration.
#
# Use it like this:
#
# uv run python -m bench.cli context-scaling --config bench/configs/context_scaling.example.toml
#
# CLI flags placed after `--config` override individual values.
#
# All shared and subcommand-specific flags can appear here. Keys mirror the
# CLI flag names with hyphens replaced by underscores. Boolean keys map to
# `--key` / `--no-key`; lists are joined as CSV; the `[tags]` table maps to
# repeated `--tag key=value` flags.
#
# NOTE: TOML scoping — once a `[table]` header is opened, all subsequent
# top-level-looking assignments belong to that table until the next header.
# Keep tables (like `[tags]`) at the END of the file.
# ---- Model + placement ----
model = "mlx-community/Qwen3-30B-A3B-4bit"
# sharding = "Tensor" # default: "Tensor"; pairs with --comm MlxJaccl + --thunderbolt a2a
# comm = "MlxJaccl" # default: "MlxJaccl" (RDMA over Thunderbolt)
# min_nodes = 1
# ---- Cluster ----
# Either pin to specific hosts...
# hosts = ["s4"]
# ...or let eco pick hosts that satisfy the constraints below.
# nodes = 1
# chip = "M3 Ultra" # eco chip name (case-insensitive substring); comment to allow any
# thunderbolt = "a2a" # default: "a2a" (clique, for Tensor+JACCL)
# "ring" (cycle; for Pipeline+JACCL)
# "none" (exclude TB; pair with sharding=Pipeline + comm=MlxRing for non-TB hosts)
# Memory + disk minimums are auto-derived from the HF model size
# (×1.30 + 1 GiB for memory, ×1.10 + 1 GiB for disk). Set any of these
# explicitly to override the auto-derived value.
# min_memory_gb = 96.0
# max_memory_gb = 256.0 # leave bigger machines free for other workloads
# min_disk_gb = 24.0
# max_disk_gb = 4000.0
# ---- Runtime ----
# evict_downloads is true by default — frees disk smallest-first to fit
# the bench model. Set to false to keep existing downloads.
# evict_downloads = false
# cleanup_instance is true by default — deletes the placed instance on exit.
# Set to false to leave it running for debugging.
# cleanup_instance = false
# ---- Output ----
output_dir = "bench/results"
# ---- Context-scaling sweep ----
num_steps = 32 # K — number of equally-spaced ramp points
# pp_step = 1024 # Δ — explicit override; otherwise auto-derived
# fraction_of_max = 1.0 # use this fraction of max_position_embeddings
tg = 64 # tokens generated per step
# warmup = 2 # default: 2 (1 cache-disabled JIT warmup + 1 cache-priming warmup)
# cold_controls = "auto" # 4 evenly-spaced controls across the ramp, or:
# cold_controls = "8192,16384,32768,40960" # explicit pp values
sleep_between_s = 1.0
# ---- Tags ----
# Survive into metadata.tags in the output JSON; useful for filtering or
# grouping runs across SHAs / hosts / configs. `$USER` is NOT expanded
# (TOML is literal); pass `--tag operator=$USER` on the CLI for shell expansion.
# Must be the LAST table in the file (see TOML scoping note above).
[tags]
run = "full"
+34
View File
@@ -0,0 +1,34 @@
# 4-node smoke campaign: two small/medium Llama models, abbreviated ramps,
# auto-everything else (TB a2a + tensor + JACCL + auto-derived constraints).
#
# Run with:
# uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
#
# Each [[runs]] gets its own cluster (deploy + bench + teardown). After
# both runs finish, a side-by-side comparison plot is written next to the
# JSONs.
[defaults]
nodes = 4
num_steps = 8
fraction_of_max = 0.5
[defaults.tags]
campaign = "llama-family-smoke"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.2-3b-4bit"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.1-8b-4bit"
# Final comparison plot (one PNG per benchmark group with ≥2 runs).
[plot]
label_tag = "model_short"
title = "Llama 3 family — 4-node tensor + JACCL smoke"
+77 -22
View File
@@ -7,7 +7,7 @@
# name, patterns, reasoning
#
# Optional per-model overrides (CLI flags take priority over these):
# temperature, top_p, max_tokens, reasoning_effort
# temperature, top_p, max_tokens, reasoning_effort, enable_thinking
#
# Fallback defaults (when no per-model config):
# reasoning: temperature=1.0, max_tokens=131072, reasoning_effort="high"
@@ -18,10 +18,9 @@
# ─── Qwen3.5 (Feb 2026) ─────────────────────────────────────────────
# Source: HuggingFace model cards (Qwen/Qwen3.5-*)
# 35B-A3B thinking general: temp=1.0, top_p=0.95, top_k=20
# 397B thinking: temp=0.6, top_p=0.95, top_k=20
# Non-thinking: temp=0.7, top_p=0.8, top_k=20
# max_tokens: 32768 general, 81920 for complex math/code
# Model card recommends: temp=0.6, top_p=0.95, top_k=20
# We omit top_k to match vllm eval (which doesn't set it).
# max_tokens=121072 to match vllm eval (131072 context - 10000 safety margin).
[[model]]
name = "Qwen3.5 2B"
@@ -29,7 +28,8 @@ patterns = ["Qwen3.5-2B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 9B"
@@ -37,7 +37,8 @@ patterns = ["Qwen3.5-9B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 27B"
@@ -45,15 +46,17 @@ patterns = ["Qwen3.5-27B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 35B A3B"
patterns = ["Qwen3.5-35B-A3B"]
reasoning = true
temperature = 1.0
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 122B A10B"
@@ -61,7 +64,8 @@ patterns = ["Qwen3.5-122B-A10B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 397B A17B"
@@ -69,12 +73,14 @@ patterns = ["Qwen3.5-397B-A17B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
# ─── Qwen3 (Apr 2025) ───────────────────────────────────────────────
# Source: HuggingFace model cards (Qwen/Qwen3-*)
# Thinking: temp=0.6, top_p=0.95, top_k=20
# Non-thinking: temp=0.7, top_p=0.8, top_k=20
# Model card recommends: temp=0.6, top_p=0.95, top_k=20
# We omit top_k to match vllm eval (which doesn't set it).
# Non-thinking: temp=0.7, top_p=0.8
# max_tokens: 32768 general, 38912 for complex math/code
[[model]]
@@ -83,6 +89,7 @@ patterns = ["Qwen3-0.6B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -91,6 +98,7 @@ patterns = ["Qwen3-30B-A3B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -99,6 +107,7 @@ patterns = ["Qwen3-235B-A22B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -107,6 +116,7 @@ patterns = ["Qwen3-Next-80B-A3B-Thinking"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -129,9 +139,9 @@ max_tokens = 16384
name = "Qwen3 Coder Next"
patterns = ["Qwen3-Coder-Next"]
reasoning = false
temperature = 0.7
top_p = 0.8
max_tokens = 16384
temperature = 1.0
top_p = 0.95
max_tokens = 121072
# ─── GPT-OSS (OpenAI) ───────────────────────────────────────────────
# Source: OpenAI GitHub README + HuggingFace discussion #21
@@ -165,10 +175,38 @@ patterns = ["DeepSeek-V3.1"]
reasoning = true
temperature = 0.0
[[model]]
name = "DeepSeek V3.2"
patterns = ["DeepSeek-V3.2"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
# ─── NVIDIA Nemotron ───────────────────────────────────────────────────
# Source: HuggingFace model cards
# All variants: temp=1.0, top_p=0.95, enable_thinking=true
[[model]]
name = "Nemotron Cascade 2 30B A3B"
patterns = ["Nemotron-Cascade-2-30B-A3B"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
[[model]]
name = "Nemotron 3 Super 120B A12B"
patterns = ["Nemotron-3-Super-120B-A12B", "NVIDIA-Nemotron-3-Super-120B-A12B"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
# ─── GLM (ZhipuAI / THUDM) ──────────────────────────────────────────
# Source: HuggingFace model cards + generation_config.json + docs.z.ai
# GLM 4.5+: temp=1.0, top_p=0.95
# Reasoning tasks: 131072 max_tokens; coding/SWE tasks: temp=0.7
# max_tokens=121072 to match vllm eval (131072 context - 10000 safety margin)
[[model]]
name = "GLM-5"
@@ -176,7 +214,8 @@ patterns = ["GLM-5"]
reasoning = true
temperature = 1.0
top_p = 0.95
max_tokens = 131072
enable_thinking = true
max_tokens = 121072
[[model]]
name = "GLM 4.5 Air"
@@ -191,7 +230,8 @@ patterns = ["GLM-4.7-"]
reasoning = true
temperature = 1.0
top_p = 0.95
max_tokens = 131072
enable_thinking = true
max_tokens = 121072
# Note: matches both GLM-4.7 and GLM-4.7-Flash
# ─── Kimi (Moonshot AI) ─────────────────────────────────────────────
@@ -213,7 +253,8 @@ patterns = ["Kimi-K2.5"]
reasoning = true
temperature = 1.0
top_p = 0.95
max_tokens = 131072
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Kimi K2 Instruct"
@@ -223,7 +264,17 @@ temperature = 0.6
# ─── MiniMax ─────────────────────────────────────────────────────────
# Source: HuggingFace model cards + generation_config.json
# All models: temp=1.0, top_p=0.95, top_k=40
# All models: temp=1.0, top_p=0.95
# max_tokens=90000 to match vllm eval (100000 context - 10000 safety margin)
[[model]]
name = "MiniMax M2.7"
patterns = ["MiniMax-M2.7"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 90000
[[model]]
name = "MiniMax M2.5"
@@ -231,6 +282,8 @@ patterns = ["MiniMax-M2.5"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 90000
[[model]]
name = "MiniMax M2.1"
@@ -251,6 +304,8 @@ patterns = ["Step-3.5-Flash"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 121072
# ─── Llama (Meta) ───────────────────────────────────────────────────
# Source: generation_config.json + meta-llama/llama-models generation.py
+84 -40
View File
@@ -3,19 +3,20 @@ from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import sys
import time
import tomllib
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
import httpx
from harness import (
ExoClient,
ExoHttpError,
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
@@ -209,7 +210,7 @@ def _openai_build_request(
"model": model,
"messages": messages,
"tools": tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
return "/v1/chat/completions", body
@@ -276,7 +277,7 @@ def _openai_build_followup(
"model": model,
"messages": followup_messages,
"tools": tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
return "/v1/chat/completions", body
@@ -379,7 +380,7 @@ def _claude_build_request(
"model": model,
"messages": claude_messages,
"tools": claude_tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
if system_content is not None:
@@ -489,7 +490,7 @@ def _claude_build_followup(
"model": model,
"messages": claude_messages,
"tools": claude_tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
if system_content is not None:
@@ -913,6 +914,12 @@ Examples:
default=1,
help="Repeat each scenario N times (default: 1)",
)
parser.add_argument(
"--concurrency",
type=int,
default=1,
help="Run up to N scenarios in parallel against the same instance (default: 1)",
)
parser.add_argument(
"--scenarios",
nargs="*",
@@ -935,6 +942,13 @@ Examples:
)
args = parser.parse_args()
if args.concurrency < 1:
print(
f"--concurrency must be >= 1 (got {args.concurrency})",
file=sys.stderr,
)
sys.exit(2)
all_scenarios = load_scenarios(SCENARIOS_PATH)
if args.scenarios:
scenarios = [s for s in all_scenarios if s.name in args.scenarios]
@@ -1010,42 +1024,72 @@ Examples:
cluster_snapshot = capture_cluster_snapshot(exo)
all_results: list[ScenarioResult] = []
tasks: list[tuple[int, Scenario, ApiName]] = [
(run_idx, scenario, api_name)
for run_idx in range(args.repeat)
for scenario in scenarios
for api_name in api_names
]
def _run_one(
http_client: httpx.Client,
task: tuple[int, Scenario, ApiName],
) -> tuple[tuple[int, Scenario, ApiName], list[ScenarioResult], str]:
run_idx, scenario, api_name = task
buf = io.StringIO()
run_tag = f"[run {run_idx + 1}/{args.repeat}]" if args.repeat > 1 else ""
print(
f"\n {run_tag}[{api_name:>9}] {scenario.name}: {scenario.description}",
file=buf,
)
scenario_results = run_scenario(
http_client,
args.host,
args.port,
full_model_id,
scenario,
api_name,
args.timeout,
args.verbose,
)
for r in scenario_results:
status = "PASS" if r.passed else "FAIL"
print(
f" [{r.phase:>10}] {status} ({r.latency_ms:.0f}ms)",
file=buf,
)
for check_name, check_ok in r.checks.items():
mark = "+" if check_ok else "-"
print(f" {mark} {check_name}", file=buf)
if r.error:
print(f" ! {r.error}", file=buf)
return task, scenario_results, buf.getvalue()
try:
with httpx.Client() as http_client:
for run_idx in range(args.repeat):
if args.repeat > 1:
print(f"\n--- Run {run_idx + 1}/{args.repeat} ---", file=log)
for scenario in scenarios:
for api_name in api_names:
print(
f"\n [{api_name:>9}] {scenario.name}: {scenario.description}",
file=log,
)
scenario_results = run_scenario(
http_client,
args.host,
args.port,
full_model_id,
scenario,
api_name,
args.timeout,
args.verbose,
)
if args.concurrency == 1:
current_run = -1
for task in tasks:
run_idx = task[0]
if args.repeat > 1 and run_idx != current_run:
print(f"\n--- Run {run_idx + 1}/{args.repeat} ---", file=log)
current_run = run_idx
_, scenario_results, buffered = _run_one(http_client, task)
all_results.extend(scenario_results)
log.write(buffered)
log.flush()
else:
print(
f"Running {len(tasks)} tasks with concurrency={args.concurrency}",
file=log,
)
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
futures = [pool.submit(_run_one, http_client, t) for t in tasks]
for fut in as_completed(futures):
_, scenario_results, buffered = fut.result()
all_results.extend(scenario_results)
for r in scenario_results:
status = "PASS" if r.passed else "FAIL"
print(
f" [{r.phase:>10}] {status} ({r.latency_ms:.0f}ms)",
file=log,
)
for check_name, check_ok in r.checks.items():
mark = "+" if check_ok else "-"
print(f" {mark} {check_name}", file=log)
if r.error:
print(f" ! {r.error}", file=log)
log.write(buffered)
log.flush()
finally:
try:
exo.request_json("DELETE", f"/instance/{instance_id}")
+170 -264
View File
@@ -24,17 +24,15 @@ import json
import sys
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from statistics import mean
from typing import Any
from harness import (
ExoClient,
ExoHttpError,
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
@@ -45,85 +43,44 @@ from harness import (
wait_for_instance_ready,
)
from loguru import logger
from transformers import AutoTokenizer
# Monkey-patch for transformers 5.x compatibility
# Kimi's tokenization_kimi.py imports bytes_to_unicode from the old location
# which was moved in transformers 5.0.0rc2
try:
import transformers.models.gpt2.tokenization_gpt2 as gpt2_tokenization
from transformers.convert_slow_tokenizer import bytes_to_unicode
# PromptSizer / run_one_completion / load_tokenizer_for_bench are the
# canonical, fully-typed implementations under bench/lib/. They are
# re-exported here for backwards compatibility with prefill_decode_bench.py
# and any other consumers of `from exo_bench import …`.
from bench.lib.completion import run_one_completion as _lib_run_one_completion
from bench.lib.prompt import (
PromptSizer as _LibPromptSizer,
)
from bench.lib.prompt import (
load_tokenizer_for_bench as _lib_load_tokenizer_for_bench,
)
if not hasattr(gpt2_tokenization, "bytes_to_unicode"):
gpt2_tokenization.bytes_to_unicode = bytes_to_unicode # type: ignore[attr-defined]
except ImportError:
pass # transformers < 5.0 or bytes_to_unicode not available
PromptSizer = _LibPromptSizer
load_tokenizer_for_bench = _lib_load_tokenizer_for_bench
def load_tokenizer_for_bench(model_id: str) -> Any:
"""
Load tokenizer for benchmarking, with special handling for Kimi models.
Kimi uses a custom TikTokenTokenizer that transformers 5.x can't load via AutoTokenizer.
This function replicates the logic from utils_mlx.py for bench compatibility.
"""
model_id_lower = model_id.lower()
if "kimi-k2" in model_id_lower:
import importlib.util
import types
from huggingface_hub import snapshot_download
# Download/get the model path
model_path = Path(
snapshot_download(
model_id,
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model"],
)
)
sys.path.insert(0, str(model_path))
# Load tool_declaration_ts first (tokenization_kimi imports it with relative import)
tool_decl_path = model_path / "tool_declaration_ts.py"
if tool_decl_path.exists():
spec = importlib.util.spec_from_file_location(
"tool_declaration_ts", tool_decl_path
)
if spec and spec.loader:
tool_decl_module = importlib.util.module_from_spec(spec)
sys.modules["tool_declaration_ts"] = tool_decl_module
spec.loader.exec_module(tool_decl_module)
# Load tokenization_kimi with patched source (convert relative to absolute import)
tok_path = model_path / "tokenization_kimi.py"
source = tok_path.read_text()
source = source.replace("from .tool_declaration_ts", "from tool_declaration_ts")
spec = importlib.util.spec_from_file_location("tokenization_kimi", tok_path)
if spec:
tok_module = types.ModuleType("tokenization_kimi")
tok_module.__file__ = str(tok_path)
sys.modules["tokenization_kimi"] = tok_module
exec(compile(source, tok_path, "exec"), tok_module.__dict__) # noqa: S102
TikTokenTokenizer = tok_module.TikTokenTokenizer # noqa: N806
else:
from tokenization_kimi import TikTokenTokenizer # type: ignore[import-not-found] # noqa: I001
hf_tokenizer: Any = TikTokenTokenizer.from_pretrained(model_path)
# Patch encode to use internal tiktoken model directly
# transformers 5.x has a bug in the encode->pad path for slow tokenizers
def _patched_encode(text: str, **kwargs: object) -> list[int]:
# Pass allowed_special="all" to handle special tokens like <|im_user|>
return list(hf_tokenizer.model.encode(text, allowed_special="all"))
hf_tokenizer.encode = _patched_encode
return hf_tokenizer
# Default: use AutoTokenizer
return AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
def run_one_completion(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
stream: bool = False,
) -> tuple[dict[str, Any], int]:
"""Backwards-compatible shim returning a plain ``dict`` row."""
row, pp_tokens = _lib_run_one_completion(
client,
model_id,
pp_hint,
tg,
prompt_sizer,
use_prefix_cache=use_prefix_cache,
stream=stream,
)
return dict(row), pp_tokens
def format_peak_memory(b: float) -> str:
@@ -229,105 +186,6 @@ def parse_int_list(values: list[str]) -> list[int]:
return items
def run_one_completion(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
) -> tuple[dict[str, Any], int]:
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
"model": model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": use_prefix_cache,
}
t0 = time.perf_counter()
out = client.post_bench_chat_completions(payload)
elapsed = time.perf_counter() - t0
stats = out.get("generation_stats")
# Extract preview, handling None content (common for thinking models)
choices = out.get("choices") or [{}]
message = choices[0].get("message", {}) if choices else {}
content = message.get("content") or ""
preview = content[:200] if content else ""
return {
"elapsed_s": elapsed,
"output_text_preview": preview,
"stats": stats,
}, pp_tokens
class PromptSizer:
def __init__(self, tokenizer: Any, atom: str = "a "):
self.tokenizer = tokenizer
self.atom = atom
self.count_fn = PromptSizer._make_counter(tokenizer)
self.base_tokens = self.count_fn("")
@staticmethod
def _make_counter(tokenizer: Any) -> Callable[[str], int]:
def count_fn(user_content: str) -> int:
messages = [{"role": "user", "content": user_content}]
ids = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
# Fix for transformers 5.x
if hasattr(ids, "input_ids"):
ids = ids.input_ids
return int(len(ids))
return count_fn
def build(self, target_prompt_tokens: int) -> tuple[str, int]:
target = int(target_prompt_tokens)
if target < self.base_tokens:
raise RuntimeError(
f"Target ({target}) is smaller than template overhead ({self.base_tokens})."
)
# Estimate tokens per atom using a sample
sample_count = 100
sample_content = self.atom * sample_count
sample_tokens = self.count_fn(sample_content) - self.base_tokens
tokens_per_atom = sample_tokens / sample_count
# Estimate starting point
needed_tokens = target - self.base_tokens
estimated_atoms = int(needed_tokens / tokens_per_atom)
# Binary search to find exact atom count
low, high = 0, estimated_atoms * 2 + 100
while low < high:
mid = (low + high) // 2
tok = self.count_fn(self.atom * mid)
if tok < target:
low = mid + 1
else:
high = mid
content = self.atom * low
tok = self.count_fn(content)
logger.info(f"{tok=}")
if tok != target:
raise RuntimeError(
f"Overshot: got {tok} tokens (target {target}). "
f"Pick a different atom (try ' a' or '\\n' or '0 ')."
)
return content, tok
def main() -> int:
ap = argparse.ArgumentParser(
prog="exo-bench",
@@ -375,6 +233,11 @@ def main() -> int:
action="store_true",
help="Force all pp×tg combinations (cartesian product) even when lists have equal length.",
)
ap.add_argument(
"--stream",
action="store_true",
help="Use /bench/chat/completions with streaming SSE response (bench=True still applies: no EOS detection, no KV cache).",
)
ap.add_argument(
"--no-system-metrics",
action="store_true",
@@ -440,81 +303,124 @@ def main() -> int:
logger.error("[exo-bench] tokenizer usable but prompt sizing failed")
raise
selected = settle_and_fetch_placements(
client, full_model_id, args, settle_timeout=args.settle_timeout
)
# Optionally reuse a running instance for this model
reused_instance_id: str | None = None
if args.reuse_instance:
existing = find_existing_instance(client, full_model_id)
if existing:
reused_instance_id = existing
logger.info(f"Reusing existing instance {reused_instance_id}")
else:
logger.warning(
"--reuse-instance: no existing instance found, creating a new one"
)
if not selected:
logger.error("No valid placements matched your filters.")
return 1
selected.sort(
key=lambda p: (
str(p.get("instance_meta", "")),
str(p.get("sharding", "")),
-nodes_used_in_instance(p["instance"]),
),
reverse=True,
)
logger.debug(f"exo-bench model: short_id={short_id} full_id={full_model_id}")
logger.info(f"placements: {len(selected)}")
for p in selected:
logger.info(
f" - {p['sharding']} / {p['instance_meta']} / nodes={nodes_used_in_instance(p['instance'])}"
if reused_instance_id is not None:
# Use the existing instance directly — skip placement iteration
selected = []
download_duration_s = None
else:
selected = settle_and_fetch_placements(
client, full_model_id, args, settle_timeout=args.settle_timeout
)
if args.dry_run:
return 0
if not selected:
logger.error("No valid placements matched your filters.")
return 1
settle_deadline = (
time.monotonic() + args.settle_timeout if args.settle_timeout > 0 else None
)
selected.sort(
key=lambda p: (
str(p.get("instance_meta", "")),
str(p.get("sharding", "")),
nodes_used_in_instance(p["instance"]),
),
reverse=True,
)
logger.info("Planning phase: checking downloads...")
download_duration_s = run_planning_phase(
client,
full_model_id,
selected[0],
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
if download_duration_s is not None:
logger.info(f"Download: {download_duration_s:.1f}s (freshly downloaded)")
else:
logger.info("Download: model already cached")
logger.debug(f"exo-bench model: short_id={short_id} full_id={full_model_id}")
logger.info(f"placements: {len(selected)}")
for p in selected:
logger.info(
f" - {p['sharding']} / {p['instance_meta']} / nodes={nodes_used_in_instance(p['instance'])}"
)
if args.dry_run:
return 0
settle_deadline = (
time.monotonic() + args.settle_timeout if args.settle_timeout > 0 else None
)
logger.info("Planning phase: checking downloads...")
download_duration_s = run_planning_phase(
client,
full_model_id,
selected[0],
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
if download_duration_s is not None:
logger.info(f"Download: {download_duration_s:.1f}s (freshly downloaded)")
else:
logger.info("Download: model already cached")
cluster_snapshot = capture_cluster_snapshot(client)
all_rows: list[dict[str, Any]] = []
all_system_metrics: dict[str, dict[str, dict[str, float]]] = {}
# If reusing an existing instance, run a single benchmark pass against it
if reused_instance_id is not None:
selected = [None]
for preview in selected:
instance = preview["instance"]
instance_id = instance_id_from_instance(instance)
created_instance = False
if preview is not None:
instance = preview["instance"]
instance_id = instance_id_from_instance(instance)
sharding = str(preview["sharding"])
instance_meta = str(preview["instance_meta"])
n_nodes = nodes_used_in_instance(instance)
sharding = str(preview["sharding"])
instance_meta = str(preview["instance_meta"])
n_nodes = nodes_used_in_instance(instance)
logger.info("=" * 80)
logger.info(
f"PLACEMENT: {sharding} / {instance_meta} / nodes={n_nodes} / instance_id={instance_id}"
)
logger.info("=" * 80)
logger.info(
f"PLACEMENT: {sharding} / {instance_meta} / nodes={n_nodes} / instance_id={instance_id}"
)
client.request_json("POST", "/instance", body={"instance": instance})
try:
wait_for_instance_ready(client, instance_id)
except (RuntimeError, TimeoutError) as e:
logger.error(f"Failed to initialize placement: {e}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
continue
# Delete any existing instances to free resources before placing
try:
state = client.request_json("GET", "/state")
for old_id in list(state.get("instances", {}).keys()):
logger.info(f"Deleting stale instance {old_id}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{old_id}")
if state.get("instances"):
time.sleep(2)
except Exception as e:
logger.warning(f"Failed to clean up stale instances: {e}")
time.sleep(1)
client.request_json("POST", "/instance", body={"instance": instance})
try:
wait_for_instance_ready(client, instance_id)
except (RuntimeError, TimeoutError) as e:
logger.error(f"Failed to initialize placement: {e}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
continue
time.sleep(1)
created_instance = True
else:
instance_id = reused_instance_id
sharding = "reused"
instance_meta = "reused"
n_nodes = 0
logger.info("=" * 80)
logger.info(f"Using existing instance {instance_id}")
sampler: SystemMetricsSampler | None = None
if not args.no_system_metrics:
if not args.no_system_metrics and preview is not None:
nids = node_ids_from_instance(instance)
sampler = SystemMetricsSampler(
ExoClient(args.host, args.port, timeout_s=30),
@@ -523,16 +429,20 @@ def main() -> int:
)
sampler.start()
def _do_one(c: ExoClient, pp: int, tg: int) -> tuple[dict[str, Any], int]:
return run_one_completion(
c,
full_model_id,
pp,
tg,
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
stream=args.stream,
)
try:
for i in range(args.warmup):
run_one_completion(
client,
full_model_id,
pp_list[0],
tg_list[0],
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
_do_one(client, pp_list[0], tg_list[0])
logger.debug(f" warmup {i + 1}/{args.warmup} done")
# If pp and tg lists have same length, run in tandem (zip)
@@ -554,14 +464,7 @@ def main() -> int:
# Sequential: single request
try:
inf_t0 = time.monotonic()
row, actual_pp_tokens = run_one_completion(
client,
full_model_id,
pp,
tg,
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
row, actual_pp_tokens = _do_one(client, pp, tg)
inference_windows.append((inf_t0, time.monotonic()))
except Exception as e:
logger.error(e)
@@ -710,10 +613,12 @@ def main() -> int:
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(
x["stats"]["peak_memory_usage"]["inBytes"] for x in runs
)
def _peak_bytes(s: dict[str, Any]) -> float:
pm = s["peak_memory_usage"]
return pm.get("inBytes") or pm.get("in_bytes", 0)
peak = mean(_peak_bytes(x["stats"]) for x in runs)
summary = (
f"prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
f"prompt_tokens={ptok} gen_tokens={gtok} "
@@ -738,15 +643,16 @@ def main() -> int:
if placement_metrics:
all_system_metrics.update(placement_metrics)
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
wait_for_instance_gone(client, instance_id)
logger.debug(f"Deleted instance {instance_id}")
if created_instance and instance_id is not None:
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
wait_for_instance_gone(client, instance_id)
logger.debug(f"Deleted instance {instance_id}")
time.sleep(5)
time.sleep(5)
output: dict[str, Any] = {"runs": all_rows}
if cluster_snapshot:
+429 -59
View File
@@ -42,11 +42,11 @@ from pathlib import Path
from typing import Any
import httpx
from harness import (
ExoClient,
ExoHttpError,
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -62,6 +62,15 @@ from loguru import logger
# ---------------------------------------------------------------------------
MAX_RETRIES = 30
INSTANCE_HEALTH_CHECK_AFTER = (
3 # Check instance health after this many consecutive failures
)
class InstanceFailedError(RuntimeError):
"""Raised when the exo instance is detected as failed/gone."""
DEFAULT_MAX_TOKENS = 16_384
REASONING_MAX_TOKENS = 131_072
TEMPERATURE_NON_REASONING = 0.0
@@ -271,7 +280,7 @@ def run_humaneval_test(
@dataclass
class QuestionResult:
question_id: int
question_id: int | str
prompt: str
response: str
extracted_answer: str | None
@@ -281,7 +290,11 @@ class QuestionResult:
prompt_tokens: int = 0
completion_tokens: int = 0
reasoning_tokens: int = 0
reasoning_content: str = ""
finish_reason: str = ""
elapsed_s: float = 0.0
power_watts: float = 0.0
energy_joules: float = 0.0
@dataclass
@@ -517,6 +530,10 @@ class ApiResult:
prompt_tokens: int
completion_tokens: int
reasoning_tokens: int
reasoning_content: str = ""
finish_reason: str = ""
power_watts: float = 0.0
energy_joules: float = 0.0
async def _call_api(
@@ -530,6 +547,9 @@ async def _call_api(
system_message: str | None = None,
reasoning_effort: str | None = None,
top_p: float | None = None,
top_k: int | None = None,
min_p: float | None = None,
enable_thinking: bool | None = None,
) -> ApiResult:
messages = []
if system_message:
@@ -546,6 +566,12 @@ async def _call_api(
body["reasoning_effort"] = reasoning_effort
if top_p is not None:
body["top_p"] = top_p
if top_k is not None:
body["top_k"] = top_k
if min_p is not None:
body["min_p"] = min_p
if enable_thinking is not None:
body["enable_thinking"] = enable_thinking
resp = await client.post(
f"{base_url}/v1/chat/completions",
@@ -554,19 +580,40 @@ async def _call_api(
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
if not content or not content.strip():
choice = data["choices"][0]
message = choice["message"]
content = message.get("content") or ""
reasoning_content = message.get("reasoning_content") or ""
finish_reason = choice.get("finish_reason") or ""
# For thinking models, empty content is expected when finish_reason is "length"
if not content.strip() and finish_reason != "length" and not reasoning_content:
raise ValueError("Empty response from model")
usage = data.get("usage", {})
details = usage.get("completion_tokens_details", {})
power = data.get("power_usage") or {}
return ApiResult(
content=content,
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
reasoning_tokens=details.get("reasoning_tokens", 0) if details else 0,
reasoning_content=reasoning_content,
finish_reason=finish_reason,
power_watts=power.get("total_avg_sys_power_watts", 0.0),
energy_joules=power.get("total_energy_joules", 0.0),
)
async def _check_instance_health(base_url: str) -> bool:
"""Return True if the exo instance is still reachable."""
try:
async with httpx.AsyncClient() as c:
resp = await c.get(f"{base_url}/models", timeout=5.0)
return resp.status_code == 200
except Exception:
return False
async def call_with_retries(
client: httpx.AsyncClient,
base_url: str,
@@ -578,8 +625,14 @@ async def call_with_retries(
system_message: str | None = None,
reasoning_effort: str | None = None,
top_p: float | None = None,
top_k: int | None = None,
min_p: float | None = None,
enable_thinking: bool | None = None,
instance_failed: asyncio.Event | None = None,
) -> ApiResult | None:
for attempt in range(MAX_RETRIES):
if instance_failed and instance_failed.is_set():
raise InstanceFailedError("Instance already marked as failed")
try:
return await _call_api(
client,
@@ -592,8 +645,30 @@ async def call_with_retries(
system_message,
reasoning_effort,
top_p,
top_k,
min_p,
enable_thinking,
)
except Exception as e:
is_conn_error = isinstance(
e,
(
httpx.ConnectError,
httpx.RemoteProtocolError,
ConnectionRefusedError,
OSError,
),
)
if (
is_conn_error
and attempt >= INSTANCE_HEALTH_CHECK_AFTER
and not await _check_instance_health(base_url)
):
if instance_failed:
instance_failed.set()
raise InstanceFailedError(
f"Instance is down after {attempt + 1} failures: {e}"
) from e
if attempt < MAX_RETRIES - 1:
wait = min(2**attempt, 60)
logger.warning(
@@ -618,10 +693,16 @@ async def evaluate_benchmark(
max_tokens: int,
concurrency: int = 1,
limit: int | None = None,
offset: int = 0,
timeout: float | None = None,
reasoning_effort: str | None = None,
top_p: float | None = None,
top_k: int | None = None,
min_p: float | None = None,
enable_thinking: bool | None = None,
difficulty: str | None = None,
checkpoint_path: Path | None = None,
release_version: str | None = None,
) -> list[QuestionResult]:
"""Run a benchmark. Returns per-question results."""
import datasets
@@ -652,7 +733,21 @@ async def evaluate_benchmark(
ds = ds.filter(lambda x: x["difficulty"] == difficulty)
logger.info(f"Filtered to {len(ds)} {difficulty} problems")
if release_version and "release_version" in ds.column_names:
ds = ds.filter(lambda x: x["release_version"] == release_version)
logger.info(
f"Filtered to {len(ds)} problems with release_version={release_version}"
)
# Sort by question_id to match LCB runner ordering (scenario_router.py:60).
# This ensures [offset:offset+limit] slices select the same problems as vllm.
if "question_id" in ds.column_names:
ds = ds.sort("question_id")
total = len(ds)
if offset > 0:
ds = ds.select(range(min(offset, total), total))
total = len(ds)
if limit and limit < total:
ds = ds.select(range(limit))
total = limit
@@ -660,6 +755,13 @@ async def evaluate_benchmark(
logger.info(
f"Evaluating {benchmark_name}: {total} questions, concurrency={concurrency}, "
f"temperature={temperature}, max_tokens={max_tokens}"
+ (f", top_k={top_k}" if top_k is not None else "")
+ (f", min_p={min_p}" if min_p is not None else "")
+ (
f", enable_thinking={enable_thinking}"
if enable_thinking is not None
else ""
)
)
if config.kind == "code":
@@ -667,16 +769,64 @@ async def evaluate_benchmark(
"Code benchmarks execute model-generated code. Use a sandboxed environment."
)
# Load checkpoint for resume
checkpoint_data: dict[str | int, dict[str, Any]] = {}
if checkpoint_path and checkpoint_path.exists():
with open(checkpoint_path) as f:
for line in f:
entry = json.loads(line)
checkpoint_data[entry["question_id"]] = entry
logger.info(f"Loaded {len(checkpoint_data)} checkpointed results")
semaphore = asyncio.Semaphore(concurrency)
instance_failed = asyncio.Event()
results: list[QuestionResult | None] = [None] * total
completed = 0
lock = asyncio.Lock()
def _get_question_id(idx: int, doc: dict) -> str | int:
"""Get a stable question ID for checkpointing."""
if benchmark_name == "livecodebench":
return doc.get("question_id", idx)
elif benchmark_name == "humaneval":
return doc.get("task_id", idx)
return idx
async def process_question(
idx: int, doc: dict, http_client: httpx.AsyncClient
) -> None:
nonlocal completed
system_msg = None
question_id = _get_question_id(idx, doc)
# Bail out early if instance is already dead
if instance_failed.is_set():
return
# Check checkpoint
if question_id in checkpoint_data:
cached = checkpoint_data[question_id]
results[idx] = QuestionResult(
question_id=question_id,
prompt=cached.get("prompt", ""),
response=cached.get("response", ""),
extracted_answer=cached.get("extracted_answer"),
gold_answer=cached.get("gold_answer", ""),
correct=cached.get("correct", False),
error=cached.get("error"),
prompt_tokens=cached.get("prompt_tokens", 0),
completion_tokens=cached.get("completion_tokens", 0),
reasoning_tokens=cached.get("reasoning_tokens", 0),
reasoning_content=cached.get("reasoning_content", ""),
finish_reason=cached.get("finish_reason", ""),
elapsed_s=cached.get("elapsed_s", 0.0),
power_watts=cached.get("power_watts", 0.0),
energy_joules=cached.get("energy_joules", 0.0),
)
async with lock:
completed += 1
logger.info(f" [{completed}/{total}] {question_id} (cached)")
return
if benchmark_name == "gpqa_diamond":
prompt, gold = format_gpqa_question(doc, idx)
@@ -697,24 +847,50 @@ async def evaluate_benchmark(
raise ValueError(f"Unknown benchmark: {benchmark_name}")
async with semaphore:
if instance_failed.is_set():
return
t0 = time.monotonic()
api_result = await call_with_retries(
http_client,
base_url,
model,
prompt,
temperature,
max_tokens,
timeout,
system_message=system_msg,
reasoning_effort=reasoning_effort,
top_p=top_p,
)
try:
# Race the API call against the instance_failed event
api_task = asyncio.create_task(
call_with_retries(
http_client,
base_url,
model,
prompt,
temperature,
max_tokens,
timeout,
system_message=system_msg,
reasoning_effort=reasoning_effort,
top_p=top_p,
top_k=top_k,
min_p=min_p,
enable_thinking=enable_thinking,
instance_failed=instance_failed,
)
)
failed_waiter = asyncio.create_task(instance_failed.wait())
done, pending = await asyncio.wait(
[api_task, failed_waiter],
return_when=asyncio.FIRST_COMPLETED,
)
for p in pending:
p.cancel()
with contextlib.suppress(asyncio.CancelledError):
await p
if instance_failed.is_set() and api_task not in done:
logger.error(f"Instance failed, aborting {question_id}")
return
api_result = api_task.result()
except InstanceFailedError:
logger.error(f"Instance failed, skipping {question_id}")
return
elapsed = time.monotonic() - t0
if api_result is None:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response="",
extracted_answer=None,
@@ -729,13 +905,17 @@ async def evaluate_benchmark(
"prompt_tokens": api_result.prompt_tokens,
"completion_tokens": api_result.completion_tokens,
"reasoning_tokens": api_result.reasoning_tokens,
"reasoning_content": api_result.reasoning_content,
"finish_reason": api_result.finish_reason,
"elapsed_s": elapsed,
"power_watts": api_result.power_watts,
"energy_joules": api_result.energy_joules,
}
if config.kind == "mc":
extracted = extract_mc_answer(response, valid_letters)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=extracted,
@@ -749,7 +929,7 @@ async def evaluate_benchmark(
check_aime_answer(extracted, int(gold)) if extracted else False
)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=extracted,
@@ -763,7 +943,7 @@ async def evaluate_benchmark(
code = extract_code_block(response, preserve_indent=keep_indent)
if code is None:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -778,7 +958,7 @@ async def evaluate_benchmark(
code,
)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer="pass" if passed else "fail",
@@ -793,7 +973,7 @@ async def evaluate_benchmark(
exec_meta["sample"],
)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer="pass" if passed else "fail",
@@ -804,7 +984,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -815,7 +995,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -827,24 +1007,82 @@ async def evaluate_benchmark(
results[idx] = result
# Write checkpoint (skip infra failures so they get retried on resume,
# but keep wrong answers — they are legitimate results)
if checkpoint_path is not None and result.response:
_write_checkpoint(checkpoint_path, result)
async with lock:
completed += 1
n = completed
if n % max(1, total // 20) == 0 or n == total:
correct_so_far = sum(1 for r in results if r is not None and r.correct)
answered = sum(1 for r in results if r is not None)
logger.info(
f" [{n}/{total}] {correct_so_far}/{answered} correct "
f"({correct_so_far / max(answered, 1):.1%})"
)
# Log progress
thinking_info = ""
if result.reasoning_content:
thinking_info = f", {len(result.reasoning_content)} chars thinking"
logger.info(
f" [{n}/{total}] {question_id}: {len(result.response)} chars{thinking_info}, "
f"tokens: {result.prompt_tokens}+{result.completion_tokens} "
f"[{result.finish_reason}]"
+ (f" {result.extracted_answer}" if result.extracted_answer else "")
)
async def _health_monitor() -> None:
"""Periodically check if the instance is still alive."""
# Wait a bit before first check to let things start
await asyncio.sleep(10)
while not instance_failed.is_set():
if not await _check_instance_health(base_url):
# Double-check to avoid false positives
await asyncio.sleep(2)
if not await _check_instance_health(base_url):
logger.error("Health monitor: instance is down!")
instance_failed.set()
return
await asyncio.sleep(5)
async with httpx.AsyncClient() as http_client:
monitor = asyncio.create_task(_health_monitor())
tasks = [process_question(i, doc, http_client) for i, doc in enumerate(ds)]
await asyncio.gather(*tasks)
monitor.cancel()
with contextlib.suppress(asyncio.CancelledError):
await monitor
if instance_failed.is_set():
completed_count = sum(1 for r in results if r is not None)
logger.error(
f"Instance failed! Completed {completed_count}/{total} problems. "
f"Checkpoint saved — restart to resume remaining problems."
)
raise InstanceFailedError("Instance failed during evaluation")
return [r for r in results if r is not None]
def _write_checkpoint(path: Path, result: QuestionResult) -> None:
"""Append a single result to the JSONL checkpoint file."""
entry = {
"question_id": result.question_id,
"prompt": result.prompt,
"response": result.response,
"extracted_answer": result.extracted_answer,
"gold_answer": result.gold_answer,
"correct": result.correct,
"error": result.error,
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
"reasoning_tokens": result.reasoning_tokens,
"reasoning_content": result.reasoning_content,
"finish_reason": result.finish_reason,
"elapsed_s": round(result.elapsed_s, 2),
"power_watts": round(result.power_watts, 2),
"energy_joules": round(result.energy_joules, 2),
}
with open(path, "a") as f:
f.write(json.dumps(entry) + "\n")
# ---------------------------------------------------------------------------
# Results display
# ---------------------------------------------------------------------------
@@ -867,6 +1105,8 @@ def print_results(
total_elapsed = sum(r.elapsed_s for r in results)
wall_clock = max(r.elapsed_s for r in results) if results else 0.0
avg_gen_tps = total_completion_tokens / total_elapsed if total_elapsed > 0 else 0.0
total_energy = sum(r.energy_joules for r in results)
avg_power = sum(r.power_watts for r in results) / max(total, 1)
label = f"[c={concurrency}] " if concurrency is not None else ""
print(f"\n{label}{benchmark_name}: {correct}/{total} ({accuracy:.1%})")
@@ -878,6 +1118,10 @@ def print_results(
f" | total time: {total_elapsed:.1f}s wall clock: {wall_clock:.1f}s"
)
print(tok_line)
if total_energy > 0:
print(
f" power: avg {avg_power:.1f}W | total energy: {total_energy:.1f}J ({total_energy / 3600:.2f}Wh)"
)
if errors:
print(f" API errors: {errors}")
if no_extract:
@@ -896,6 +1140,8 @@ def print_results(
"total_elapsed_s": total_elapsed,
"wall_clock_s": wall_clock,
"avg_gen_tps": avg_gen_tps,
"avg_power_watts": avg_power,
"total_energy_joules": total_energy,
}
@@ -1053,7 +1299,11 @@ def save_results(
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"reasoning_tokens": r.reasoning_tokens,
"reasoning_content": r.reasoning_content,
"finish_reason": r.finish_reason,
"elapsed_s": round(r.elapsed_s, 2),
"power_watts": round(r.power_watts, 2),
"energy_joules": round(r.energy_joules, 2),
}
for r in results
],
@@ -1069,6 +1319,15 @@ def save_results(
# ---------------------------------------------------------------------------
def _checkpoint_path(
results_dir: str, benchmark: str, model: str, concurrency: int
) -> Path:
"""Return the JSONL checkpoint path for a benchmark run."""
out_dir = Path(results_dir) / model.replace("/", "_") / benchmark
out_dir.mkdir(parents=True, exist_ok=True)
return out_dir / f"c{concurrency}.checkpoint.jsonl"
def parse_int_list(values: list[str]) -> list[int]:
items: list[int] = []
for v in values:
@@ -1096,6 +1355,12 @@ def main() -> int:
default=None,
help="Max questions per benchmark (for fast iteration).",
)
ap.add_argument(
"--offset",
type=int,
default=0,
help="Skip first N questions (0-based).",
)
reasoning_group = ap.add_mutually_exclusive_group()
reasoning_group.add_argument(
@@ -1115,6 +1380,8 @@ def main() -> int:
"--temperature", type=float, default=None, help="Override temperature."
)
ap.add_argument("--top-p", type=float, default=None, help="Override top_p.")
ap.add_argument("--top-k", type=int, default=None, help="Override top_k.")
ap.add_argument("--min-p", type=float, default=None, help="Override min_p.")
ap.add_argument(
"--max-tokens", type=int, default=None, help="Override max output tokens."
)
@@ -1148,15 +1415,31 @@ def main() -> int:
choices=["easy", "medium", "hard"],
help="Filter by difficulty (livecodebench only). E.g. --difficulty hard",
)
ap.add_argument(
"--release-version",
default=None,
help="LCB dataset release version (livecodebench only). E.g. release_v5",
)
ap.add_argument(
"--results-dir",
default="eval_results",
help="Directory for result JSON files (default: eval_results).",
)
ap.add_argument(
"--skip-instance-setup",
"--enable-thinking",
type=lambda v: v.lower() in ("true", "1", "yes"),
default=None,
help="Enable thinking mode for models that support it.",
)
ap.add_argument(
"--force",
action="store_true",
help="Skip exo instance management (assumes model is already running).",
help="Discard any existing checkpoint and run from scratch.",
)
ap.add_argument(
"--keep-instance",
action="store_true",
help="Skip deleting the instance after eval (for chaining runs).",
)
args, _ = ap.parse_known_args()
@@ -1177,13 +1460,26 @@ def main() -> int:
# Instance management
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
instance_id: str | None = None
created_instance = False
if not args.skip_instance_setup:
short_id, full_model_id = resolve_model_short_id(
client,
args.model,
force_download=args.force_download,
)
_short_id, full_model_id = resolve_model_short_id(
client,
args.model,
force_download=args.force_download,
)
# Optionally reuse a running instance for this model
if args.reuse_instance:
existing = find_existing_instance(client, full_model_id)
if existing:
instance_id = existing
logger.info(f"Reusing existing instance {instance_id}")
else:
logger.warning(
"--reuse-instance: no existing instance found, creating a new one"
)
if instance_id is None:
selected = settle_and_fetch_placements(
client,
full_model_id,
@@ -1198,7 +1494,7 @@ def main() -> int:
key=lambda p: (
str(p.get("instance_meta", "")),
str(p.get("sharding", "")),
-nodes_used_in_instance(p["instance"]),
nodes_used_in_instance(p["instance"]),
),
reverse=True,
)
@@ -1225,6 +1521,18 @@ def main() -> int:
if download_duration is not None:
logger.info(f"Download: {download_duration:.1f}s")
# Delete any existing instances to free resources before placing
try:
state = client.request_json("GET", "/state")
for old_id in list(state.get("instances", {}).keys()):
logger.info(f"Deleting stale instance {old_id}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{old_id}")
if state.get("instances"):
time.sleep(2)
except Exception as e:
logger.warning(f"Failed to clean up stale instances: {e}")
client.request_json("POST", "/instance", body={"instance": instance})
try:
wait_for_instance_ready(client, instance_id)
@@ -1234,10 +1542,9 @@ def main() -> int:
client.request_json("DELETE", f"/instance/{instance_id}")
return 1
time.sleep(1)
cluster_snapshot = capture_cluster_snapshot(client)
else:
full_model_id = args.model
cluster_snapshot = None
created_instance = True
cluster_snapshot = capture_cluster_snapshot(client)
# Auto-detect reasoning from model config
model_config = load_model_config(full_model_id)
@@ -1291,16 +1598,57 @@ def main() -> int:
reasoning_effort = str(cfg["reasoning_effort"])
else:
reasoning_effort = "high" if is_reasoning else None
if args.top_k is not None:
top_k: int | None = args.top_k
elif "top_k" in cfg:
top_k = int(cfg["top_k"])
else:
top_k = None
if args.min_p is not None:
min_p: float | None = args.min_p
elif "min_p" in cfg:
min_p = float(cfg["min_p"])
else:
min_p = None
if args.enable_thinking is not None:
enable_thinking: bool | None = args.enable_thinking
elif "enable_thinking" in cfg:
enable_thinking = bool(cfg["enable_thinking"])
else:
enable_thinking = None
base_url = f"http://{args.host}:{args.port}"
logger.info(f"Model: {full_model_id}")
logger.info(
f"Settings: temperature={temperature}, max_tokens={max_tokens}, "
+ (f"top_p={top_p}, " if top_p is not None else "")
+ (f"top_k={top_k}, " if top_k is not None else "")
+ (f"min_p={min_p}, " if min_p is not None else "")
+ f"reasoning={'yes' if is_reasoning else 'no'}"
+ (f", reasoning_effort={reasoning_effort}" if reasoning_effort else "")
+ (
f", enable_thinking={enable_thinking}"
if enable_thinking is not None
else ""
)
)
# Common kwargs for evaluate_benchmark
eval_kwargs: dict[str, Any] = {
"reasoning_effort": reasoning_effort,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"enable_thinking": enable_thinking,
"difficulty": args.difficulty,
"offset": args.offset,
"release_version": args.release_version,
}
try:
if args.compare_concurrency:
concurrency_levels = parse_int_list(args.compare_concurrency)
@@ -1309,6 +1657,11 @@ def main() -> int:
for c in concurrency_levels:
logger.info(f"\n{'=' * 50}")
logger.info(f"Running {task_name} at concurrency={c}")
checkpoint_path = _checkpoint_path(
args.results_dir, task_name, full_model_id, c
)
if args.force and checkpoint_path.exists():
checkpoint_path.unlink()
results = asyncio.run(
evaluate_benchmark(
task_name,
@@ -1319,9 +1672,8 @@ def main() -> int:
concurrency=c,
limit=args.limit,
timeout=args.request_timeout,
reasoning_effort=reasoning_effort,
top_p=top_p,
difficulty=args.difficulty,
checkpoint_path=checkpoint_path,
**eval_kwargs,
)
)
if results:
@@ -1336,10 +1688,18 @@ def main() -> int:
cluster=cluster_snapshot,
)
results_by_c[c] = results
# Clean up checkpoint on success
if checkpoint_path.exists():
checkpoint_path.unlink()
if len(results_by_c) >= 2:
print_comparison(task_name, results_by_c)
else:
for task_name in task_names:
checkpoint_path = _checkpoint_path(
args.results_dir, task_name, full_model_id, args.num_concurrent
)
if args.force and checkpoint_path.exists():
checkpoint_path.unlink()
results = asyncio.run(
evaluate_benchmark(
task_name,
@@ -1350,9 +1710,8 @@ def main() -> int:
concurrency=args.num_concurrent,
limit=args.limit,
timeout=args.request_timeout,
reasoning_effort=reasoning_effort,
top_p=top_p,
difficulty=args.difficulty,
checkpoint_path=checkpoint_path,
**eval_kwargs,
)
)
if results:
@@ -1366,14 +1725,25 @@ def main() -> int:
scores,
cluster=cluster_snapshot,
)
# Clean up checkpoint on success
if checkpoint_path.exists():
checkpoint_path.unlink()
finally:
if instance_id is not None:
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
wait_for_instance_gone(client, instance_id)
if created_instance and instance_id is not None:
if args.keep_instance:
logger.info(f"Keeping instance {instance_id} (--keep-instance)")
else:
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
try:
wait_for_instance_gone(client, instance_id)
except TimeoutError:
logger.warning(
f"Timed out waiting for instance {instance_id} to be deleted"
)
return 0
+18
View File
@@ -0,0 +1,18 @@
"""Composable bench library for exo.
Provides reusable building blocks for benchmarks:
- :class:`bench.lib.session.BenchSession` cluster + instance + client wrapper
- :class:`bench.lib.results.ResultsBundle` structured results + JSON writer
- :func:`bench.lib.cluster.managed_cluster` /
:func:`bench.lib.cluster.managed_instance` eco-managed lifecycle ctx-managers
- :func:`bench.lib.model_meta.fetch_model_meta` HF metadata fetcher driving
cluster constraints + auto-derived context ramps
- :mod:`bench.lib.context_scaling` prompt-TPS / decode-TPS vs context-size sweep
CLI entrypoints under ``bench/cli/`` consume this library via
``python -m bench.cli <subcommand>``. Adding a new benchmark = (i) write
``bench/lib/<name>.py`` exposing a typed ``run(session, params, bundle)``
callable, (ii) write ``bench/cli/<name>.py`` with an ``add_subparser`` and
a handler, (iii) register it in ``_REGISTRY`` in ``bench/cli/__main__.py``.
"""
+215
View File
@@ -0,0 +1,215 @@
"""Eco-managed cluster + instance lifecycle helpers for the bench CLI.
Two context managers:
- :func:`managed_cluster` deploys exo on the requested hosts (or via
constraint-based reservation) and tears it down on exit.
- :func:`managed_instance` resolves the model on the cluster, optionally
frees disk via ``--danger-delete-downloads`` (default on for benches),
places the instance, and deletes it on exit.
The library never reaches for global state every call takes an
explicit :class:`EcoSession`. Callers are expected to instantiate one
session per CLI invocation and use it across both context managers.
"""
from __future__ import annotations
import contextlib
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any, cast
from exo_tools.client import ExoClient
from exo_tools.cluster import Chip, ClusterInfo, EcoSession, Thunderbolt
from exo_tools.harness import (
Comm,
Sharding,
cleanup_all_instances,
place_instance,
resolve_model_short_id,
run_planning_phase,
)
from loguru import logger
from .session import BenchSession
@contextmanager
def managed_cluster(
eco: EcoSession,
*,
hosts: list[str] | None = None,
count: int = 1,
thunderbolt: Thunderbolt | None = None,
chip: Chip | None = None,
min_memory_gb: float | None = None,
max_memory_gb: float | None = None,
min_disk_gb: float | None = None,
max_disk_gb: float | None = None,
deploy_timeout_s: int = 600,
) -> Iterator[ClusterInfo]:
"""Deploy exo for the duration of the ``with`` block, then ``eco stop``.
If ``hosts`` is given, deploys on exactly those hosts (constraint flags
are ignored eco doesn't re-validate the explicit list). Otherwise eco
reserves any matching hosts that satisfy all of:
- ``count`` (number of hosts)
- ``thunderbolt`` topology (``A2A``, ``RING``, or ``NONE`` to
exclude TB-connected hosts)
- ``chip`` (substring match against eco's chip names)
- memory bounds (``min_memory_gb`` / ``max_memory_gb``)
- disk bounds (``min_disk_gb`` / ``max_disk_gb``)
"""
if hosts:
cluster = eco.start_deploy(
hosts=hosts[:count],
wait=True,
timeout=deploy_timeout_s,
)
else:
cluster = eco.start_deploy(
count=count,
thunderbolt=thunderbolt,
chip=chip,
min_memory_gb=min_memory_gb,
max_memory_gb=max_memory_gb,
min_disk_gb=min_disk_gb,
max_disk_gb=max_disk_gb,
wait=True,
timeout=deploy_timeout_s,
)
logger.info(
f"cluster deployed: {len(cluster.hosts)} host(s) "
f"({', '.join(cluster.hosts)}); namespace={cluster.namespace}"
)
try:
yield cluster
finally:
with contextlib.suppress(Exception):
eco.stop(cluster.hosts)
logger.info("cluster stopped")
@contextmanager
def managed_instance(
cluster: ClusterInfo,
eco: EcoSession,
model_id: str,
*,
sharding: Sharding = Sharding.PIPELINE,
comm: Comm = Comm.RING,
min_nodes: int = 1,
evict_downloads: bool = True,
cleanup_on_exit: bool = True,
instance_timeout_s: float = 7200.0,
settle_timeout_s: float = 60.0,
) -> Iterator[BenchSession]:
"""Resolve the model on the cluster, place an instance, yield a session.
Steps on entry:
1. Resolve ``model_id`` to ``(short_id, full_id)`` against the cluster's
``/models`` endpoint (auto-adds from HuggingFace if missing).
2. Run the harness's planning phase: validates each node has enough
disk for the model and starts the download (or reuses an existing
download). When ``evict_downloads=True`` (the default for benches),
this also evicts smaller existing models if disk is short.
3. Place the instance, wait for it to be ``RunnerReady``.
4. Yield a :class:`BenchSession` pointing at the cluster's primary API.
On exit: deletes the placed instance (and any other lingering
instances) so the cluster is clean for the next benchmark.
"""
client = cluster.make_client(timeout_s=instance_timeout_s)
short_id, full_id = resolve_model_short_id(client, model_id, force_download=True)
logger.info(f"resolved model: short_id={short_id} full_id={full_id}")
# The planning phase needs a concrete preview (instance + runner-to-shard
# mapping) to know which nodes to download to. Pull the placements API
# directly and take the first valid one — bench cares about disk +
# download, not the specific shard mapping.
preview = _first_valid_preview(client, full_id, settle_timeout_s)
if preview is None:
raise RuntimeError(
f"No placement available for {full_id} on cluster {cluster.hosts}"
)
duration = run_planning_phase(
client,
full_id,
preview,
danger_delete=evict_downloads,
timeout=instance_timeout_s,
settle_deadline=None,
)
if duration is not None:
logger.info(f"download: {duration:.1f}s (freshly downloaded)")
else:
logger.info("download: model already cached on all nodes")
instance_id = place_instance(
client,
model_id,
sharding=sharding,
comm=comm,
min_nodes=min_nodes,
timeout=instance_timeout_s,
)
logger.info(f"placed instance {instance_id} ({sharding.value}/{comm.value})")
sess = BenchSession(
cluster=cluster,
eco=eco,
instance_id=instance_id,
model_id=short_id,
full_model_id=full_id,
)
try:
yield sess
finally:
if cleanup_on_exit:
with contextlib.suppress(Exception):
cleanup_all_instances(sess.client)
else:
logger.info(
f"cleanup_on_exit=False: leaving instance(s) on {cluster.hosts}"
)
def _first_valid_preview(
client: ExoClient, full_model_id: str, settle_timeout_s: float
) -> dict[str, Any] | None:
"""Poll ``/instance/previews`` until at least one valid preview comes back."""
deadline = time.monotonic() + settle_timeout_s
backoff_s = 1.0
while True:
resp_obj: Any = client.request_json( # type: ignore[reportAny]
"GET", "/instance/previews", params={"model_id": full_model_id}
)
resp: dict[str, Any] = (
cast("dict[str, Any]", resp_obj) if isinstance(resp_obj, dict) else {}
)
previews_raw: object = resp.get("previews") or []
previews: list[Any] = (
cast("list[Any]", previews_raw) if isinstance(previews_raw, list) else []
)
for raw in previews: # type: ignore[reportAny]
if not isinstance(raw, dict):
continue
entry = cast("dict[str, Any]", raw)
if entry.get("error") is not None:
continue
instance = entry.get("instance")
if isinstance(instance, dict):
return entry
if time.monotonic() >= deadline:
return None
logger.info(
f"waiting for placement to appear for {full_model_id} "
f"({deadline - time.monotonic():.0f}s remaining)..."
)
time.sleep(min(backoff_s, max(0.0, deadline - time.monotonic())))
backoff_s = min(backoff_s * 2, 30.0)
+194
View File
@@ -0,0 +1,194 @@
"""Typed wrapper around ``/bench/chat/completions`` for benchmarks.
The bench endpoint disables EOS suppression and KV prefix caching by
default (see ``bench/METHODOLOGY.md``). This module exposes a single
function :func:`run_one_completion` that:
1. Builds an exact-token-length prompt via :class:`PromptSizer`.
2. POSTs to ``/bench/chat/completions``.
3. Returns a ``(BenchRow, prompt_tokens)`` pair where ``BenchRow`` is a
:class:`typing.TypedDict` with the fields the caller needs.
Streaming is supported but rarely needed for context-scaling the
non-streaming path is the default.
"""
from __future__ import annotations
import contextlib
import json
import time
from typing import Any, Literal, NotRequired, TypedDict, cast
from exo_tools.client import ExoClient
from .prompt import PromptSizer
PrefixCacheHit = Literal["none", "partial", "exact"]
class GenerationStats(TypedDict, total=False):
"""Server-reported per-task timing stats."""
prompt_tps: float
generation_tps: float
prompt_tokens: int
generation_tokens: int
peak_memory_usage: dict[str, int]
prefix_cache_hit: PrefixCacheHit
class BenchRow(TypedDict):
"""Per-request result row returned to callers."""
elapsed_s: float
output_text_preview: str
stats: GenerationStats
error: NotRequired[str]
def _as_dict(value: Any) -> dict[str, Any]: # type: ignore[reportAny]
"""Narrow an arbitrary JSON value to a typed ``dict[str, Any]``."""
if isinstance(value, dict):
return cast("dict[str, Any]", value)
return {}
def _as_list(value: Any) -> list[Any]: # type: ignore[reportAny]
if isinstance(value, list):
return cast("list[Any]", value)
return []
def _extract_stats(raw_response: dict[str, Any]) -> GenerationStats:
stats_obj = raw_response.get("generation_stats")
if not isinstance(stats_obj, dict):
return {}
return cast("GenerationStats", cast("object", stats_obj))
def _extract_preview(raw_response: dict[str, Any], limit: int = 200) -> str:
choices = _as_list(raw_response.get("choices"))
if not choices:
return ""
first = _as_dict(choices[0])
message = _as_dict(first.get("message"))
content_obj = message.get("content")
if isinstance(content_obj, str):
return content_obj[:limit]
return ""
def run_one_completion(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
stream: bool = False,
) -> tuple[BenchRow, int]:
"""Send one request to ``/bench/chat/completions`` and return its row.
``pp_hint`` is the *target* prompt-token count; the actual prompt is
sized via :class:`PromptSizer` and the verified value is returned as
the second element of the tuple.
"""
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
"model": model_id,
"messages": [{"role": "user", "content": content}],
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": use_prefix_cache,
}
if not stream:
payload["stream"] = False
t0 = time.perf_counter()
raw_obj = client.post_bench_chat_completions(payload)
elapsed = time.perf_counter() - t0
raw = _as_dict(raw_obj)
return (
BenchRow(
elapsed_s=elapsed,
output_text_preview=_extract_preview(raw),
stats=_extract_stats(raw),
),
pp_tokens,
)
return _run_streaming(client, payload, pp_tokens)
def _run_streaming(
client: ExoClient,
payload: dict[str, Any],
pp_tokens: int,
) -> tuple[BenchRow, int]:
"""Streaming variant: parse SSE lines, recover ``GenerationStats``."""
payload = {**payload, "stream": True}
tokens = 0
first_token_time: float | None = None
t0 = time.perf_counter()
text_parts: list[str] = []
stats: GenerationStats = {}
for raw_line in client.stream_bench_chat_completions(payload):
line = raw_line.strip()
if line.startswith(": generation_stats "):
with contextlib.suppress(json.JSONDecodeError):
parsed_obj: Any = json.loads( # type: ignore[reportAny]
line[len(": generation_stats ") :]
)
if isinstance(parsed_obj, dict):
stats = cast("GenerationStats", cast("object", parsed_obj))
continue
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk_obj: Any = json.loads(data) # type: ignore[reportAny]
except json.JSONDecodeError:
continue
chunk = _as_dict(chunk_obj)
choices = _as_list(chunk.get("choices"))
if not choices:
continue
first = _as_dict(choices[0])
delta = _as_dict(first.get("delta"))
delta_content_obj = delta.get("content")
if isinstance(delta_content_obj, str) and delta_content_obj:
if first_token_time is None:
first_token_time = time.perf_counter()
tokens += 1
text_parts.append(delta_content_obj)
elapsed = time.perf_counter() - t0
preview = "".join(text_parts)[:200]
if not stats:
ttft = (first_token_time - t0) if first_token_time is not None else elapsed
gen_time = elapsed - ttft if tokens > 1 else elapsed
gen_tps = (tokens - 1) / gen_time if tokens > 1 and gen_time > 0 else 0.0
prompt_tps = pp_tokens / ttft if ttft > 0 else 0.0
stats = GenerationStats(
prompt_tokens=pp_tokens,
generation_tokens=tokens,
prompt_tps=round(prompt_tps, 2),
generation_tps=round(gen_tps, 2),
peak_memory_usage={"inBytes": 0},
)
return (
BenchRow(
elapsed_s=elapsed,
output_text_preview=preview,
stats=stats,
),
pp_tokens,
)
+428
View File
@@ -0,0 +1,428 @@
"""Prompt-TPS / decode-TPS vs context-size sweep.
Methodology (see also ``bench/METHODOLOGY.md``):
Run a single ascending ramp of equally-spaced prompt lengths
``pp {Δ, 2Δ, , K·Δ}`` with ``prefix_cache=enabled``, ``repeat=1``,
``concurrency=1`` and one warmup at ``pp=Δ``.
Because each step's prefix is exactly what the previous step left in
the cache, every step beyond the first is a *partial* hit and the
server-reported ``prompt_tps`` reflects the true cold rate over the
fresh ``Δ``-token suffix. We accept the warmup's reported rate as the
cold equivalent for ``pp=Δ`` (the warmup itself is the cold prefill).
``decode TPS`` is independent of prefill mechanics every step's
``generation_tps`` is a real decode-rate-at-N data point.
Cumulative cold-prefill upper bound:
``T_cum(pp_k) = Σ_{i=1..k} (Δ_i / prompt_tps_i)``
Optional cold-control points (``prefix_cache=disabled``) validate the
approximation; the gap quantifies per-task overhead. To preserve the
``none`` cache-hit classification AND ensure the request actually
hits a freshly-placed runner (the master picks the instance with the
lowest in-flight task count, which is non-deterministic when multiple
same-model instances exist), :func:`run` deletes the sweep instance
*before* invoking the cold-control factory. The factory itself places
a fresh instance per control and deletes it on exit; the
:func:`bench.lib.cluster.managed_instance` ctx-manager calls
``cleanup_all_instances`` on exit as a final safety net.
"""
from __future__ import annotations
import contextlib
import time
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from dataclasses import asdict, dataclass
from typing import Any
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
Comm,
Sharding,
place_instance,
wait_for_instance_gone,
)
from loguru import logger
from .completion import GenerationStats, PrefixCacheHit, run_one_completion
from .prompt import PromptSizer
from .results import ResultsBundle
from .session import BenchSession
@dataclass(frozen=True)
class ContextScalingParams:
"""Inputs for a single context-scaling sweep."""
pp_step: int
num_steps: int
tg: int
warmup: int = 1
cold_controls: tuple[int, ...] = ()
sleep_between_s: float = 1.0
@dataclass
class StepResult:
pp_tokens: int
delta_tokens: int
prompt_tps: float
generation_tps: float
prefix_cache_hit: PrefixCacheHit | str
prompt_tokens: int
generation_tokens: int
elapsed_s: float
peak_memory_bytes: int = 0
output_text_preview: str = ""
def _peak_bytes(stats: GenerationStats) -> int:
pm = stats.get("peak_memory_usage") or {}
return int(pm.get("inBytes") or pm.get("in_bytes") or 0)
def _build_step_result(
pp_tokens: int,
delta_tokens: int,
elapsed_s: float,
output_text_preview: str,
stats: GenerationStats,
) -> StepResult:
return StepResult(
pp_tokens=pp_tokens,
delta_tokens=delta_tokens,
prompt_tps=float(stats.get("prompt_tps") or 0.0),
generation_tps=float(stats.get("generation_tps") or 0.0),
prefix_cache_hit=stats.get("prefix_cache_hit") or "unknown",
prompt_tokens=int(stats.get("prompt_tokens") or pp_tokens),
generation_tokens=int(stats.get("generation_tokens") or 0),
elapsed_s=elapsed_s,
peak_memory_bytes=_peak_bytes(stats),
output_text_preview=output_text_preview[:200],
)
def _run_request(
client: ExoClient,
full_model_id: str,
pp: int,
tg: int,
sizer: PromptSizer,
*,
use_prefix_cache: bool,
) -> tuple[StepResult, int]:
"""Send one request and return ``(StepResult, actual_pp_tokens)``."""
row, actual_pp = run_one_completion(
client,
full_model_id,
pp,
tg,
sizer,
use_prefix_cache=use_prefix_cache,
stream=False,
)
step = _build_step_result(
pp_tokens=actual_pp,
delta_tokens=actual_pp, # caller overrides for cached sweep
elapsed_s=row["elapsed_s"],
output_text_preview=row["output_text_preview"],
stats=row["stats"],
)
return step, actual_pp
def _compute_t_cum(steps: list[StepResult]) -> list[float]:
t_cum = 0.0
out: list[float] = []
for s in steps:
if s.prompt_tps > 0 and s.delta_tokens > 0:
t_cum += s.delta_tokens / s.prompt_tps
out.append(round(t_cum, 6))
return out
def run_cached_sweep(
session: BenchSession,
params: ContextScalingParams,
bundle: ResultsBundle,
) -> list[StepResult]:
"""Run the ascending PP sweep with ``prefix_cache=enabled``.
Mutates ``bundle.runs`` in place and returns the typed step list.
"""
if session.full_model_id is None:
raise RuntimeError(
"BenchSession.full_model_id must be set for context-scaling."
)
sizer = session.get_prompt_sizer()
client = session.client
pp_targets = [params.pp_step * i for i in range(1, params.num_steps + 1)]
logger.info(
f"context-scaling: K={params.num_steps} steps, Δ={params.pp_step} tokens, "
f"tg={params.tg}, warmup={params.warmup}, cached"
)
# Warmup discipline:
# - First warmup runs with the prefix cache DISABLED. This triggers
# the MLX kernel JIT compile + KV-buffer alloc for this exact
# (Δ, dtype, batch) shape, but does NOT write a cache entry — so
# the cold-with-JIT rate isn't fossilised.
# - Subsequent warmups run with the prefix cache ENABLED. The
# second one finds an empty cache, does a real cold prefill with
# a HOT kernel, and writes the resulting rate into the cache
# entry at pp=Δ.
# - Step 0 (also cache-enabled) is then an exact hit on that entry
# and reports the hot rate.
# Default warmup=2 gives both effects; warmup=1 still does the JIT
# warmup but leaves step 0 as a "none" hit (cold prefill at the hot
# kernel, creates the cache entry on the way through).
for w in range(params.warmup):
is_jit_warmup = w == 0
kind = "JIT warmup" if is_jit_warmup else "cache-prime warmup"
logger.info(
f" warmup {w + 1}/{params.warmup} ({kind}, pp={params.pp_step})"
)
_run_request(
client,
session.full_model_id,
params.pp_step,
params.tg,
sizer,
use_prefix_cache=not is_jit_warmup,
)
steps: list[StepResult] = []
prev_pp = 0
for i, pp in enumerate(pp_targets):
time.sleep(params.sleep_between_s)
try:
step, actual_pp = _run_request(
client,
session.full_model_id,
pp,
params.tg,
sizer,
use_prefix_cache=True,
)
except Exception as e:
logger.error(f"step {i + 1}/{params.num_steps} (pp={pp}) failed: {e}")
raise
step.delta_tokens = actual_pp - prev_pp
steps.append(step)
bundle.runs.append({"step_index": i, "phase": "cached_sweep", **asdict(step)})
logger.info(
f" step {i + 1}/{params.num_steps} pp={actual_pp} Δ={step.delta_tokens} "
f"prompt_tps={step.prompt_tps:.1f} gen_tps={step.generation_tps:.2f} "
f"hit={step.prefix_cache_hit}"
)
prev_pp = actual_pp
return steps
def run_cold_controls(
factory: Callable[[], AbstractContextManager[ExoClient]],
session: BenchSession,
params: ContextScalingParams,
bundle: ResultsBundle,
) -> list[StepResult]:
"""Run cold-control points on a fresh instance to preserve ``none`` hits.
A cold control is a single request at ``pp=N`` with
``prefix_cache=disabled``, executed against a freshly-placed instance
(and with no other same-model instance live, so the master's task
routing is deterministic). The caller is expected to delete the
sweep instance before invoking this see :func:`run`.
"""
if not params.cold_controls:
return []
if session.full_model_id is None:
raise RuntimeError("BenchSession.full_model_id must be set for cold controls.")
sizer = session.get_prompt_sizer()
out: list[StepResult] = []
for control_pp in params.cold_controls:
logger.info(f"cold control: pp={control_pp} (fresh instance, cache disabled)")
with factory() as fresh_client:
step, actual_pp = _run_request(
fresh_client,
session.full_model_id,
control_pp,
params.tg,
sizer,
use_prefix_cache=False,
)
step.delta_tokens = actual_pp
out.append(step)
bundle.cold_controls.append({"phase": "cold_control", **asdict(step)})
logger.info(
f" cold pp={actual_pp} prompt_tps={step.prompt_tps:.1f} "
f"gen_tps={step.generation_tps:.2f} hit={step.prefix_cache_hit}"
)
if step.prefix_cache_hit != "none":
logger.warning(
f"cold control at pp={actual_pp} reported "
f"prefix_cache_hit={step.prefix_cache_hit!r}; "
f"control may not be cold."
)
return out
def derive_summary(
steps: list[StepResult],
cold_controls: list[StepResult],
) -> dict[str, Any]:
"""Compute the cumulative cold-prefill upper bound + control gaps."""
t_cum = _compute_t_cum(steps)
bracketed = sorted(
((s.pp_tokens, t) for s, t in zip(steps, t_cum, strict=True)),
key=lambda x: x[0],
)
control_gaps: list[dict[str, float]] = []
for ctrl in cold_controls:
cold_t = ctrl.pp_tokens / ctrl.prompt_tps if ctrl.prompt_tps > 0 else 0.0
cum_t = _interp(bracketed, ctrl.pp_tokens)
gap = cum_t - cold_t
control_gaps.append(
{
"pp_tokens": ctrl.pp_tokens,
"cold_t_seconds": round(cold_t, 4),
"t_cum_seconds_at_pp": round(cum_t, 4),
"gap_seconds": round(gap, 4),
"gap_fraction": round(gap / cold_t, 4) if cold_t > 0 else 0.0,
}
)
return {
"t_cum_seconds": t_cum,
"control_gaps": control_gaps,
}
def _interp(points: list[tuple[int, float]], x: int) -> float:
"""Linear interpolate y at x, given sorted ``(x, y)`` points."""
if not points:
return 0.0
if x <= points[0][0]:
return points[0][1]
if x >= points[-1][0]:
return points[-1][1]
for i in range(1, len(points)):
x0, y0 = points[i - 1]
x1, y1 = points[i]
if x0 <= x <= x1 and x1 != x0:
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
return points[-1][1]
# ---------------------------------------------------------------------------
# Cold-control instance factory
# ---------------------------------------------------------------------------
def make_cold_control_factory(
session: BenchSession,
sharding: Sharding,
comm: Comm,
min_nodes: int,
instance_timeout_s: float = 1800.0,
) -> Callable[[], AbstractContextManager[ExoClient]]:
"""Return a callable yielding a context manager that places a fresh instance.
Each ``with factory() as client:`` block places a brand-new instance,
yields its client, then deletes the instance on exit. Used to isolate
cold-control runs.
The caller is responsible for ensuring no other same-model instance is
live during the ``with`` block otherwise master routing is
non-deterministic and the cold control may be served by a stale runner.
See :func:`run` for the orchestration.
"""
@contextmanager
def factory() -> Iterator[ExoClient]:
if session.full_model_id is None:
raise RuntimeError("session.full_model_id is unset")
client = session.client
instance_id = place_instance(
client,
session.full_model_id,
sharding=sharding,
comm=comm,
min_nodes=min_nodes,
timeout=instance_timeout_s,
)
try:
yield client
finally:
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
with contextlib.suppress(Exception):
wait_for_instance_gone(client, instance_id, timeout=60.0)
return factory
def _delete_instance(client: ExoClient, instance_id: str) -> None:
"""Best-effort delete of a placed instance."""
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
with contextlib.suppress(Exception):
wait_for_instance_gone(client, instance_id, timeout=60.0)
def run(
session: BenchSession,
params: ContextScalingParams,
bundle: ResultsBundle,
*,
cold_control_factory: Callable[[], AbstractContextManager[ExoClient]] | None = None,
) -> ResultsBundle:
"""End-to-end: cached sweep + optional cold controls + derived summary.
To make cold controls truly isolated from the sweep instance, we
delete the sweep instance *before* running the controls (otherwise
the master might route a control's request to the stale sweep
instance, since both match the same ``model_id``). The controls then
each place their own fresh instance via the factory.
"""
bundle.params.update(
{
"pp_step": params.pp_step,
"num_steps": params.num_steps,
"tg": params.tg,
"warmup": params.warmup,
"cold_controls": list(params.cold_controls),
"sleep_between_s": params.sleep_between_s,
"model_id": session.model_id,
"full_model_id": session.full_model_id,
}
)
bundle.capture_cluster(session.client)
cached_steps = run_cached_sweep(session, params, bundle)
cold_steps: list[StepResult] = []
if params.cold_controls and cold_control_factory is not None:
# Delete the sweep instance so the cold-control fresh instance is
# the only same-model instance live for the duration of the controls.
if session.instance_id is not None:
logger.info(
f"cold controls: deleting sweep instance {session.instance_id} "
"to isolate fresh instance routing"
)
_delete_instance(session.client, session.instance_id)
session.instance_id = None
cold_steps = run_cold_controls(cold_control_factory, session, params, bundle)
elif params.cold_controls and cold_control_factory is None:
logger.warning(
"Cold controls requested but no cold_control_factory supplied; skipping."
)
bundle.derived.update(derive_summary(cached_steps, cold_steps))
return bundle
+183
View File
@@ -0,0 +1,183 @@
"""Fetch HuggingFace model metadata for benchmark planning.
Two pieces of metadata drive every benchmark we run:
1. **Total weight size** used to derive ``min-memory`` and ``min-disk``
constraints when picking a host. We sum the sizes of all
``.safetensors`` (or ``.bin``) shards from the repo's file listing.
2. **Max position embeddings** the model's training context length.
Used to bound a context-scaling sweep at the model's max context, and
to derive a sensible Δ given a target step count.
The fetcher uses the ``huggingface_hub`` python API, which talks to the
public HF Hub HTTPS endpoints no exo cluster required, no download
of weights.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, cast
# Files that count toward the on-disk weight footprint.
_WEIGHT_SUFFIXES = (".safetensors", ".bin", ".gguf", ".pt", ".npz")
@dataclass(frozen=True)
class ModelMeta:
"""Subset of HF metadata that a benchmark needs."""
model_id: str
total_weight_bytes: int
max_position_embeddings: int
num_hidden_layers: int
raw_config: dict[str, Any] = field(default_factory=dict)
@property
def total_weight_gb(self) -> float:
return self.total_weight_bytes / (1024**3)
@property
def memory_constraint_gb(self) -> float:
"""Estimated minimum host memory to hold weights + overhead.
Picks the model size + 30 % headroom (KV cache, activations,
framework bookkeeping). Rounded up to the next whole GiB.
"""
return float(int(self.total_weight_gb * 1.30) + 1)
@property
def disk_constraint_gb(self) -> float:
"""Disk space the host must have free for the download."""
return float(int(self.total_weight_gb * 1.10) + 1)
def _read_config_json(model_id: str) -> dict[str, Any]:
from huggingface_hub import (
hf_hub_download, # type: ignore[reportUnknownVariableType]
)
raw_path = hf_hub_download(repo_id=model_id, filename="config.json", dry_run=False)
with open(raw_path) as f:
loaded: Any = json.load(f) # type: ignore[reportAny]
return cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {}
def _sum_weight_sizes(model_id: str) -> int:
"""Sum sizes of all weight-shard files in the repo's file listing."""
from huggingface_hub import HfApi
api = HfApi()
info = api.model_info(repo_id=model_id, files_metadata=True)
siblings = info.siblings or []
total = 0
for sib in siblings:
rfilename = getattr(sib, "rfilename", None)
size = getattr(sib, "size", None)
if not isinstance(rfilename, str) or not isinstance(size, int):
continue
if any(rfilename.endswith(suf) for suf in _WEIGHT_SUFFIXES):
total += size
return total
def _first_int(config: dict[str, Any], *keys: str) -> int:
"""Return the first key from ``config`` that holds a usable positive int."""
for key in keys:
value = config.get(key)
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str):
try:
parsed = int(value)
except ValueError:
continue
if parsed > 0:
return parsed
return 0
def fetch_model_meta(model_id: str) -> ModelMeta:
"""Fetch the metadata our benchmarks care about for ``model_id``.
Args:
model_id: HuggingFace repo id, e.g. ``mlx-community/Qwen3-30B-A3B-4bit``.
Returns:
Populated :class:`ModelMeta`.
Raises:
Exception: any HTTP / parse error from ``huggingface_hub`` propagates.
"""
config = _read_config_json(model_id)
return ModelMeta(
model_id=model_id,
total_weight_bytes=_sum_weight_sizes(model_id),
max_position_embeddings=_first_int(
config,
"max_position_embeddings",
"max_seq_len",
"model_max_length",
"n_positions",
),
num_hidden_layers=_first_int(
config,
"num_hidden_layers",
"num_layers",
"n_layer",
"n_layers",
"num_decoder_layers",
),
raw_config=config,
)
def derive_context_ramp(
meta: ModelMeta,
*,
num_steps: int,
fraction_of_max: float = 1.0,
min_pp_step: int = 256,
round_to: int = 256,
) -> tuple[int, int]:
"""Pick ``(pp_step, num_steps)`` covering ``fraction_of_max`` of the context.
Δ is rounded down to the nearest ``round_to`` so the per-step prompt is a
clean number, and clamped to ``min_pp_step`` for tiny-context models.
"""
if meta.max_position_embeddings <= 0:
raise ValueError(
f"{meta.model_id} reports max_position_embeddings=0 in config.json"
)
if not (0.0 < fraction_of_max <= 1.0):
raise ValueError(f"fraction_of_max must be in (0, 1], got {fraction_of_max}")
if num_steps <= 0:
raise ValueError(f"num_steps must be >0, got {num_steps}")
target_max = int(meta.max_position_embeddings * fraction_of_max)
raw_step = max(min_pp_step, target_max // num_steps)
pp_step = (raw_step // round_to) * round_to or round_to
return pp_step, num_steps
def derive_cold_controls(
meta: ModelMeta,
*,
pp_step: int,
num_steps: int,
count: int = 4,
) -> tuple[int, ...]:
"""Pick ``count`` evenly-spaced cold-control points across the ramp.
Always includes the largest ramp point (``pp_step * num_steps``).
Returns control pp values in ascending order, deduped.
"""
if count <= 0:
return ()
max_pp = pp_step * num_steps
if count == 1:
return (max_pp,)
spaced = sorted({(max_pp * (i + 1)) // count for i in range(count)})
# Filter out anything below pp_step (a control at <Δ is meaningless).
return tuple(p for p in spaced if p >= pp_step)
+308
View File
@@ -0,0 +1,308 @@
"""Typed matplotlib renderers for benchmark JSON results.
This module owns the *visualisation* of bench results, mirroring how
``bench/lib/<name>.py`` owns the methodology and ``bench/cli/<name>.py``
owns the orchestration. Adding plotting for a new benchmark = a new
``render_<name>`` function here + a dispatch entry in ``bench/cli/plot.py``.
Functions take typed inputs (``Path`` lists, options) and write a PNG.
They never touch argparse or stdout that's the CLI's job.
matplotlib's type stubs are thin (most return values are ``Any``), so all
calls into ``pyplot`` are concentrated at the bottom of this file with
targeted ``# type: ignore[reportUnknownMemberType, reportAny]`` per line.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
# Tab10 cycle from matplotlib's default; we pick colours by index ourselves
# instead of fishing them out of `Line2D.get_color()` so the strict-type
# fallout stays small and predictable.
_COLOR_CYCLE: tuple[str, ...] = (
"C0",
"C1",
"C2",
"C3",
"C4",
"C5",
"C6",
"C7",
"C8",
"C9",
)
@dataclass(frozen=True)
class PlotInputs:
"""Inputs for any benchmark renderer.
Attributes:
results: One or more bench JSON files. The first is used to
auto-derive the title when ``title`` is unset.
output: Path to write the PNG to.
label_tag: When set, use ``metadata.tags[label_tag]`` as the
legend label for each run; otherwise use the run id.
title: Override for the figure title.
"""
results: list[Path]
output: Path
label_tag: str | None = None
title: str | None = None
@dataclass(frozen=True)
class _RunSeries:
"""Pre-extracted plot data for one results JSON.
``cached_prefill_seconds`` is the cumulative cold-prefill estimate
(``T_cum`` from the methodology read from ``derived.t_cum_seconds``).
``control_prefill_seconds`` is the actual cold prefill time per
control (``pp_tokens / prompt_tps`` from the cold-control row).
"""
label: str
cached_pp: list[int]
cached_prefill_seconds: list[float]
cached_gen_tps: list[float]
control_pp: list[int]
control_prefill_seconds: list[float]
# ---------------------------------------------------------------------------
# Pure data extraction (strict-typed, no matplotlib)
# ---------------------------------------------------------------------------
def _load(path: Path) -> dict[str, Any]:
"""Read a bench JSON file and assert top-level shape."""
with path.open() as f:
loaded: Any = json.load(f) # type: ignore[reportAny]
if not isinstance(loaded, dict):
raise ValueError(f"{path}: expected top-level JSON object")
return cast("dict[str, Any]", loaded)
# dict[str, Any].get(...) returns Any. The five _get_* helpers below
# concentrate the Any boundary so the rest of the module can be strict.
def _get_dict(d: dict[str, Any], key: str) -> dict[str, Any]:
val: Any = d.get(key)
return cast("dict[str, Any]", val) if isinstance(val, dict) else {}
def _get_list(d: dict[str, Any], key: str) -> list[Any]:
val: Any = d.get(key)
return cast("list[Any]", val) if isinstance(val, list) else []
def _get_str(d: dict[str, Any], key: str, default: str = "") -> str:
val: Any = d.get(key, default) # type: ignore[reportAny]
return val if isinstance(val, str) else default
def _get_int(row: dict[str, Any], key: str) -> int:
val: Any = row.get(key, 0) # type: ignore[reportAny]
if isinstance(val, bool): # bool is int; reject explicitly
return 0
if isinstance(val, (int, float)):
return int(val)
if isinstance(val, str):
try:
return int(float(val))
except ValueError:
return 0
return 0
def _get_float(row: dict[str, Any], key: str) -> float:
val: Any = row.get(key, 0.0) # type: ignore[reportAny]
if isinstance(val, bool):
return 0.0
if isinstance(val, (int, float)):
return float(val)
if isinstance(val, str):
try:
return float(val)
except ValueError:
return 0.0
return 0.0
def _label_for(data: dict[str, Any], label_tag: str | None) -> str:
if label_tag is not None:
tags = _get_dict(_get_dict(data, "metadata"), "tags")
if label_tag in tags:
return _get_str(tags, label_tag, "(unnamed)")
return _get_str(_get_dict(data, "metadata"), "run_id", "(unnamed)")
def _extract_series(data: dict[str, Any], label: str) -> _RunSeries:
"""Pre-extract typed lists from a context-scaling bench JSON."""
cached_pp: list[int] = []
cached_gen_tps: list[float] = []
for raw in _get_list(data, "runs"): # type: ignore[reportAny]
if not isinstance(raw, dict):
continue
row = cast("dict[str, Any]", raw)
if _get_str(row, "phase") != "cached_sweep":
continue
cached_pp.append(_get_int(row, "pp_tokens"))
cached_gen_tps.append(_get_float(row, "generation_tps"))
# Cumulative cold-prefill estimate is computed in derive_summary and
# written to derived.t_cum_seconds (parallel to the cached steps).
derived = _get_dict(data, "derived")
t_cum_raw = _get_list(derived, "t_cum_seconds")
cached_prefill_seconds: list[float] = []
for raw in t_cum_raw: # type: ignore[reportAny]
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
cached_prefill_seconds.append(float(raw))
# Cold controls give us the actual cold prefill time directly:
# pp_tokens / prompt_tps. Skip rows with zero/missing prompt_tps.
control_pp: list[int] = []
control_prefill_seconds: list[float] = []
for raw in _get_list(data, "cold_controls"): # type: ignore[reportAny]
if not isinstance(raw, dict):
continue
row = cast("dict[str, Any]", raw)
pp = _get_int(row, "pp_tokens")
tps = _get_float(row, "prompt_tps")
if pp > 0 and tps > 0:
control_pp.append(pp)
control_prefill_seconds.append(pp / tps)
return _RunSeries(
label=label,
cached_pp=cached_pp,
cached_prefill_seconds=cached_prefill_seconds,
cached_gen_tps=cached_gen_tps,
control_pp=control_pp,
control_prefill_seconds=control_prefill_seconds,
)
def _auto_title(data: dict[str, Any]) -> str:
metadata = _get_dict(data, "metadata")
params = _get_dict(data, "params")
model = (
_get_str(params, "full_model_id") or _get_str(params, "model_id") or "(unknown)"
)
sha = _get_str(metadata, "exo_sha") or "(no-sha)"
host = _get_str(metadata, "hostname") or "(no-host)"
return f"{model}\n{sha} on {host}"
# ---------------------------------------------------------------------------
# Matplotlib boundary — each call site has a narrow, justified ignore.
# ---------------------------------------------------------------------------
def render_context_scaling(inputs: PlotInputs) -> Path:
"""Render a 2-panel context-scaling plot.
Top: pp_tokens vs prompt_tps (line per run; cold controls as 'x' scatter)
Bottom: pp_tokens vs generation_tps (line per run)
Each line is a separate result file. Multi-file mode is for comparing
runs across exo SHAs / hosts / configs; the title is taken from the
first file's metadata unless ``inputs.title`` is set.
"""
if not inputs.results:
raise ValueError("at least one results JSON path is required")
# Validate + extract first so any data-shape error surfaces before we
# even import matplotlib.
first_data: dict[str, Any] | None = None
series: list[_RunSeries] = []
for path in inputs.results:
data = _load(path)
if first_data is None:
first_data = data
benchmark = _get_str(_get_dict(data, "metadata"), "benchmark")
if benchmark != "context_scaling":
raise ValueError(
f"{path}: expected benchmark=='context_scaling', got {benchmark!r}"
)
series.append(_extract_series(data, _label_for(data, inputs.label_tag)))
title = inputs.title
if title is None and first_data is not None:
title = _auto_title(first_data)
if len(inputs.results) > 1:
title = f"{title}\n(comparison of {len(inputs.results)} runs)"
inputs.output.parent.mkdir(parents=True, exist_ok=True)
_draw(series, inputs.output, title=title)
return inputs.output
def _draw(series: list[_RunSeries], output: Path, *, title: str | None) -> None:
"""Concentrated matplotlib boundary."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, axes = plt.subplots( # type: ignore[reportUnknownMemberType]
2, 1, figsize=(10, 8), sharex=True
)
top: Any = axes[0] # type: ignore[reportAny]
bottom: Any = axes[1] # type: ignore[reportAny]
for i, run in enumerate(series):
color = _COLOR_CYCLE[i % len(_COLOR_CYCLE)]
# Cumulative cold-prefill estimate (T_cum). Only plot points where
# we have a t_cum value — skip if derived was empty for this run.
n = min(len(run.cached_pp), len(run.cached_prefill_seconds))
if n > 0:
top.plot( # type: ignore[reportAny, reportUnknownMemberType]
run.cached_pp[:n],
run.cached_prefill_seconds[:n],
"-o",
color=color,
label=run.label,
)
if run.control_pp:
top.scatter( # type: ignore[reportAny, reportUnknownMemberType]
run.control_pp,
run.control_prefill_seconds,
marker="x",
s=80,
color=color,
label=f"{run.label} (cold one-shot)",
)
bottom.plot( # type: ignore[reportAny, reportUnknownMemberType]
run.cached_pp,
run.cached_gen_tps,
"-o",
color=color,
label=run.label,
)
top.set_ylabel("prefill time (s)") # type: ignore[reportAny, reportUnknownMemberType]
top.set_title( # type: ignore[reportAny, reportUnknownMemberType]
"cumulative cold-prefill time vs context size "
"(line: T_cum estimate; ✕: cold one-shot control)"
)
top.grid(True, alpha=0.3) # type: ignore[reportAny, reportUnknownMemberType]
top.legend(loc="best", fontsize=8) # type: ignore[reportAny, reportUnknownMemberType]
bottom.set_xlabel("pp_tokens") # type: ignore[reportAny, reportUnknownMemberType]
bottom.set_ylabel("generation_tps (tok/s)") # type: ignore[reportAny, reportUnknownMemberType]
bottom.set_title("decode throughput vs context size") # type: ignore[reportAny, reportUnknownMemberType]
bottom.grid(True, alpha=0.3) # type: ignore[reportAny, reportUnknownMemberType]
if title is not None:
fig.suptitle(title, fontsize=10) # type: ignore[reportUnknownMemberType]
fig.tight_layout()
fig.savefig(output, dpi=120, bbox_inches="tight") # type: ignore[reportUnknownMemberType]
plt.close(fig)
+269
View File
@@ -0,0 +1,269 @@
"""Typed prompt-sizing utilities for benchmarks.
Wraps the HuggingFace ``transformers`` tokenizer (a fundamentally dynamic
object different models return different types from
``apply_chat_template``) behind a small typed API so the rest of the bench
library can stay strict-typed.
``PromptSizer.build(target)`` returns a ``(content, exact_token_count)``
pair. Internally it:
1. Tokenises the empty user message to learn the chat-template overhead
(``base_tokens``).
2. Estimates tokens-per-atom from a 100-atom sample.
3. Binary-searches over the atom count so the resulting message
tokenises to *exactly* ``target`` tokens.
Callers downstream (``run_one_completion`` etc.) receive the verified
token count, so analysis can confirm the prompt hit its target.
"""
from __future__ import annotations
import importlib.util
import json
import sys
import types
from collections.abc import Callable
from pathlib import Path
from typing import Any, Final, cast
def _coerce_token_ids(raw: object) -> list[int]:
"""Normalise ``apply_chat_template`` output to a flat list of token ids.
transformers' ``apply_chat_template`` may return:
- ``list[int]`` (slow tokenizers, ``tokenize=True``)
- a ``BatchEncoding`` with ``.input_ids`` (fast tokenizers)
- a tensor wrapped object (some models)
We only need ``len(.)`` of the result, so we just need to flatten to a
list and return it.
"""
if isinstance(raw, list):
return cast("list[int]", raw)
input_ids = getattr(raw, "input_ids", None)
if isinstance(input_ids, list):
return cast("list[int]", input_ids)
raise TypeError(
f"Unsupported tokenizer output type {type(raw).__name__}; "
"expected list[int] or BatchEncoding-like with .input_ids."
)
def _build_token_counter(tokenizer: object) -> Callable[[str], int]:
"""Return a closure that counts tokens for a user message.
Tries ``apply_chat_template`` first; falls back to the DeepSeek-V4
Python encoder for models that don't ship a Jinja chat template.
"""
apply_chat_template = cast(
Callable[..., object],
tokenizer.apply_chat_template, # type: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
)
encode = cast(
Callable[..., list[int]],
tokenizer.encode, # type: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
)
def count_fn(user_content: str) -> int:
messages = [{"role": "user", "content": user_content}]
try:
raw = apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
except ValueError:
# Models without a Jinja chat template (e.g. DeepSeek V4 which
# ships its own Python encoder). Use the exo-side V4 encoder.
from exo.worker.engines.mlx.vendor.deepseek_v4_encoding import ( # type: ignore[reportMissingTypeStubs]
encode_messages as encode_v4,
)
prompt = cast(str, encode_v4(messages, thinking_mode="thinking")) # type: ignore[reportUnknownArgumentType]
raw = encode(prompt, add_special_tokens=False)
return len(_coerce_token_ids(raw))
return count_fn
class PromptSizer:
"""Build a chat-completion content string of an exact token length."""
DEFAULT_ATOM: Final[str] = "a "
def __init__(self, tokenizer: object, atom: str = DEFAULT_ATOM):
self._tokenizer = tokenizer
self.atom = atom
self._count_fn = _build_token_counter(tokenizer)
self.base_tokens = self._count_fn("")
def count(self, content: str) -> int:
"""Return the token count for ``content`` after chat-template expansion."""
return self._count_fn(content)
def build(self, target_prompt_tokens: int) -> tuple[str, int]:
"""Return ``(content, exact_token_count)`` summing to ``target``.
Raises ``RuntimeError`` if the chosen ``atom`` overshoots the target
(try a different atom see ``DEFAULT_ATOM``).
"""
target = int(target_prompt_tokens)
if target < self.base_tokens:
raise RuntimeError(
f"Target ({target}) is smaller than template overhead "
f"({self.base_tokens})."
)
# Estimate tokens per atom using a sample.
sample_count = 100
sample_tokens = self._count_fn(self.atom * sample_count) - self.base_tokens
tokens_per_atom = sample_tokens / sample_count
needed_tokens = target - self.base_tokens
estimated_atoms = int(needed_tokens / tokens_per_atom)
# Binary search to find exact atom count.
low, high = 0, estimated_atoms * 2 + 100
while low < high:
mid = (low + high) // 2
if self._count_fn(self.atom * mid) < target:
low = mid + 1
else:
high = mid
content = self.atom * low
actual = self._count_fn(content)
if actual != target:
raise RuntimeError(
f"Overshot: got {actual} tokens (target {target}). "
f"Pick a different atom (try ' a' or '\\n' or '0 ')."
)
return content, actual
def _load_kimi_tokenizer(model_id: str) -> object:
"""Special-case Kimi K2's custom TikTokenTokenizer (transformers 5.x quirk)."""
from huggingface_hub import (
snapshot_download, # type: ignore[reportUnknownVariableType]
)
raw_path = snapshot_download(
model_id,
allow_patterns=[
"*.json",
"*.py",
"*.tiktoken",
"*.model",
"*.jinja",
],
dry_run=False,
)
model_path = Path(raw_path)
sys.path.insert(0, str(model_path))
tool_decl_path = model_path / "tool_declaration_ts.py"
if tool_decl_path.exists():
spec = importlib.util.spec_from_file_location(
"tool_declaration_ts", tool_decl_path
)
if spec is not None and spec.loader is not None:
tool_decl_module = importlib.util.module_from_spec(spec)
sys.modules["tool_declaration_ts"] = tool_decl_module
spec.loader.exec_module(tool_decl_module)
tok_path = model_path / "tokenization_kimi.py"
source = tok_path.read_text().replace(
"from .tool_declaration_ts", "from tool_declaration_ts"
)
tok_module = types.ModuleType("tokenization_kimi")
tok_module.__file__ = str(tok_path)
sys.modules["tokenization_kimi"] = tok_module
exec(compile(source, str(tok_path), "exec"), tok_module.__dict__) # noqa: S102
tik_token_cls = cast(Any, tok_module).TikTokenTokenizer # type: ignore[reportAny]
hf_tokenizer = cast(Any, tik_token_cls.from_pretrained(model_path)) # type: ignore[reportAny]
# Patch encode to use internal tiktoken model directly (transformers 5.x
# bug in the encode→pad path for slow tokenizers).
def _patched_encode(text: str, **_kwargs: object) -> list[int]:
return list(
hf_tokenizer.model.encode(text, allowed_special="all") # type: ignore[reportAny, reportUnknownMemberType]
)
hf_tokenizer.encode = _patched_encode
return cast(object, hf_tokenizer)
def load_tokenizer_for_bench(model_id: str) -> object:
"""Load a HuggingFace tokenizer with bench-specific compatibility shims.
Returns the tokenizer as ``object`` because transformers' types are
fundamentally dynamic (concrete class depends on the model). Callers
should pass the result straight to :class:`PromptSizer`.
"""
# Monkey-patch for transformers 5.x: Kimi's tokenization_kimi.py imports
# bytes_to_unicode from gpt2_tokenization which moved.
try:
import transformers.models.gpt2.tokenization_gpt2 as gpt2_tokenization
from transformers.convert_slow_tokenizer import bytes_to_unicode
if not hasattr(gpt2_tokenization, "bytes_to_unicode"):
gpt2_tokenization.bytes_to_unicode = bytes_to_unicode # type: ignore[reportAttributeAccessIssue]
except ImportError:
pass
if "kimi-k2" in model_id.lower():
return _load_kimi_tokenizer(model_id)
from transformers import AutoTokenizer
try:
return cast(
object,
AutoTokenizer.from_pretrained(model_id, trust_remote_code=True), # type: ignore[reportUnknownMemberType]
)
except (AttributeError, ValueError):
# Some models ship a Jinja template / encoder that AutoTokenizer
# can't introspect from HF directly — download artefacts and load
# from the local snapshot path.
from huggingface_hub import (
snapshot_download, # type: ignore[reportUnknownVariableType]
)
from transformers import PretrainedConfig
raw_full_path = snapshot_download(
model_id,
allow_patterns=[
"*.json",
"*.py",
"tokenizer.model",
"*.tiktoken",
"tiktoken.model",
"*.txt",
"*.jsonl",
"*.jinja",
],
dry_run=False,
)
model_path = Path(raw_full_path)
stub_kwargs: dict[str, Any] = {}
config_file = model_path / "config.json"
if config_file.exists():
with config_file.open() as f:
raw_config: dict[str, Any] = json.load(f) # type: ignore[reportAny]
for key in (
"model_type",
"max_position_embeddings",
"vocab_size",
"bos_token_id",
"eos_token_id",
"pad_token_id",
):
if key in raw_config:
stub_kwargs[key] = raw_config[key]
return cast(
object,
AutoTokenizer.from_pretrained( # type: ignore[reportUnknownMemberType]
str(model_path),
config=PretrainedConfig(**stub_kwargs), # type: ignore[reportArgumentType, reportAny]
trust_remote_code=True,
),
)
+139
View File
@@ -0,0 +1,139 @@
"""Structured benchmark results — metadata capture + JSON output.
Every benchmark run produces a single JSON file with a stable schema:
- ``metadata``: exo SHA, ISO timestamps, hostnames, and any user-supplied
tags identifying the run.
- ``cluster``: snapshot from the API (node identities, topology, memory).
- ``params``: the benchmark's input parameters (sweep config, etc).
- ``runs``: per-request result rows.
- ``derived``: any computed summaries (``t_cum_seconds`` for context scaling).
The format is intentionally additive so downstream tooling (plot scripts,
dashboards) can rely on optional fields being absent rather than malformed.
"""
from __future__ import annotations
import json
import os
import platform
import socket
import subprocess
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from exo_tools.client import ExoClient
from exo_tools.harness import capture_cluster_snapshot
def _git_describe(repo_root: Path) -> str | None:
"""Return ``<short-sha>[-dirty]`` for the repo at ``repo_root`` or None."""
try:
sha = subprocess.run(
["git", "rev-parse", "--short=12", "HEAD"],
cwd=str(repo_root),
capture_output=True,
text=True,
timeout=5,
check=True,
).stdout.strip()
except (
subprocess.CalledProcessError,
FileNotFoundError,
subprocess.TimeoutExpired,
):
return None
try:
dirty = subprocess.run(
["git", "status", "--porcelain"],
cwd=str(repo_root),
capture_output=True,
text=True,
timeout=5,
check=True,
).stdout.strip()
return f"{sha}-dirty" if dirty else sha
except (
subprocess.CalledProcessError,
FileNotFoundError,
subprocess.TimeoutExpired,
):
return sha
@dataclass
class RunMetadata:
"""Identifies a single bench run."""
run_id: str
benchmark: str
started_at: str
finished_at: str | None = None
exo_sha: str | None = None
hostname: str = ""
platform: str = ""
tags: dict[str, str] = field(default_factory=dict)
@classmethod
def new(
cls,
benchmark: str,
repo_root: Path,
*,
tags: dict[str, str] | None = None,
) -> RunMetadata:
now = datetime.now(timezone.utc)
run_id = f"{benchmark}_{now.strftime('%Y%m%dT%H%M%SZ')}_{os.getpid()}"
return cls(
run_id=run_id,
benchmark=benchmark,
started_at=now.isoformat(),
exo_sha=_git_describe(repo_root),
hostname=socket.gethostname(),
platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
tags=dict(tags or {}),
)
@dataclass
class ResultsBundle:
"""Container for a single benchmark's results, before being written."""
metadata: RunMetadata
params: dict[str, Any] = field(default_factory=dict)
cluster: dict[str, Any] = field(default_factory=dict)
runs: list[dict[str, Any]] = field(default_factory=list)
cold_controls: list[dict[str, Any]] = field(default_factory=list)
derived: dict[str, Any] = field(default_factory=dict)
def capture_cluster(self, client: ExoClient) -> None:
"""Snapshot the cluster state into ``self.cluster``."""
try:
snapshot = capture_cluster_snapshot(client)
if snapshot:
self.cluster.update(snapshot)
except Exception:
# Non-fatal: a benchmark without cluster snapshot is still valid
pass
def write_json(self, output_dir: Path) -> Path:
"""Write the bundle as ``<output_dir>/<run_id>.json`` and return the path."""
if self.metadata.finished_at is None:
self.metadata.finished_at = datetime.now(timezone.utc).isoformat()
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / f"{self.metadata.run_id}.json"
with path.open("w", encoding="utf-8") as f:
json.dump(asdict(self), f, indent=2, ensure_ascii=False)
return path
def find_repo_root(start: Path | None = None) -> Path:
"""Walk upwards from ``start`` (or this file) until a ``.git`` dir is found."""
cur = (start or Path(__file__)).resolve()
for parent in (cur, *cur.parents):
if (parent / ".git").is_dir() or (parent / ".git").is_file():
return parent
raise RuntimeError(f"Could not locate repo root above {cur}")
+65
View File
@@ -0,0 +1,65 @@
"""BenchSession — wires together cluster + client + instance + tokenizer.
Holds the ``EcoSession``, a deployed ``ClusterInfo``, an ``ExoClient`` for
the cluster's primary endpoint, and (for benchmarks that need exact-token
prompts) a lazily-constructed :class:`PromptSizer`.
Benchmarks consume this via :func:`bench.lib.cluster.managed_instance`,
which yields a populated ``BenchSession``. Library helpers (e.g.
``context_scaling.run``) take a ``BenchSession`` and never reach for
global state.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, cast
from exo_tools.client import ExoClient
from exo_tools.cluster import ClusterInfo, EcoSession, make_client_from_url
from .prompt import PromptSizer, load_tokenizer_for_bench
@dataclass
class BenchSession:
"""Bundle of cluster + client + (optional) instance for benchmarks."""
cluster: ClusterInfo
eco: EcoSession
instance_id: str | None = None
model_id: str | None = None
full_model_id: str | None = None
_prompt_sizer: PromptSizer | None = field(default=None, repr=False)
@property
def client(self) -> ExoClient:
return make_client_from_url(self.cluster.api_url)
def state(self) -> dict[str, Any]:
raw: Any = self.client.request_json("GET", "/state") # type: ignore[reportAny]
if isinstance(raw, dict):
return cast("dict[str, Any]", raw)
return {}
def instances(self) -> dict[str, Any]:
result: Any = self.state().get("instances", {}) # type: ignore[reportAny]
if isinstance(result, dict):
return cast("dict[str, Any]", result)
return {}
def get_prompt_sizer(self) -> PromptSizer:
"""Return a cached :class:`PromptSizer` for ``self.full_model_id``.
Loaded lazily because tokenizer load is expensive and not every
benchmark needs prompt sizing.
"""
if self._prompt_sizer is not None:
return self._prompt_sizer
if self.full_model_id is None:
raise RuntimeError(
"BenchSession.full_model_id is not set; cannot build a PromptSizer."
)
tokenizer = load_tokenizer_for_bench(self.full_model_id)
self._prompt_sizer = PromptSizer(tokenizer)
return self._prompt_sizer
View File
Whitespace-only changes.
+186
View File
@@ -0,0 +1,186 @@
"""Unit tests for the pure helpers in ``bench.lib.context_scaling``.
The orchestration entry points (``run``, ``run_cached_sweep``,
``run_cold_controls``, ``make_cold_control_factory``) need a real
``BenchSession`` and exo cluster, so they're exercised end-to-end via
``python -m bench.cli context-scaling``. This module covers the
underscore-prefixed pure helpers via direct private-symbol access (the
private prefix discourages library users; tests for those helpers are
the explicit exception).
"""
from __future__ import annotations
import math
from typing import cast
from bench.lib.context_scaling import (
StepResult,
_compute_t_cum, # type: ignore[reportPrivateUsage]
_interp, # type: ignore[reportPrivateUsage]
derive_summary,
)
def _step(
*,
pp: int,
delta: int,
prompt_tps: float,
generation_tps: float = 100.0,
hit: str = "partial",
) -> StepResult:
return StepResult(
pp_tokens=pp,
delta_tokens=delta,
prompt_tps=prompt_tps,
generation_tps=generation_tps,
prefix_cache_hit=hit,
prompt_tokens=pp,
generation_tokens=32,
elapsed_s=delta / prompt_tps if prompt_tps else 0.0,
)
def _close(actual: float, expected: float, abs_tol: float = 1e-3) -> bool:
return math.isclose(actual, expected, abs_tol=abs_tol)
# ---------------------------------------------------------------------------
# _compute_t_cum
# ---------------------------------------------------------------------------
class TestComputeTCum:
def test_empty_returns_empty(self) -> None:
assert _compute_t_cum([]) == []
def test_single_step(self) -> None:
# 256 tokens at 1024 tps -> 0.25s
out = _compute_t_cum([_step(pp=256, delta=256, prompt_tps=1024.0)])
assert len(out) == 1
assert _close(out[0], 0.25)
def test_cumulative_sum_across_three_steps(self) -> None:
steps = [
_step(pp=256, delta=256, prompt_tps=1000.0), # 0.256s
_step(pp=512, delta=256, prompt_tps=2000.0), # +0.128s = 0.384s
_step(pp=768, delta=256, prompt_tps=512.0), # +0.500s = 0.884s
]
out = _compute_t_cum(steps)
assert _close(out[0], 0.256)
assert _close(out[1], 0.384)
assert _close(out[2], 0.884)
# Monotonically non-decreasing
assert out == sorted(out)
def test_zero_tps_step_skipped(self) -> None:
# A row with prompt_tps == 0 contributes nothing to the cumulative sum
steps = [
_step(pp=256, delta=256, prompt_tps=1024.0), # +0.25s
_step(pp=512, delta=256, prompt_tps=0.0), # +0
_step(pp=768, delta=256, prompt_tps=512.0), # +0.5s
]
out = _compute_t_cum(steps)
assert _close(out[0], 0.25)
assert _close(out[1], 0.25) # unchanged
assert _close(out[2], 0.75)
def test_zero_delta_step_skipped(self) -> None:
# Defensive: a Δ=0 row would otherwise add zero anyway, but we
# explicitly guard against negative delta + 0/0.
steps = [
_step(pp=256, delta=256, prompt_tps=1000.0),
_step(pp=256, delta=0, prompt_tps=1000.0), # explicit Δ=0
]
out = _compute_t_cum(steps)
assert _close(out[0], 0.256)
assert _close(out[1], 0.256)
# ---------------------------------------------------------------------------
# _interp
# ---------------------------------------------------------------------------
class TestInterp:
def test_empty_points_returns_zero(self) -> None:
assert _interp([], 100) == 0.0
def test_single_point_returns_y(self) -> None:
assert _interp([(100, 1.5)], 50) == 1.5
assert _interp([(100, 1.5)], 100) == 1.5
assert _interp([(100, 1.5)], 200) == 1.5
def test_clamps_below_first(self) -> None:
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
assert _interp(points, 0) == 0.1
assert _interp(points, 50) == 0.1
assert _interp(points, 100) == 0.1
def test_clamps_above_last(self) -> None:
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
assert _interp(points, 300) == 0.6
assert _interp(points, 500) == 0.6
assert _interp(points, 1_000_000) == 0.6
def test_mid_bracket_linear_interpolation(self) -> None:
points = [(100, 0.0), (200, 1.0)]
assert _close(_interp(points, 150), 0.5)
assert _close(_interp(points, 175), 0.75)
def test_multi_segment_linear_interpolation(self) -> None:
# Two adjacent segments, x=250 falls in the second one
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
# 200..300: 0.3 + (0.6-0.3) * (250-200)/(300-200) = 0.3 + 0.15 = 0.45
assert _close(_interp(points, 250), 0.45)
# ---------------------------------------------------------------------------
# derive_summary
# ---------------------------------------------------------------------------
def _gap_at(summary: dict[str, object], index: int) -> dict[str, float]:
"""Cast ``summary['control_gaps'][index]`` into the typed shape we expect."""
raw = summary["control_gaps"]
assert isinstance(raw, list)
entry = cast("dict[str, float]", raw[index])
return entry
class TestDeriveSummary:
def test_no_controls_only_t_cum(self) -> None:
steps = [
_step(pp=256, delta=256, prompt_tps=1024.0),
_step(pp=512, delta=256, prompt_tps=1024.0),
]
summary = derive_summary(steps, [])
t_cum = cast("list[float]", summary["t_cum_seconds"])
assert _close(t_cum[0], 0.25)
assert _close(t_cum[1], 0.5)
assert summary["control_gaps"] == []
def test_control_gap_at_known_pp(self) -> None:
# Sweep: 0.25s @ pp=256, 0.5s @ pp=512
steps = [
_step(pp=256, delta=256, prompt_tps=1024.0),
_step(pp=512, delta=256, prompt_tps=1024.0),
]
# Cold control at pp=512, 2x faster than the per-step rate -> 0.25s
controls = [_step(pp=512, delta=512, prompt_tps=2048.0, hit="none")]
summary = derive_summary(steps, controls)
gap = _gap_at(summary, 0)
assert gap["pp_tokens"] == 512
assert _close(gap["cold_t_seconds"], 0.25, abs_tol=0.01)
assert _close(gap["t_cum_seconds_at_pp"], 0.5, abs_tol=0.01)
assert _close(gap["gap_seconds"], 0.25, abs_tol=0.01)
# gap_fraction = 0.25 / 0.25 = 1.0
assert _close(gap["gap_fraction"], 1.0, abs_tol=0.01)
def test_control_gap_zero_cold_tps_yields_zero_fraction(self) -> None:
steps = [_step(pp=256, delta=256, prompt_tps=1000.0)]
controls = [_step(pp=256, delta=256, prompt_tps=0.0, hit="none")]
gap = _gap_at(derive_summary(steps, controls), 0)
assert gap["cold_t_seconds"] == 0.0
assert gap["gap_fraction"] == 0.0
+171
View File
@@ -0,0 +1,171 @@
"""Unit tests for ``bench.lib.model_meta``.
These exercise the pure derivation helpers (no HF round-trip). The HTTP
fetchers (``fetch_model_meta``, ``_read_config_json``, ``_sum_weight_sizes``)
hit the public hub and aren't covered here.
"""
from __future__ import annotations
import math
import pytest
from bench.lib.model_meta import (
ModelMeta,
derive_cold_controls,
derive_context_ramp,
)
def _meta(
*,
weight_bytes: int = 0,
max_pos: int = 4096,
layers: int = 32,
) -> ModelMeta:
return ModelMeta(
model_id="test/model",
total_weight_bytes=weight_bytes,
max_position_embeddings=max_pos,
num_hidden_layers=layers,
)
# ---------------------------------------------------------------------------
# ModelMeta properties
# ---------------------------------------------------------------------------
class TestModelMetaConstraints:
def test_zero_weight_yields_one_gib_floor(self) -> None:
meta = _meta(weight_bytes=0)
# int(0 * 1.30) + 1 == 1; int(0 * 1.10) + 1 == 1
assert meta.memory_constraint_gb == 1.0
assert meta.disk_constraint_gb == 1.0
def test_one_gib_weight_rounds_up(self) -> None:
meta = _meta(weight_bytes=1 * (1024**3))
# int(1.0 * 1.30) + 1 = 2; int(1.0 * 1.10) + 1 = 2
assert meta.memory_constraint_gb == 2.0
assert meta.disk_constraint_gb == 2.0
def test_sixteen_gib_weight_uses_30pct_memory_10pct_disk(self) -> None:
meta = _meta(weight_bytes=16 * (1024**3))
# memory: int(16 * 1.30) + 1 = 21; disk: int(16 * 1.10) + 1 = 18
assert meta.memory_constraint_gb == 21.0
assert meta.disk_constraint_gb == 18.0
def test_total_weight_gb_property(self) -> None:
meta = _meta(weight_bytes=2_147_483_648) # 2 GiB exactly
assert math.isclose(meta.total_weight_gb, 2.0)
# ---------------------------------------------------------------------------
# derive_context_ramp
# ---------------------------------------------------------------------------
class TestDeriveContextRamp:
def test_full_max_evenly_divides_round_to(self) -> None:
meta = _meta(max_pos=131072) # 128k
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
# 131072 // 32 = 4096; rounded down to multiple of 256 = 4096
assert pp_step == 4096
assert num_steps == 32
# Top of ramp == max
assert pp_step * num_steps == 131072
def test_qwen30b_a3b_ramp(self) -> None:
meta = _meta(max_pos=40960) # Qwen3-30B-A3B
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
# 40960 // 32 = 1280; multiple of 256
assert pp_step == 1280
assert pp_step * num_steps == 40960
def test_fraction_of_max_half(self) -> None:
meta = _meta(max_pos=131072)
pp_step, num_steps = derive_context_ramp(meta, num_steps=8, fraction_of_max=0.5)
# half = 65536; 65536 // 8 = 8192
assert pp_step == 8192
assert num_steps == 8
def test_min_pp_step_floor(self) -> None:
meta = _meta(max_pos=512)
# 512 // 32 = 16, but min_pp_step=256 floors it; rounded to 256
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
assert pp_step == 256
assert num_steps == 32
def test_round_to_truncates_down(self) -> None:
meta = _meta(max_pos=10000)
pp_step, _ = derive_context_ramp(meta, num_steps=32, round_to=256)
# 10000 // 32 = 312; (312 // 256) * 256 = 256
assert pp_step == 256
def test_round_to_zero_step_falls_back_to_round_to(self) -> None:
# Pathological: huge round_to relative to per-step size
meta = _meta(max_pos=1024)
pp_step, _ = derive_context_ramp(meta, num_steps=8, round_to=1024)
# 1024 // 8 = 128, but min_pp_step=256 → 256; (256 // 1024) * 1024 = 0;
# `or round_to` rescues to 1024.
assert pp_step == 1024
def test_max_pos_zero_raises(self) -> None:
meta = _meta(max_pos=0)
with pytest.raises(ValueError, match="max_position_embeddings=0"):
_ = derive_context_ramp(meta, num_steps=32)
@pytest.mark.parametrize("fraction", [0.0, -0.1, 1.5, 2.0])
def test_fraction_outside_unit_interval_raises(self, fraction: float) -> None:
meta = _meta(max_pos=4096)
with pytest.raises(ValueError, match="fraction_of_max"):
_ = derive_context_ramp(meta, num_steps=4, fraction_of_max=fraction)
@pytest.mark.parametrize("steps", [0, -1, -100])
def test_num_steps_must_be_positive(self, steps: int) -> None:
meta = _meta(max_pos=4096)
with pytest.raises(ValueError, match="num_steps"):
_ = derive_context_ramp(meta, num_steps=steps)
# ---------------------------------------------------------------------------
# derive_cold_controls
# ---------------------------------------------------------------------------
class TestDeriveColdControls:
def test_count_zero_returns_empty_tuple(self) -> None:
meta = _meta()
assert derive_cold_controls(meta, pp_step=4096, num_steps=32, count=0) == ()
def test_count_one_returns_top_only(self) -> None:
meta = _meta()
assert derive_cold_controls(meta, pp_step=4096, num_steps=32, count=1) == (
131072,
)
def test_evenly_spaced_four(self) -> None:
meta = _meta()
out = derive_cold_controls(meta, pp_step=4096, num_steps=32, count=4)
# max_pp = 131072; (131072 * (i+1)) // 4 for i in {0,1,2,3}
# = {32768, 65536, 98304, 131072}
assert out == (32768, 65536, 98304, 131072)
def test_filters_below_pp_step(self) -> None:
meta = _meta()
out = derive_cold_controls(meta, pp_step=8192, num_steps=2, count=4)
# max_pp = 16384; spaced points = {4096, 8192, 12288, 16384};
# 4096 < pp_step=8192 → dropped.
assert out == (8192, 12288, 16384)
def test_dedups_at_low_count_high_step(self) -> None:
meta = _meta()
# max_pp = 1024; count=2 → spaced = {512, 1024}; 512 < pp_step? No (=).
out = derive_cold_controls(meta, pp_step=512, num_steps=2, count=2)
assert out == (512, 1024)
def test_returned_in_ascending_order(self) -> None:
meta = _meta()
out = derive_cold_controls(meta, pp_step=1024, num_steps=8, count=4)
assert list(out) == sorted(out)
+189
View File
@@ -0,0 +1,189 @@
"""Smoke tests for ``bench.lib.plotting``.
Renders a synthetic benchmark JSON to a tmp PNG and verifies the file is
non-empty. We deliberately don't assert on pixel values — matplotlib
output isn't byte-stable across versions — but a non-empty PNG with a
valid header is a strong signal the renderer didn't throw.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, cast
import pytest
from bench.lib.plotting import PlotInputs, render_context_scaling
def _write_synthetic_run(path: Path, *, run_id: str, model: str = "test/model") -> None:
"""Write a minimal context-scaling-shaped JSON for plotting tests."""
payload = {
"metadata": {
"run_id": run_id,
"benchmark": "context_scaling",
"started_at": "2026-05-10T00:00:00Z",
"exo_sha": "deadbeef",
"hostname": "test-host",
"platform": "Linux 6.0 (x86_64)",
"tags": {"operator": "tester"},
},
"params": {
"pp_step": 256,
"num_steps": 4,
"tg": 32,
"warmup": 1,
"full_model_id": model,
},
"cluster": {},
"runs": [
{
"step_index": 0,
"phase": "cached_sweep",
"pp_tokens": 256,
"delta_tokens": 256,
"prompt_tps": 1800.0,
"generation_tps": 410.0,
"prefix_cache_hit": "exact",
"prompt_tokens": 256,
"generation_tokens": 32,
"elapsed_s": 0.14,
"peak_memory_bytes": 1_000_000_000,
"output_text_preview": "",
},
{
"step_index": 1,
"phase": "cached_sweep",
"pp_tokens": 512,
"delta_tokens": 256,
"prompt_tps": 2000.0,
"generation_tps": 395.0,
"prefix_cache_hit": "partial",
"prompt_tokens": 512,
"generation_tokens": 32,
"elapsed_s": 0.13,
"peak_memory_bytes": 1_100_000_000,
"output_text_preview": "",
},
{
"step_index": 2,
"phase": "cached_sweep",
"pp_tokens": 768,
"delta_tokens": 256,
"prompt_tps": 2200.0,
"generation_tps": 378.0,
"prefix_cache_hit": "partial",
"prompt_tokens": 768,
"generation_tokens": 32,
"elapsed_s": 0.12,
"peak_memory_bytes": 1_200_000_000,
"output_text_preview": "",
},
],
"cold_controls": [
{
"phase": "cold_control",
"pp_tokens": 512,
"delta_tokens": 512,
"prompt_tps": 3200.0,
"generation_tps": 400.0,
"prefix_cache_hit": "none",
"prompt_tokens": 512,
"generation_tokens": 32,
"elapsed_s": 0.16,
"peak_memory_bytes": 1_500_000_000,
"output_text_preview": "",
},
],
"derived": {
"t_cum_seconds": [0.14, 0.27, 0.39],
"control_gaps": [],
},
}
_ = path.write_text(json.dumps(payload))
def _png_is_valid(path: Path) -> bool:
"""A PNG file starts with the 8-byte magic ``\\x89PNG\\r\\n\\x1a\\n``."""
if not path.is_file():
return False
if path.stat().st_size < 100:
return False
head = path.read_bytes()[:8]
return head == b"\x89PNG\r\n\x1a\n"
# ---------------------------------------------------------------------------
class TestRenderContextScaling:
def test_single_run(self, tmp_path: Path) -> None:
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "out.png"
returned = render_context_scaling(PlotInputs(results=[json_path], output=out))
assert returned == out
assert _png_is_valid(out)
def test_creates_output_parent_dir(self, tmp_path: Path) -> None:
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "nested" / "deep" / "out.png"
_ = render_context_scaling(PlotInputs(results=[json_path], output=out))
assert _png_is_valid(out)
def test_comparison_two_runs(self, tmp_path: Path) -> None:
a = tmp_path / "a.json"
b = tmp_path / "b.json"
_write_synthetic_run(a, run_id="run-a", model="test/model-a")
_write_synthetic_run(b, run_id="run-b", model="test/model-b")
out = tmp_path / "compare.png"
_ = render_context_scaling(PlotInputs(results=[a, b], output=out))
assert _png_is_valid(out)
def test_label_tag_uses_metadata_tag(self, tmp_path: Path) -> None:
# Smoke test: just confirm passing label_tag doesn't throw and the
# PNG renders. Label content is too matplotlib-internal to inspect.
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "out.png"
_ = render_context_scaling(
PlotInputs(results=[json_path], output=out, label_tag="operator")
)
assert _png_is_valid(out)
def test_explicit_title(self, tmp_path: Path) -> None:
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "out.png"
_ = render_context_scaling(
PlotInputs(results=[json_path], output=out, title="Custom Title")
)
assert _png_is_valid(out)
def test_empty_results_raises(self, tmp_path: Path) -> None:
with pytest.raises(ValueError, match="at least one"):
_ = render_context_scaling(
PlotInputs(results=[], output=tmp_path / "out.png")
)
def test_wrong_benchmark_raises(self, tmp_path: Path) -> None:
# Same shape but with the wrong metadata.benchmark
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
raw_loaded: Any = json.loads(json_path.read_text()) # type: ignore[reportAny]
assert isinstance(raw_loaded, dict)
data = cast("dict[str, dict[str, str]]", raw_loaded)
data["metadata"]["benchmark"] = "something_else"
_ = json_path.write_text(json.dumps(data))
with pytest.raises(ValueError, match="context_scaling"):
_ = render_context_scaling(
PlotInputs(results=[json_path], output=tmp_path / "out.png")
)
+36
View File
@@ -0,0 +1,36 @@
# Prefill/Decode disaggregation benchmark config.
#
# Top-level keys are bench-wide. [prefill] and [decode] sections set per-side
# placement filters and (optionally) per-side model.
#
# Example:
# uv run python bench/prefill_decode_bench.py --config bench/prefill-decode.toml
host = "james"
port = 52415
timeout = 7200.0
settle_timeout = 60.0
# Workload
pp = [4096]
tg = [512]
repeat = 1
warmup = 0
json_out = "bench/prefill_decode_results.json"
[prefill]
model = "mlx-community/gpt-oss-20b-MXFP4-Q8"
node = "mike"
instance_meta = "ring"
sharding = "pipeline"
min_nodes = 1
max_nodes = 1
[decode]
model = "mlx-community/gpt-oss-20b-MXFP4-Q8"
node = "james"
instance_meta = "ring"
sharding = "pipeline"
min_nodes = 1
max_nodes = 1
+784
View File
@@ -0,0 +1,784 @@
# type: ignore
#!/usr/bin/env python3
"""Disaggregated prefill-decode benchmark for exo (MLX → MLX).
Spins up two MLX instances on the cluster, marks one as Prefill source and
the other as Decode target via /v1/instance-links, then sends chat
completions to the API. The master routes the request to the decode
instance and stamps `prefill_endpoint` pointing at the prefill instance
the worker decides per-request whether to ship prefill remotely
(uncached_count > REMOTE_PREFILL_MIN_TOKENS).
Usage:
uv run python bench/prefill_decode_bench.py --model <id> --pp 2048,8192 --tg 128
uv run python bench/prefill_decode_bench.py --model <id> --pp 4096 --tg 128 --repeat 3
uv run python bench/prefill_decode_bench.py --model <id> --pp 2048 --tg 128 --dry-run
"""
from __future__ import annotations
import argparse
import contextlib
import copy
import itertools
import json
import sys
import time
import tomllib
from pathlib import Path
from statistics import mean
from typing import Any
from exo_bench import (
PromptSizer,
format_peak_memory,
load_tokenizer_for_bench,
parse_int_list,
)
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
run_planning_phase,
settle_and_fetch_placements,
unwrap_instance,
wait_for_instance_gone,
wait_for_instance_ready,
)
from loguru import logger
def _node_id_to_friendly(client: ExoClient) -> dict[str, str]:
identities = client.get_node_identities() or {}
out: dict[str, str] = {}
for node_id, identity in identities.items():
if isinstance(identity, dict):
name = identity.get("friendlyName") or identity.get("friendly_name")
if isinstance(name, str):
out[str(node_id)] = name
return out
def _placement_node_friendly_names(
placement: dict[str, Any], id_to_friendly: dict[str, str]
) -> list[str]:
instance = placement["instance"]
return [id_to_friendly.get(nid, nid) for nid in node_ids_from_instance(instance)]
def _filter_by_node(
placements: list[dict[str, Any]],
friendly_name: str,
id_to_friendly: dict[str, str],
) -> list[dict[str, Any]]:
target = friendly_name.lower()
matched: list[dict[str, Any]] = []
for p in placements:
names = [n.lower() for n in _placement_node_friendly_names(p, id_to_friendly)]
if any(target == n or target in n for n in names):
matched.append(p)
return matched
def _node_id_by_friendly(id_to_friendly: dict[str, str], target: str) -> str | None:
target_lc = target.lower()
for nid, name in id_to_friendly.items():
if target_lc == name.lower() or target_lc in name.lower():
return nid
return None
def _load_toml(path: str) -> dict[str, Any]:
with Path(path).open("rb") as f:
return tomllib.load(f)
_TOP_LEVEL_TOML_KEYS = {
"host",
"port",
"timeout",
"settle_timeout",
"model",
"pp",
"tg",
"repeat",
"warmup",
"json_out",
"instance_meta",
"sharding",
"min_nodes",
"max_nodes",
"force_download",
"danger_delete_downloads",
"all_combinations",
}
def _inject_toml_into_argv() -> None:
"""If --config X is in sys.argv, pre-load it and inject required CLI args
(--model, --pp, --tg) so argparse's required=True checks pass."""
argv = sys.argv
if "--config" not in argv:
return
idx = argv.index("--config")
if idx + 1 >= len(argv):
return
cfg_path = argv[idx + 1]
cfg = _load_toml(cfg_path)
decode = cfg.get("decode", {})
def _has(flag: str) -> bool:
return any(a == flag or a.startswith(flag + "=") for a in argv)
# --model: prefer top-level, then [decode].model
if not _has("--model"):
model = cfg.get("model") or decode.get("model")
if model:
argv += ["--model", str(model)]
if not _has("--pp"):
pp = cfg.get("pp")
if pp:
argv += (
["--pp", *(str(x) for x in pp)]
if isinstance(pp, list)
else [
"--pp",
str(pp),
]
)
if not _has("--tg"):
tg = cfg.get("tg")
if tg:
argv += (
["--tg", *(str(x) for x in tg)]
if isinstance(tg, list)
else [
"--tg",
str(tg),
]
)
def _merge_toml_into_args(args: argparse.Namespace, cfg: dict[str, Any]) -> None:
"""Apply top-level toml keys onto args namespace where args has a default."""
for key, value in cfg.items():
if key in {"prefill", "decode"}:
continue
if key not in _TOP_LEVEL_TOML_KEYS:
continue
attr = key
current = getattr(args, attr, None)
if current in (None, [], False):
setattr(args, attr, value)
def _side_args(
base: argparse.Namespace, overrides: dict[str, Any]
) -> argparse.Namespace:
out = copy.copy(base)
for k in (
"instance_meta",
"sharding",
"min_nodes",
"max_nodes",
"skip_pipeline_jaccl",
"skip_tensor_ring",
):
if k in overrides:
setattr(out, k, overrides[k])
return out
def _pick_two_distinct_placements(
placements: list[dict[str, Any]],
) -> tuple[dict[str, Any], dict[str, Any]] | None:
if len(placements) < 2:
return None
seen_nodes: set[tuple[str, ...]] = set()
chosen: list[dict[str, Any]] = []
for p in placements:
nodes = tuple(sorted(str(n) for n in p.get("nodes", [])))
if nodes in seen_nodes:
continue
seen_nodes.add(nodes)
chosen.append(p)
if len(chosen) == 2:
return chosen[0], chosen[1]
return None
def _create_instance_link(
client: ExoClient,
prefill_instance_id: str,
decode_instance_id: str,
) -> str:
out = client.request_json(
"POST",
"/v1/instance-links",
body={
"prefill_instances": [prefill_instance_id],
"decode_instances": [decode_instance_id],
},
)
return str(out.get("commandId", ""))
def _list_instance_links(client: ExoClient) -> list[dict[str, Any]]:
out = client.request_json("GET", "/v1/instance-links")
return out if isinstance(out, list) else []
def _delete_instance_link(client: ExoClient, link_id: str) -> None:
client.request_json("DELETE", f"/v1/instance-links/{link_id}")
def run_one(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
) -> tuple[dict[str, Any], int]:
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
"model": model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"max_tokens": tg,
}
t0 = time.perf_counter()
out = client.post_bench_chat_completions(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 ""
preview = text[:200] if text else ""
return {
"elapsed_s": elapsed,
"output_text_preview": preview,
"stats": stats,
}, pp_tokens
def _run_phase(
*,
client: ExoClient,
label: str,
pp_tg_pairs: list[tuple[int, int]],
model_id: str,
prompt_sizer: PromptSizer,
warmup: int,
repeat: int,
common_meta: dict[str, Any],
) -> list[dict[str, Any]]:
logger.info(f"=== phase: {label} (model={model_id}) ===")
rows: list[dict[str, Any]] = []
for i in range(warmup):
run_one(client, model_id, pp_tg_pairs[0][0], pp_tg_pairs[0][1], prompt_sizer)
logger.debug(f" warmup {i + 1}/{warmup} done")
for pp, tg in pp_tg_pairs:
logger.info(f"--- {label}: pp={pp} tg={tg} ---")
runs: list[dict[str, Any]] = []
for r in range(repeat):
time.sleep(2)
try:
row, actual_pp_tokens = run_one(client, model_id, pp, tg, prompt_sizer)
except Exception as e:
logger.error(e)
continue
row.update(common_meta)
row.update(
{
"phase": label,
"phase_model_id": model_id,
"pp_tokens": actual_pp_tokens,
"tg": tg,
"repeat_index": r,
}
)
runs.append(row)
rows.append(row)
if runs:
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
gen_tps = mean(x["stats"]["generation_tps"] for x in runs)
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
peak = mean(x["stats"]["peak_memory_usage"]["inBytes"] for x in runs)
avg_elapsed = mean(x["elapsed_s"] for x in runs)
logger.info(
f"[{label}] prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
f"prompt_tokens={ptok} gen_tokens={gtok} "
f"peak_memory={format_peak_memory(peak)} "
f"avg_elapsed={avg_elapsed:.2f}s"
)
time.sleep(2)
return rows
def _summarise(rows: list[dict[str, Any]]) -> dict[tuple[int, int], dict[str, float]]:
grouped: dict[tuple[int, int], list[dict[str, Any]]] = {}
for r in rows:
key = (int(r["pp_tokens"]), int(r["tg"]))
grouped.setdefault(key, []).append(r)
out: dict[tuple[int, int], dict[str, float]] = {}
for key, runs in grouped.items():
out[key] = {
"prompt_tps": mean(x["stats"]["prompt_tps"] for x in runs),
"gen_tps": mean(x["stats"]["generation_tps"] for x in runs),
"elapsed_s": mean(x["elapsed_s"] for x in runs),
}
return out
def _print_diff(
disagg_rows: list[dict[str, Any]],
decode_alone_rows: list[dict[str, Any]],
prefill_alone_rows: list[dict[str, Any]],
) -> None:
disagg = _summarise(disagg_rows)
decode_alone = _summarise(decode_alone_rows)
prefill_alone = _summarise(prefill_alone_rows)
keys = set(disagg.keys()) | set(decode_alone.keys()) | set(prefill_alone.keys())
width = 64
for key in sorted(keys):
pp, tg = key
logger.info("" * width)
logger.info(f" pp={pp} tg={tg}")
logger.info("" * width)
logger.info(
f" {'phase':<16} {'elapsed':>10} {'prompt_tps':>11} {'gen_tps':>9}"
)
for label, summary in (
("disaggregated", disagg.get(key)),
("decode_alone", decode_alone.get(key)),
("prefill_alone", prefill_alone.get(key)),
):
if summary is None:
logger.info(f" {label:<16} {'':>10} {'':>11} {'':>9}")
continue
logger.info(
f" {label:<16} "
f"{summary['elapsed_s']:>9.2f}s "
f"{summary['prompt_tps']:>11.1f} "
f"{summary['gen_tps']:>9.2f}"
)
d = disagg.get(key)
da = decode_alone.get(key)
pa = prefill_alone.get(key)
if d and da and d["elapsed_s"] > 0:
logger.info(
f" speedup vs decode_alone: {da['elapsed_s'] / d['elapsed_s']:.2f}x"
)
if d and pa and d["elapsed_s"] > 0:
logger.info(
f" speedup vs prefill_alone: {pa['elapsed_s'] / d['elapsed_s']:.2f}x"
)
logger.info("" * width)
def main() -> int:
_inject_toml_into_argv()
ap = argparse.ArgumentParser(
prog="prefill-decode-bench",
description="Benchmark MLX-MLX disaggregated prefill/decode via instance links.",
)
add_common_instance_args(ap)
ap.add_argument(
"--pp",
nargs="+",
required=True,
help="Prompt-size hints (ints, must be >1000). Accepts commas.",
)
ap.add_argument(
"--tg",
nargs="+",
required=True,
help="Generation lengths (ints). Accepts commas.",
)
ap.add_argument(
"--repeat", type=int, default=1, help="Repetitions per (pp,tg) pair."
)
ap.add_argument(
"--warmup",
type=int,
default=0,
help="Warmup runs (uses first pp/tg).",
)
ap.add_argument(
"--json-out",
default="bench/prefill_decode_results.json",
help="Write raw per-run results JSON to this path.",
)
ap.add_argument("--stdout", action="store_true", help="Write results to stdout")
ap.add_argument(
"--dry-run", action="store_true", help="List selected placements and exit."
)
ap.add_argument(
"--all-combinations",
action="store_true",
help="Force all pp×tg combinations even when lists have equal length.",
)
ap.add_argument(
"--prefill-model",
default=None,
help="Model id for the prefill instance. Defaults to --model.",
)
ap.add_argument(
"--prefill-node",
default=None,
help="friendly_name of the node hosting the prefill instance.",
)
ap.add_argument(
"--decode-node",
default=None,
help="friendly_name of the node hosting the decode instance.",
)
ap.add_argument(
"--config",
default=None,
help="TOML config file. CLI flags override toml values.",
)
ap.add_argument(
"--compare-baseline",
action="store_true",
help="Also run each (pp,tg) pair without the prefill/decode link "
"(decode instance does its own prefill) and report the diff.",
)
args = ap.parse_args()
cfg = _load_toml(args.config) if args.config else {}
_merge_toml_into_args(args, cfg)
prefill_overrides = cfg.get("prefill", {}) if cfg else {}
decode_overrides = cfg.get("decode", {}) if cfg else {}
if args.prefill_model is None and "model" in prefill_overrides:
args.prefill_model = prefill_overrides["model"]
if args.prefill_node is None and "node" in prefill_overrides:
args.prefill_node = prefill_overrides["node"]
if args.decode_node is None and "node" in decode_overrides:
args.decode_node = decode_overrides["node"]
if "model" in decode_overrides and not args.model:
args.model = decode_overrides["model"]
pp_list = parse_int_list(args.pp)
tg_list = parse_int_list(args.tg)
if not pp_list or not tg_list:
logger.error("pp and tg lists must be non-empty")
return 2
for pp in pp_list:
if pp <= 1000:
logger.error(
f"pp={pp} must be >1000 (remote prefill triggers when uncached >1000)"
)
return 2
if args.repeat <= 0:
logger.error("--repeat must be >= 1")
return 2
use_combinations = args.all_combinations or len(pp_list) != len(tg_list)
if use_combinations:
logger.info(
f"pp/tg mode: combinations (product) — {len(pp_list) * len(tg_list)} pairs"
)
else:
logger.info(f"pp/tg mode: tandem (zip) — {len(pp_list)} pairs")
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
decode_short_id, decode_full_id = resolve_model_short_id(
client, args.model, force_download=args.force_download
)
if args.prefill_model:
prefill_short_id, prefill_full_id = resolve_model_short_id(
client, args.prefill_model, force_download=args.force_download
)
else:
prefill_short_id, prefill_full_id = decode_short_id, decode_full_id
tokenizer = load_tokenizer_for_bench(decode_full_id)
if tokenizer is None:
raise RuntimeError("[prefill-decode-bench] decode tokenizer load failed")
try:
decode_prompt_sizer = PromptSizer(tokenizer)
except Exception:
logger.error("[prefill-decode-bench] decode prompt sizing failed")
raise
if prefill_full_id == decode_full_id:
prefill_prompt_sizer = decode_prompt_sizer
else:
prefill_tokenizer = load_tokenizer_for_bench(prefill_full_id)
if prefill_tokenizer is None:
raise RuntimeError("[prefill-decode-bench] prefill tokenizer load failed")
prefill_prompt_sizer = PromptSizer(prefill_tokenizer)
id_to_friendly = _node_id_to_friendly(client)
prefill_args = _side_args(args, prefill_overrides)
decode_args = _side_args(args, decode_overrides)
if prefill_full_id == decode_full_id and prefill_overrides == decode_overrides:
placements = settle_and_fetch_placements(
client, decode_full_id, args, settle_timeout=args.settle_timeout
)
prefill_candidates = (
_filter_by_node(placements, args.prefill_node, id_to_friendly)
if args.prefill_node
else placements
)
decode_candidates = (
_filter_by_node(placements, args.decode_node, id_to_friendly)
if args.decode_node
else placements
)
if args.prefill_node and not prefill_candidates:
logger.error(f"No placement on prefill node {args.prefill_node!r}.")
return 1
if args.decode_node and not decode_candidates:
logger.error(f"No placement on decode node {args.decode_node!r}.")
return 1
if args.prefill_node and args.decode_node:
prefill_p = prefill_candidates[0]
decode_p = decode_candidates[0]
else:
pair = _pick_two_distinct_placements(placements)
if pair is None:
logger.error(
"Need at least two distinct-node MLX placements for the same model."
)
return 1
prefill_p, decode_p = pair
if args.prefill_node:
prefill_p = prefill_candidates[0]
if args.decode_node:
decode_p = decode_candidates[0]
else:
prefill_node_id = (
_node_id_by_friendly(id_to_friendly, args.prefill_node)
if args.prefill_node
else None
)
decode_node_id = (
_node_id_by_friendly(id_to_friendly, args.decode_node)
if args.decode_node
else None
)
if args.prefill_node and prefill_node_id is None:
logger.error(f"Unknown node {args.prefill_node!r}.")
return 1
if args.decode_node and decode_node_id is None:
logger.error(f"Unknown node {args.decode_node!r}.")
return 1
prefill_placements = settle_and_fetch_placements(
client,
prefill_full_id,
prefill_args,
settle_timeout=args.settle_timeout,
node_id=prefill_node_id,
)
decode_placements = settle_and_fetch_placements(
client,
decode_full_id,
decode_args,
settle_timeout=args.settle_timeout,
node_id=decode_node_id,
)
if not prefill_placements:
logger.error(
f"No placement found for prefill model {prefill_full_id}"
f"{f' on node {args.prefill_node!r}' if args.prefill_node else ''}."
)
return 1
if not decode_placements:
logger.error(
f"No placement found for decode model {decode_full_id}"
f"{f' on node {args.decode_node!r}' if args.decode_node else ''}."
)
return 1
prefill_p = prefill_placements[0]
decode_p = decode_placements[0]
prefill_node_names = _placement_node_friendly_names(prefill_p, id_to_friendly)
decode_node_names = _placement_node_friendly_names(decode_p, id_to_friendly)
_ = unwrap_instance
prefill_instance = prefill_p["instance"]
decode_instance = decode_p["instance"]
prefill_id = instance_id_from_instance(prefill_instance)
decode_id = instance_id_from_instance(decode_instance)
prefill_meta = str(prefill_p.get("instance_meta", ""))
decode_meta = str(decode_p.get("instance_meta", ""))
prefill_nodes = nodes_used_in_instance(prefill_instance)
decode_nodes = nodes_used_in_instance(decode_instance)
logger.info("=" * 80)
logger.info(
f"PREFILL: {prefill_meta} / nodes={prefill_nodes} ({','.join(prefill_node_names)}) "
f"/ {prefill_short_id} ({prefill_full_id}) / instance_id={prefill_id}"
)
logger.info(
f"DECODE: {decode_meta} / nodes={decode_nodes} ({','.join(decode_node_names)}) "
f"/ {decode_short_id} ({decode_full_id}) / instance_id={decode_id}"
)
if args.dry_run:
return 0
settle_deadline = (
time.monotonic() + args.settle_timeout if args.settle_timeout > 0 else None
)
logger.info("Planning phase: prefill...")
run_planning_phase(
client,
prefill_full_id,
prefill_p,
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
logger.info("Planning phase: decode...")
run_planning_phase(
client,
decode_full_id,
decode_p,
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
if use_combinations:
pp_tg_pairs = list(itertools.product(pp_list, tg_list))
else:
pp_tg_pairs = list(zip(pp_list, tg_list, strict=True))
common_meta = {
"decode_model_short_id": decode_short_id,
"decode_model_id": decode_full_id,
"prefill_model_short_id": prefill_short_id,
"prefill_model_id": prefill_full_id,
"prefill_instance_id": prefill_id,
"prefill_instance_meta": prefill_meta,
"prefill_nodes": prefill_nodes,
"decode_instance_id": decode_id,
"decode_instance_meta": decode_meta,
"decode_nodes": decode_nodes,
}
all_rows: list[dict[str, Any]] = []
disagg_rows: list[dict[str, Any]] = []
decode_alone_rows: list[dict[str, Any]] = []
prefill_alone_rows: list[dict[str, Any]] = []
link_id = ""
prefill_alive = False
decode_alive = False
try:
logger.info("Creating prefill instance...")
client.request_json("POST", "/instance", body={"instance": prefill_instance})
wait_for_instance_ready(client, prefill_id)
prefill_alive = True
logger.info("Prefill instance ready")
if args.compare_baseline:
time.sleep(2)
prefill_alone_rows = _run_phase(
client=client,
label="prefill_alone",
pp_tg_pairs=pp_tg_pairs,
model_id=prefill_full_id,
prompt_sizer=prefill_prompt_sizer,
warmup=args.warmup,
repeat=args.repeat,
common_meta=common_meta,
)
all_rows.extend(prefill_alone_rows)
logger.info("Creating decode instance...")
client.request_json("POST", "/instance", body={"instance": decode_instance})
wait_for_instance_ready(client, decode_id)
decode_alive = True
logger.info("Decode instance ready")
logger.info("Linking instances (prefill → decode)...")
_create_instance_link(client, prefill_id, decode_id)
time.sleep(1)
links = _list_instance_links(client)
if not links:
logger.error("Link did not appear in state.")
return 1
link_id = str(links[-1].get("linkId") or links[-1].get("link_id") or "")
logger.info(f"Link created: {link_id}")
time.sleep(2)
disagg_rows = _run_phase(
client=client,
label="disaggregated",
pp_tg_pairs=pp_tg_pairs,
model_id=decode_full_id,
prompt_sizer=decode_prompt_sizer,
warmup=args.warmup,
repeat=args.repeat,
common_meta=common_meta,
)
all_rows.extend(disagg_rows)
if args.compare_baseline:
logger.info("Removing link and prefill instance to isolate decode_alone.")
with contextlib.suppress(ExoHttpError):
if link_id:
_delete_instance_link(client, link_id)
link_id = ""
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{prefill_id}")
wait_for_instance_gone(client, prefill_id)
prefill_alive = False
time.sleep(2)
decode_alone_rows = _run_phase(
client=client,
label="decode_alone",
pp_tg_pairs=pp_tg_pairs,
model_id=decode_full_id,
prompt_sizer=decode_prompt_sizer,
warmup=args.warmup,
repeat=args.repeat,
common_meta=common_meta,
)
all_rows.extend(decode_alone_rows)
_print_diff(disagg_rows, decode_alone_rows, prefill_alone_rows)
finally:
with contextlib.suppress(ExoHttpError):
if link_id:
_delete_instance_link(client, link_id)
if decode_alive:
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{decode_id}")
wait_for_instance_gone(client, decode_id)
if prefill_alive:
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{prefill_id}")
wait_for_instance_gone(client, prefill_id)
logger.debug("Deleted both instances")
if args.stdout:
json.dump(all_rows, sys.stdout, indent=2, ensure_ascii=False)
elif args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(all_rows, f, indent=2, ensure_ascii=False)
logger.debug(f"\nWrote results JSON: {args.json_out}")
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"lm-eval[api,math]>=0.4.0",
"human-eval>=1.0.3",
"numpy>=1.24.0",
"matplotlib>=3.8",
]
[build-system]
@@ -1,5 +1,8 @@
<script lang="ts">
import { browser } from "$app/environment";
import { featureFlags } from "$lib/stores/app.svelte";
const showAdvanced = $derived(featureFlags()["disaggregation"] === true);
interface Props {
showHome?: boolean;
@@ -297,5 +300,28 @@
</svg>
<span class="hidden sm:inline">Integrations</span>
</a>
{#if showAdvanced}
<a
href="/#/advanced"
class="text-xs md:text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-1.5 md:gap-2 cursor-pointer"
title="Advanced cluster settings"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span class="hidden sm:inline">Advanced</span>
</a>
{/if}
</nav>
</header>
@@ -0,0 +1,565 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import FamilyLogos from "$lib/components/FamilyLogos.svelte";
import {
instances,
instanceLinks,
nodeIdentities,
refreshState,
createInstanceLink,
updateInstanceLink,
deleteInstanceLink,
type Instance,
} from "$lib/stores/app.svelte";
import { deriveBaseModel, deriveFamily } from "$lib/utils/model_family";
type InstanceWrapper = {
MlxRingInstance?: Instance;
MlxJacclInstance?: Instance;
VllmInstance?: Instance;
};
let interval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
refreshState();
interval = setInterval(refreshState, 3000);
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
type InstanceRow = {
id: string;
modelId: string;
family: string;
baseModel: string;
nodeNames: string[];
nodeCount: number;
};
const instanceRows = $derived.by<InstanceRow[]>(() => {
const rows: InstanceRow[] = [];
const ids = nodeIdentities();
for (const [id, raw] of Object.entries(instances())) {
const wrapper = raw as InstanceWrapper;
const inst =
wrapper.MlxRingInstance ??
wrapper.MlxJacclInstance ??
wrapper.VllmInstance;
const modelId = inst?.shardAssignments?.modelId ?? "";
const nodeToRunner = inst?.shardAssignments?.nodeToRunner ?? {};
const nodeIds = Object.keys(nodeToRunner);
const nodeNames = nodeIds
.map((nodeId) => ids[nodeId]?.friendlyName ?? nodeId.slice(0, 6))
.filter((name) => !!name);
rows.push({
id,
modelId,
family: deriveFamily(modelId),
baseModel: deriveBaseModel(modelId),
nodeNames,
nodeCount: nodeIds.length,
});
}
rows.sort((a, b) => a.modelId.localeCompare(b.modelId));
return rows;
});
const instanceById = $derived(
Object.fromEntries(instanceRows.map((r) => [r.id, r])),
);
type LinkRow = {
linkId: string;
prefill: string[];
decode: string[];
families: string[];
multiNode: boolean;
};
const linkRows = $derived.by<LinkRow[]>(() => {
const rows: LinkRow[] = [];
for (const [, link] of Object.entries(instanceLinks())) {
const fams = new Set<string>();
let multiNode = false;
for (const id of [...link.prefillInstances, ...link.decodeInstances]) {
const r = instanceById[id];
if (r && r.baseModel) fams.add(r.baseModel.toLowerCase());
if (r && r.nodeCount > 1) multiNode = true;
}
rows.push({
linkId: link.linkId,
prefill: link.prefillInstances,
decode: link.decodeInstances,
families: Array.from(fams),
multiNode,
});
}
return rows;
});
let editingLinkId = $state<string | null>(null);
let editingPrefill = $state<Set<string>>(new Set());
let editingDecode = $state<Set<string>>(new Set());
let saving = $state(false);
let errorMessage = $state<string | null>(null);
function startCreate() {
editingLinkId = "new";
editingPrefill = new Set();
editingDecode = new Set();
errorMessage = null;
}
function startEdit(row: LinkRow) {
editingLinkId = row.linkId;
editingPrefill = new Set(row.prefill);
editingDecode = new Set(row.decode);
errorMessage = null;
}
function cancelEdit() {
editingLinkId = null;
editingPrefill = new Set();
editingDecode = new Set();
errorMessage = null;
}
type Role = "prefill" | "decode" | "none";
function roleOf(id: string): Role {
if (editingPrefill.has(id)) return "prefill";
if (editingDecode.has(id)) return "decode";
return "none";
}
function setRole(id: string, role: Role) {
const p = new Set(editingPrefill);
const d = new Set(editingDecode);
p.delete(id);
d.delete(id);
if (role === "prefill") p.add(id);
if (role === "decode") d.add(id);
editingPrefill = p;
editingDecode = d;
}
const editingFamilies = $derived.by<string[]>(() => {
const fams = new Set<string>();
for (const id of [...editingPrefill, ...editingDecode]) {
const r = instanceById[id];
if (r && r.baseModel) fams.add(r.baseModel.toLowerCase());
}
return Array.from(fams);
});
const editingMultiNode = $derived.by<string[]>(() => {
const names: string[] = [];
for (const id of [...editingPrefill, ...editingDecode]) {
const r = instanceById[id];
if (r && r.nodeCount > 1) {
names.push(r.baseModel || r.modelId);
}
}
return names;
});
const editingMismatch = $derived(editingFamilies.length > 1);
const canSave = $derived(
editingLinkId !== null &&
editingPrefill.size > 0 &&
editingDecode.size > 0 &&
!saving,
);
async function save() {
if (editingLinkId === null) return;
saving = true;
errorMessage = null;
try {
const prefill = Array.from(editingPrefill);
const decode = Array.from(editingDecode);
if (editingLinkId === "new") {
await createInstanceLink(prefill, decode);
} else {
await updateInstanceLink(editingLinkId, prefill, decode);
}
cancelEdit();
await refreshState();
} catch (err) {
errorMessage = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
async function remove(linkId: string) {
if (!confirm("Remove this routing?")) return;
try {
await deleteInstanceLink(linkId);
if (editingLinkId === linkId) cancelEdit();
await refreshState();
} catch (err) {
errorMessage = err instanceof Error ? err.message : String(err);
}
}
</script>
<div class="font-mono text-foreground">
<div class="mb-6 space-y-4">
<details open class="group [&_summary::-webkit-details-marker]:hidden">
<summary
class="cursor-pointer list-none text-exo-yellow text-xs font-mono tracking-widest uppercase flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span
class="inline-block transition-transform group-open:rotate-90 text-exo-light-gray"
>▶</span
>
Prefill vs Decode
</summary>
<div class="mt-2 text-white/80 text-sm leading-relaxed">
Prefill is the compute-heavy pass that consumes the entire prompt and
builds a KV cache. Decode is the memory-bandwidth-bound loop that emits
tokens sequentially from that cache. The two phases have very different
bottlenecks, so running them on different hardware can be substantially
faster than doing both on one node.
</div>
</details>
<details class="group [&_summary::-webkit-details-marker]:hidden">
<summary
class="cursor-pointer list-none text-exo-yellow text-xs font-mono tracking-widest uppercase flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span
class="inline-block transition-transform group-open:rotate-90 text-exo-light-gray"
>▶</span
>
Linking Instances
</summary>
<div class="mt-2 text-white/80 text-sm leading-relaxed space-y-2">
<p>
A linked route here tells the cluster: when a request is sent to a
model in that cluster, the decode node (or the least active one if
there are multiple) will handle it. If it decides it must do a lot of
prefill not already cached in the prefix cache, it routes the request
to the prefill node over TCP IP. The prefill node streams the KV cache
back to the decode node which picks up from there.
</p>
<p>
Linked instances must be running the same model family — KV layouts
differ across architectures. More on the <a
class="text-exo-yellow underline underline-offset-2 hover:text-exo-yellow-darker transition-colors"
href="https://blog.exolabs.net/nvidia-dgx-spark/"
target="_blank"
rel="noreferrer noopener">blog</a
>.
</p>
</div>
</details>
</div>
{#if errorMessage}
<div
class="mb-4 px-4 py-3 bg-red-500/10 border border-red-500/40 text-red-300 text-sm"
>
{errorMessage}
</div>
{/if}
<section class="mt-12">
<h2
class="text-exo-yellow text-xs font-mono tracking-widest uppercase m-0 mb-3"
>
Existing routes
</h2>
{#if linkRows.length === 0}
{#if editingLinkId === null}
<div class="flex items-center justify-between">
<p class="text-exo-light-gray italic text-sm m-0">
No routes yet. Create one to enable remote prefill.
</p>
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-yellow/15 border border-exo-yellow/50 text-exo-yellow hover:bg-exo-yellow/25 hover:border-exo-yellow/80 transition-colors"
onclick={startCreate}
>
+ New route
</button>
</div>
{/if}
{:else}
{#if editingLinkId === null}
<div class="flex justify-end mb-3">
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-yellow/15 border border-exo-yellow/50 text-exo-yellow hover:bg-exo-yellow/25 hover:border-exo-yellow/80 transition-colors"
onclick={startCreate}
>
+ New route
</button>
</div>
{/if}
<div
class="bg-exo-dark-gray/60 border border-exo-medium-gray/40 flex flex-col"
>
{#each linkRows as row (row.linkId)}
{#if editingLinkId !== row.linkId}
<article
class="p-4 border-b border-exo-light-gray/25 last:border-b-0"
>
{#if row.multiNode}
<div
class="mb-3 px-3 py-2 bg-red-500/10 border border-red-500/40 text-red-300 text-xs tracking-wide"
>
⚠ Multi-node instance detected. Remote prefill currently only
works on single-node (rank-0) instances. This route will not
function until that's supported.
</div>
{/if}
{#if row.families.length > 1}
<div
class="mb-3 px-3 py-2 bg-amber-500/10 border border-amber-500/40 text-amber-300 text-xs tracking-wide"
>
⚠ Mixed model families: {row.families.join(", ")}
</div>
{/if}
<div
class="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-x-3 gap-y-2"
>
<span
class="inline-block justify-self-start text-[10px] font-mono tracking-widest uppercase px-2 py-0.5 bg-exo-yellow/15 border border-exo-yellow/40 text-exo-yellow"
>Prefill</span
>
<span></span>
<span
class="inline-block justify-self-start text-[10px] font-mono tracking-widest uppercase px-2 py-0.5 bg-exo-medium-gray/40 border border-exo-medium-gray/60 text-foreground"
>Decode</span
>
<span></span>
<div class="min-w-0">
<ul class="list-none p-0 m-0 flex flex-col gap-2">
{#each row.prefill as id (id)}
{@const r = instanceById[id]}
{#if r}
<li
class="flex items-center gap-2 px-2.5 py-2 bg-exo-medium-gray/20 border border-exo-medium-gray/40"
>
<FamilyLogos family={r.family} />
<div class="min-w-0 flex-1">
<div
class="text-exo-yellow text-xs font-mono truncate"
>
{r.baseModel || r.modelId}
</div>
<div
class="text-exo-light-gray text-[11px] truncate"
>
{r.nodeNames.join(", ") || "?"}{r.nodeCount > 1
? ` (${r.nodeCount} nodes)`
: ""}
</div>
<div
class="text-exo-light-gray/40 text-[10px] font-mono truncate"
title={r.id}
>
{r.id.slice(0, 8)}
</div>
</div>
</li>
{/if}
{/each}
</ul>
</div>
<div class="text-exo-yellow/60 text-xl px-2" aria-hidden="true">
</div>
<div class="min-w-0">
<ul class="list-none p-0 m-0 flex flex-col gap-2">
{#each row.decode as id (id)}
{@const r = instanceById[id]}
{#if r}
<li
class="flex items-center gap-2 px-2.5 py-2 bg-exo-medium-gray/20 border border-exo-medium-gray/40"
>
<FamilyLogos family={r.family} />
<div class="min-w-0 flex-1">
<div
class="text-exo-yellow text-xs font-mono truncate"
>
{r.baseModel || r.modelId}
</div>
<div
class="text-exo-light-gray text-[11px] truncate"
>
{r.nodeNames.join(", ") || "?"}{r.nodeCount > 1
? ` (${r.nodeCount} nodes)`
: ""}
</div>
<div
class="text-exo-light-gray/40 text-[10px] font-mono truncate"
title={r.id}
>
{r.id.slice(0, 8)}
</div>
</div>
</li>
{/if}
{/each}
</ul>
</div>
<div class="flex gap-2 pl-3">
<button
class="px-2 py-0.5 text-[11px] font-mono tracking-wider uppercase bg-exo-medium-gray/30 border border-exo-medium-gray/60 rounded text-foreground hover:border-exo-yellow/60 hover:text-exo-yellow disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
onclick={() => startEdit(row)}
disabled={editingLinkId !== null}
>
Edit
</button>
<button
class="px-2 py-0.5 text-[11px] font-mono tracking-wider uppercase bg-red-500/15 border border-red-500/40 rounded text-red-300 hover:bg-red-500/25 transition-colors"
onclick={() => remove(row.linkId)}
>
Remove
</button>
</div>
</div>
</article>
{/if}
{/each}
</div>
{/if}
</section>
{#if editingLinkId !== null && instanceRows.length === 0}
<section
class="mt-6 bg-exo-dark-gray/60 border border-exo-yellow/30 px-4 py-2.5 flex items-center justify-between gap-3"
>
<span class="text-exo-light-gray italic text-sm font-mono"
>No instances available.</span
>
<button
class="px-3 py-1 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 border border-exo-medium-gray/60 rounded text-foreground hover:border-exo-yellow/60 transition-colors"
onclick={cancelEdit}
>
Cancel
</button>
</section>
{:else if editingLinkId !== null}
<section class="mt-6 bg-exo-dark-gray/60 border border-exo-yellow/30 p-5">
<h2
class="text-exo-yellow text-xs font-mono tracking-widest uppercase m-0 mb-3"
>
{editingLinkId === "new" ? "New route" : "Edit route"}
</h2>
{#if editingMismatch}
<div
class="mb-3 px-3 py-2 bg-amber-500/10 border border-amber-500/40 text-amber-300 text-xs tracking-wide"
>
⚠ Selected instances span multiple model families: <strong
>{editingFamilies.join(", ")}</strong
>. Linking across families produces a corrupt KV cache.
</div>
{/if}
{#if editingMultiNode.length > 0}
<div
class="mb-3 px-3 py-2 bg-red-500/10 border border-red-500/40 text-red-300 text-xs tracking-wide"
>
⚠ Multi-node instance(s) selected: <strong
>{editingMultiNode.join(", ")}</strong
>. Remote prefill currently only works on single-node instances. This
route will not function until multi-node support lands.
</div>
{/if}
<p class="text-exo-light-gray text-xs mb-4">
Pick a role for each instance:
<span class="text-exo-yellow">Prefill</span>
serves KV cache,
<span class="text-foreground">Decode</span> consumes it.
</p>
<div
class="grid gap-2.5"
style="grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));"
>
{#each instanceRows as row (row.id)}
{@const role = roleOf(row.id)}
<div
class="border p-3 flex flex-col gap-2.5 transition-colors {role ===
'prefill'
? 'border-exo-yellow/60 bg-exo-dark-gray/60'
: role === 'decode'
? 'border-exo-light-gray/60 bg-exo-dark-gray/60'
: 'border-exo-medium-gray/40 bg-exo-dark-gray/40'}"
>
<div class="flex items-center gap-2">
<FamilyLogos family={row.family} />
<div class="min-w-0 flex-1">
<div class="text-exo-yellow text-xs font-mono truncate">
{row.baseModel || row.modelId}
</div>
<div class="text-exo-light-gray text-[11px] truncate">
{row.nodeNames.join(", ") || "?"}{row.nodeCount > 1
? ` (${row.nodeCount} nodes)`
: ""}
</div>
<div
class="text-exo-light-gray/40 text-[10px] font-mono truncate"
title={row.id}
>
{row.id.slice(0, 8)}
</div>
</div>
{#if row.nodeCount > 1}
<span
class="text-[9px] font-mono tracking-widest uppercase px-1.5 py-0.5 bg-red-500/15 border border-red-500/40 text-red-300"
title="Multi-node instances are not supported by remote prefill yet."
>Unsupported</span
>
{/if}
</div>
<div
class="flex rounded-md overflow-hidden border border-exo-light-gray/40 divide-x divide-exo-light-gray/40"
>
<button
class="flex-1 px-2 py-1 text-[11px] font-mono tracking-wider uppercase transition-colors {role ===
'prefill'
? 'bg-exo-yellow/20 text-exo-yellow'
: 'bg-transparent text-white/80 hover:text-exo-yellow'}"
onclick={() =>
setRole(row.id, role === "prefill" ? "none" : "prefill")}
>Prefill</button
>
<button
class="flex-1 px-2 py-1 text-[11px] font-mono tracking-wider uppercase transition-colors {role ===
'decode'
? 'bg-exo-medium-gray/50 text-foreground'
: 'bg-transparent text-white/80 hover:text-foreground'}"
onclick={() =>
setRole(row.id, role === "decode" ? "none" : "decode")}
>Decode</button
>
</div>
</div>
{/each}
</div>
<div class="flex gap-2 mt-5 justify-end">
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-yellow/15 border border-exo-yellow/50 text-exo-yellow hover:bg-exo-yellow/25 hover:border-exo-yellow/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
onclick={save}
disabled={!canSave}
>
{saving ? "Saving..." : "Save route"}
</button>
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 border border-exo-medium-gray/60 text-foreground hover:border-exo-yellow/60 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
onclick={cancelEdit}
disabled={saving}
>
Cancel
</button>
</div>
</section>
{/if}
</div>
+128 -10
View File
@@ -74,6 +74,12 @@ export interface Instance {
};
}
export interface RawInstanceLink {
linkId: string;
prefillInstances: string[];
decodeInstances: string[];
}
// Granular node state types from the new state structure
interface RawNodeIdentity {
modelId?: string;
@@ -223,6 +229,7 @@ interface RawStateResponse {
}
>;
runners?: Record<string, unknown>;
instanceLinks?: Record<string, RawInstanceLink>;
downloads?: Record<string, unknown[]>;
// New granular node state fields
nodeIdentities?: Record<string, RawNodeIdentity>;
@@ -541,6 +548,8 @@ class AppStore {
topologyData = $state<TopologyData | null>(null);
instances = $state<Record<string, unknown>>({});
runners = $state<Record<string, unknown>>({});
instanceLinks = $state<Record<string, RawInstanceLink>>({});
featureFlags = $state<Record<string, boolean>>({});
downloads = $state<Record<string, unknown[]>>({});
nodeDisk = $state<
Record<
@@ -1274,6 +1283,7 @@ class AppStore {
startPolling() {
this.fetchState();
this.fetchFeatureFlags();
this.fetchInterval = setInterval(() => this.fetchState(), 1000);
}
@@ -1285,6 +1295,16 @@ class AppStore {
this.stopPreviewsPolling();
}
async fetchFeatureFlags() {
try {
const response = await fetch("/v1/feature-flags");
if (!response.ok) return;
this.featureFlags = await response.json();
} catch {
// Silently ignore — defaults to all-disabled.
}
}
async fetchState() {
try {
const response = await fetch("/state");
@@ -1310,6 +1330,11 @@ class AppStore {
if (data.runners) {
this.runners = data.runners;
}
if (data.instanceLinks) {
this.instanceLinks = data.instanceLinks;
} else {
this.instanceLinks = {};
}
if (data.downloads) {
this.downloads = data.downloads;
}
@@ -1670,7 +1695,15 @@ class AppStore {
}
}
}
return { role: m.role, content: msgContent };
const out: {
role: string;
content: string;
reasoning_content?: string;
} = { role: m.role, content: msgContent };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}),
];
@@ -1877,7 +1910,15 @@ class AppStore {
const apiMessages = [
systemPrompt,
...targetConversation.messages.slice(0, -1).map((m) => {
return { role: m.role, content: m.content };
const out: {
role: string;
content: string;
reasoning_content?: string;
} = { role: m.role, content: m.content };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}),
];
@@ -2408,10 +2449,15 @@ class AppStore {
contentParts.push({ type: "text", text: textContent });
}
return {
role: m.role,
content: contentParts,
};
const out: {
role: string;
content: typeof contentParts;
reasoning_content?: string;
} = { role: m.role, content: contentParts };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}
// Text-only message (original path)
@@ -2429,10 +2475,15 @@ class AppStore {
}
}
return {
role: m.role,
content: msgContent,
};
const out: {
role: string;
content: string;
reasoning_content?: string;
} = { role: m.role, content: msgContent };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}),
];
@@ -3281,6 +3332,60 @@ class AppStore {
}
}
async createInstanceLink(
prefillInstances: string[],
decodeInstances: string[],
): Promise<void> {
const response = await fetch("/v1/instance-links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prefill_instances: prefillInstances,
decode_instances: decodeInstances,
}),
});
if (!response.ok) {
throw new Error(
`Failed to create instance link: ${response.status} ${await response.text()}`,
);
}
}
async updateInstanceLink(
linkId: string,
prefillInstances: string[],
decodeInstances: string[],
): Promise<void> {
const response = await fetch(
`/v1/instance-links/${encodeURIComponent(linkId)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prefill_instances: prefillInstances,
decode_instances: decodeInstances,
}),
},
);
if (!response.ok) {
throw new Error(
`Failed to update instance link: ${response.status} ${await response.text()}`,
);
}
}
async deleteInstanceLink(linkId: string): Promise<void> {
const response = await fetch(
`/v1/instance-links/${encodeURIComponent(linkId)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw new Error(
`Failed to delete instance link: ${response.status} ${await response.text()}`,
);
}
}
/**
* Delete a downloaded model from a specific node
*/
@@ -3379,6 +3484,19 @@ export const prefillProgress = () => appStore.prefillProgress;
export const topologyData = () => appStore.topologyData;
export const instances = () => appStore.instances;
export const runners = () => appStore.runners;
export const instanceLinks = () => appStore.instanceLinks;
export const featureFlags = () => appStore.featureFlags;
export const createInstanceLink = (
prefillInstances: string[],
decodeInstances: string[],
) => appStore.createInstanceLink(prefillInstances, decodeInstances);
export const updateInstanceLink = (
linkId: string,
prefillInstances: string[],
decodeInstances: string[],
) => appStore.updateInstanceLink(linkId, prefillInstances, decodeInstances);
export const deleteInstanceLink = (linkId: string) =>
appStore.deleteInstanceLink(linkId);
export const downloads = () => appStore.downloads;
export const nodeDisk = () => appStore.nodeDisk;
export const placementPreviews = () => appStore.placementPreviews;
+44
View File
@@ -0,0 +1,44 @@
// Mirrors src/exo/shared/models/model_cards.py:derive_base_model
const QUANT_SUFFIXES = new RegExp(
"[-_ ](?:MLX|MXFP[0-9]+|NVFP[0-9]+|GPTQ|AWQ|GGUF|fp16|bf16|fp8|int[0-9]+|[0-9]+(?:\\.[0-9]+)?bit|Q[0-9]+(?:_[A-Z0-9]+)?|gs[0-9]+)" +
"(?:[-_ ](?:MLX|Q[0-9]+|Int[0-9]+|[A-Z0-9]+|gs[0-9]+))*$",
"i",
);
function normalize(s: string): string {
return s
.replaceAll("-", " ")
.replaceAll("_", " ")
.replaceAll(" ", " ")
.trim();
}
export function deriveBaseModel(modelId: string): string {
const short = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
const stripped = short.replace(QUANT_SUFFIXES, "");
return normalize(stripped);
}
export function baseModelsCompatible(a: string, b: string): boolean {
return deriveBaseModel(a).toLowerCase() === deriveBaseModel(b).toLowerCase();
}
// Mirrors src/exo/shared/models/model_cards.py:derive_family
export function deriveFamily(modelId: string): string {
const short = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
const stripped = short
.replace(QUANT_SUFFIXES, "")
.toLowerCase()
.replaceAll("_", "-");
const parts = stripped.split(/[-.]/);
const familyParts: string[] = [];
for (const p of parts) {
if (/^\d+$/.test(p) || /^\d+[bm]?$/i.test(p)) break;
familyParts.push(p);
}
return familyParts.length > 0 ? familyParts.join("-") : stripped;
}
+3
View File
@@ -3435,6 +3435,7 @@
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code class="text-yellow-300 bg-yellow-400/10 px-1 rounded"
@@ -4822,6 +4823,7 @@
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code class="text-yellow-300 bg-yellow-400/10 px-1 rounded"
@@ -4968,6 +4970,7 @@
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code
@@ -0,0 +1,81 @@
<script lang="ts">
import { browser } from "$app/environment";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import PrefillDecodeDisaggregation from "$lib/components/PrefillDecodeDisaggregation.svelte";
import { featureFlags, refreshState } from "$lib/stores/app.svelte";
import { onMount } from "svelte";
type TabId = "prefill-decode";
const tabs: { id: TabId; label: string }[] = [
{ id: "prefill-decode", label: "Prefill / Decode" },
];
let activeTab = $state<TabId>(tabs[0].id);
let flagsLoaded = $state(false);
onMount(() => {
refreshState().finally(() => {
flagsLoaded = true;
});
});
const flags = $derived(featureFlags());
const enabled = $derived(flags["disaggregation"] === true);
$effect(() => {
if (browser && flagsLoaded && !enabled) {
// No advanced features enabled — bounce home.
window.location.hash = "/";
}
});
</script>
<div class="min-h-screen bg-exo-dark-gray flex flex-col">
<HeaderNav />
<main class="flex-1 max-w-[1100px] mx-auto w-full px-4 md:px-6 py-8">
{#if !flagsLoaded}
<div class="text-exo-light-gray/60 text-sm">Loading…</div>
{:else if !enabled}
<div class="text-exo-light-gray/60 text-sm">
No advanced features enabled. Set <code
class="text-exo-yellow font-mono">ENABLE_DISAGGREGATION=true</code
> on the cluster to access prefill/decode disaggregation.
</div>
{:else}
<div class="mb-4">
<h1
class="text-white text-xl md:text-2xl font-semibold tracking-wide mb-2"
>
Advanced
</h1>
<p class="text-exo-light-gray/60 text-sm">
Cluster-level configuration. Most users don't need anything here.
</p>
</div>
<div
class="flex flex-wrap gap-2 mb-6 border-b border-exo-light-gray/10 pb-3"
>
{#each tabs as tab (tab.id)}
<button
onclick={() => (activeTab = tab.id)}
class="px-3 py-1.5 text-xs rounded-md transition-all cursor-pointer
{activeTab === tab.id
? 'bg-exo-yellow/15 text-exo-yellow border border-exo-yellow/30'
: 'text-exo-light-gray/60 hover:text-white/80 border border-transparent hover:border-exo-light-gray/20'}"
>
{tab.label}
</button>
{/each}
</div>
<div class="space-y-4">
{#if activeTab === "prefill-decode"}
<PrefillDecodeDisaggregation />
{/if}
</div>
{/if}
</main>
</div>
+112 -1
View File
@@ -14,6 +14,7 @@
let modelCapabilities = $state<Record<string, string[]>>({});
let modelContextLengths = $state<Record<string, number>>({});
let modelReasoningDialects = $state<Record<string, string>>({});
const runningModels = $derived.by(() => {
const models: string[] = [];
@@ -88,10 +89,12 @@
let codexModel = $state("");
let codexMcpPath = $state("/Users/username");
let openClawModel = $state("");
let piModel = $state("");
$effect(() => {
const def = modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id";
codexModel = def;
openClawModel = def;
piModel = def;
});
const claudeShellCommand = $derived(
@@ -130,6 +133,7 @@
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const dialect = modelReasoningDialects[modelId];
const entry: Record<string, unknown> = { name: modelId };
if (ctxLen > 0) {
entry.limit = { context: ctxLen, output: Math.min(ctxLen, 16384) };
@@ -137,6 +141,27 @@
if (caps.includes("vision")) {
entry.modalities = { input: ["text", "image"], output: ["text"] };
}
// Reasoning round-trip: opencode's `interleaved` field tells the
// openai-compatible adapter to send the assistant's prior
// reasoning_content back in subsequent turns. Emit it for dialects
// whose chat templates use prior reasoning:
// - `tool_conditional` (DeepSeek V3.2 / V4): wrapper preserves all
// reasoning when tools are present.
// - `post_last_user` (Qwen3-Thinking, GLM 4.5+, MiniMax M2.x):
// Jinja template reads reasoning_content for assistant turns since
// the last user message — exactly the tool-chain window.
// - `channel` (gpt-oss / Harmony): the model's Jinja template reads
// `message.thinking` rather than `message.reasoning_content`, but
// the server bridges `reasoning_content` → `thinking` before
// rendering, so the round-trip works through the standard field.
// `suffix` (Kimi): reasoning lives in content; no separate field path.
if (
dialect === "tool_conditional" ||
dialect === "post_last_user" ||
dialect === "channel"
) {
entry.interleaved = { field: "reasoning_content" };
}
models[modelId] = entry;
}
if (Object.keys(models).length === 0) {
@@ -218,6 +243,55 @@
),
);
const piModelsJson = $derived.by(() => {
const models: Record<string, unknown>[] = [];
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const entry: Record<string, unknown> = { id: modelId };
if (caps.includes("vision")) {
entry.input = ["text", "image"];
}
// Mark thinking-capable models so pi surfaces its thinking-level selector
// for them. exo capability strings: "thinking" (model emits reasoning
// content) and "thinking_toggle" (user can turn it on/off).
if (caps.includes("thinking") || caps.includes("thinking_toggle")) {
entry.reasoning = true;
}
if (ctxLen > 0) {
entry.contextWindow = ctxLen;
}
models.push(entry);
}
if (models.length === 0) {
models.push({ id: "your-model-id" });
}
return JSON.stringify(
{
providers: {
exo: {
baseUrl: `${apiUrl}/v1`,
api: "openai-completions",
apiKey: "exo",
compat: {
supportsDeveloperRole: false,
// exo's OpenAI surface takes a boolean `enable_thinking` toggle,
// not graded effort levels, so disable pi's `reasoning_effort`
// parameter and use the matching top-level-boolean format.
supportsReasoningEffort: false,
thinkingFormat: "qwen",
},
models,
},
},
},
null,
2,
);
});
const piShellCommand = $derived(`pi --provider exo --model ${piModel}`);
const ollamaCommand = $derived(
`OLLAMA_HOST=${apiUrl}/ollama ollama run ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"}`,
);
@@ -277,6 +351,7 @@
"OpenCode",
"Codex",
"OpenClaw",
"Pi",
"Open WebUI",
"n8n",
"Firefox",
@@ -298,16 +373,25 @@
try {
const resp = await fetch("/v1/models");
const data = (await resp.json()) as {
data: { id: string; capabilities: string[]; context_length: number }[];
data: {
id: string;
capabilities: string[];
context_length: number;
reasoning_dialect?: string;
}[];
};
const caps: Record<string, string[]> = {};
const ctxs: Record<string, number> = {};
const dialects: Record<string, string> = {};
for (const model of data.data) {
caps[model.id] = model.capabilities || [];
if (model.context_length > 0) ctxs[model.id] = model.context_length;
if (model.reasoning_dialect)
dialects[model.id] = model.reasoning_dialect;
}
modelCapabilities = caps;
modelContextLengths = ctxs;
modelReasoningDialects = dialects;
} catch {
/* ignore */
}
@@ -515,6 +599,33 @@
config={`openclaw doctor --fix${(modelCapabilities[openClawModel] || []).includes("vision") ? `\nopenclaw models set-image exo/${openClawModel}` : ""}\nopenclaw gateway &\nopenclaw dashboard`}
language="bash"
/>
{:else if activeTab === "Pi"}
{#if runningModels.length > 1}
<div class="text-xs">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={piModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<IntegrationCard
title="Models Config"
subtitle="~/.pi/agent/models.json"
description="Register exo as a custom provider in pi. Create or edit this file, then run pi and pick an exo model via /model. Install pi with: npm install -g @mariozechner/pi-coding-agent"
config={piModelsJson}
/>
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch pi directly with the exo provider and model selected."
config={piShellCommand}
language="bash"
/>
{:else if activeTab === "Open WebUI"}
<IntegrationCard
title="1. Start Open WebUI"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "exo",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+40 -14
View File
@@ -3,7 +3,7 @@ name = "exo"
version = "0.3.70"
description = "Exo"
readme = "README.md"
requires-python = ">=3.13"
requires-python = "==3.13.*"
dependencies = [
"aiofiles>=24.1.0",
"aiohttp>=3.12.14",
@@ -15,11 +15,11 @@ dependencies = [
"huggingface-hub>=1.8.0",
"psutil>=7.0.0",
"loguru>=0.7.3",
"exo-pyo3-bindings", # rust bindings
"exo-pyo3-bindings", # rust bindings
"anyio==4.11.0",
"mlx==0.31.1; sys_platform == 'darwin'",
"mlx-lm",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"mlx==0.31.2; sys_platform == 'darwin'",
"mlx-lm; sys_platform=='darwin'",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0",
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
@@ -28,8 +28,8 @@ dependencies = [
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
"mlx-vlm>=0.3.11",
"transformers>=5.0.0,<5.4.0",
"mlx-vlm>=0.3.11; sys_platform == 'darwin'",
"transformers>=5.6.2",
]
[project.scripts]
@@ -40,6 +40,7 @@ exo = "exo.main:main"
dev = [
"basedpyright>=1.29.0",
"pyinstaller>=6.17.0",
"playwright>=1.52.0",
"pytest>=8.4.0",
"pytest-asyncio>=1.0.0",
"pytest-env",
@@ -49,15 +50,24 @@ dev = [
[project.optional-dependencies]
build = ["nanobind"]
cpu = [
"mlx==0.31.1; sys_platform == 'linux'",
"mlx-cpu==0.31.1; sys_platform == 'linux'",
"mlx-lm; sys_platform == 'linux'",
"mlx-vlm>=0.3.11; sys_platform== 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
cuda12 = [
"mlx==0.31.1; sys_platform == 'linux'",
"mlx-cuda-12==0.31.1; sys_platform == 'linux'",
"mlx-lm; sys_platform == 'linux'",
"mlx-vlm>=0.3.11; sys_platform== 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
cuda13 = [
"mlx==0.31.1; sys_platform == 'linux'",
"mlx-cuda-13==0.31.1; sys_platform == 'linux'",
"mlx-lm; sys_platform == 'linux'",
"mlx-vlm>=0.3.11; sys_platform== 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
@@ -66,16 +76,16 @@ cuda13 = [
###
[tool.uv.workspace]
members = ["rust/exo_pyo3_bindings", "bench"]
members = ["rust/exo_pyo3_bindings", "bench", "tools"]
[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/fix-arrayscache-leak" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/deepseek-v4" }
torch = [
{ index = "pytorch-cu130", marker = "sys_platform == 'linux' and extra == 'cuda13' and extra != 'cpu' and extra != 'cuda12'" },
{ index = "pytorch-cu120", marker = "sys_platform == 'linux' and extra == 'cuda12' and extra != 'cpu' and extra != 'cuda13'" },
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'cpu' and extra != 'cuda12' and extra != 'cuda13'" },
{ index = "pytorch-cpu", marker = "(extra != 'cuda12' and extra != 'cuda13' and sys_platform == 'linux') or sys_platform == 'darwin'" },
]
vllm = { git = "https://github.com/hmellor/vllm.git", branch = "transformers-v5" }
@@ -103,7 +113,7 @@ build-backend = "uv_build"
###
[tool.basedpyright]
include = ["src", "bench"]
include = ["src", "bench", "tools"]
typeCheckingMode = "strict"
failOnWarnings = true
@@ -137,6 +147,15 @@ reportMissingModuleSource = false
[[tool.basedpyright.executionEnvironments]]
root = "src"
[[tool.basedpyright.executionEnvironments]]
root = "bench"
# `.` keeps `from bench.lib.X import …` resolvable (pytest adds the project
# root to sys.path; we want type-checking to agree with runtime).
extraPaths = ["tools/src", "."]
[[tool.basedpyright.executionEnvironments]]
root = "tools/src"
###
# uv configuration
@@ -148,7 +167,11 @@ required-version = ">=0.8.6"
prerelease = "allow"
environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
conflicts = [[{ extra = "cuda12" }, { extra = "cuda13" }, { extra = "cpu" }]]
constraint-dependencies = ["transformers>=5.0.0,<5.4.0"]
constraint-dependencies = ["transformers>=5.6.2"]
override-dependencies = [
"mlx==0.31.1; sys_platform=='linux'",
"mlx; sys_platform=='darwin'",
]
[tool.uv.extra-build-dependencies]
miniaudio = ["setuptools", "cffi", "pycparser"]
@@ -203,9 +226,12 @@ extend-exclude = [
extend-select = ["I", "N", "B", "A", "PIE", "SIM"]
[tool.pytest.ini_options]
pythonpath = "."
pythonpath = ["."]
asyncio_mode = "auto"
markers = ["slow: marks tests as slow (deselected by default)"]
env = ["EXO_TESTS=1"]
addopts = "-m 'not slow' --ignore=tests/start_distributed_test.py"
# `tests/` requires an eco cluster (opt-in). `tmp/` holds throwaway scripts
# that run a top-level `sys.exit(...)` at import time, which otherwise blows
# up the default pytest collection.
addopts = "-m 'not slow' --ignore=tests --ignore=tmp"
filterwarnings = ["ignore:builtin type Swig:DeprecationWarning"]
+62 -68
View File
@@ -5,14 +5,13 @@ let
workspaceRoot = ../.;
};
mkPythonSet = { pkgs, lib, self' }:
mkPythonSet = { pkgs, lib, self', members }:
let
inherit (pkgs.stdenv.hostPlatform) isLinux isDarwin isx86_64;
inherit (pkgs.config) cudaSupport;
inherit (pkgs) cudaPackages;
cuda13Support = cudaSupport && cudaPackages.cudaMajorVersion == "13";
libmlx_source = if cuda13Support then "mlx-cuda-13" else if cudaSupport then "mlx-cuda-12" else "mlx-cpu";
uv_extra = if cuda13Support then "cuda13" else if cudaSupport then "cuda12" else "cpu";
python = pkgs.python313;
cudaLibs = with cudaPackages; [
cuda_cudart
@@ -51,7 +50,7 @@ let
'';
};
};
buildSystemsOverlay = final: prev: { } //
buildSystemsOverlay = final: prev:
lib.optionalAttrs isDarwin
{
mlx = prev.mlx.overrideAttrs (old:
@@ -81,7 +80,7 @@ let
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.cmake self'.packages.metal-toolchain ];
# TODO: non-sdk_26 support
buildInputs = (old.buildInputs or [ ])
++ [ gguf-tools pkgs.fmt pkgs.nlohmann_json pkgs.apple-sdk_26 ];
++ [ gguf-tools pkgs.fmt pkgs.nlohmann_json pkgs.apple-sdk_26 ];
patches = [
(pkgs.replaceVars ../nix/darwin-build-fixes.patch {
sdkVersion = pkgs.apple-sdk_26.version;
@@ -113,42 +112,42 @@ let
MACOSX_DEPLOYMENT_TARGET = pkgs.apple-sdk_26.version;
});
} // lib.optionalAttrs isLinux {
mlx = prev.mlx.overrideAttrs (old: {
buildInputs = old.buildInputs ++ lib.optionals cudaSupport cudaLibs;
autoPatchelfIgnoreMissingDeps = lib.optionals cudaSupport [ "libcuda.so.1" ];
postInstall = (old.postInstall or "") + ''
cp -r "${final.${libmlx_source}}/${final.python.sitePackages}/mlx" "$out/${final.python.sitePackages}/mlx/"
'';
});
} // lib.optionalAttrs cudaSupport {
"${libmlx_source}" = prev."${libmlx_source}".overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cufile = prev.nvidia-cufile.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusolver = prev.nvidia-cusolver.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-nvshmem-cu13 = prev.nvidia-nvshmem-cu13.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core pkgs.pmix pkgs.libfabric pkgs.ucx pkgs.openmpi ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusparse = prev.nvidia-cusparse.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ cudaLibs ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
torch = prev.torch.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
};
mlx = prev.mlx.overrideAttrs (old: {
buildInputs = old.buildInputs ++ lib.optionals cudaSupport cudaLibs;
autoPatchelfIgnoreMissingDeps = lib.optionals cudaSupport [ "libcuda.so.1" ];
postInstall = ''
cp -r "${final.${libmlx_source}}/${final.python.sitePackages}/mlx" "$out/${final.python.sitePackages}/mlx/"
'';
});
} // lib.optionalAttrs cudaSupport {
"${libmlx_source}" = prev."${libmlx_source}".overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cufile = prev.nvidia-cufile.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusolver = prev.nvidia-cusolver.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-nvshmem-cu13 = prev.nvidia-nvshmem-cu13.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core pkgs.pmix pkgs.libfabric pkgs.ucx pkgs.openmpi ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusparse = prev.nvidia-cusparse.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ cudaLibs ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
torch = prev.torch.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
};
pyprojectOverlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
dependencies = { exo = [ uv_extra ]; exo-bench = [ ]; };
dependencies = members;
};
editableOverlay = workspace.mkEditablePyprojectOverlay {
# Use environment variable pointing to editable root directory
@@ -165,8 +164,8 @@ let
buildSystemsOverlay
]
);
mkApp = cmd: name: members: pkgs.writeShellApplication {
venv = name: (pythonSet.mkVirtualEnv "${name}-env" members).overrideAttrs (_: { venvSkip = [ "lib/python${python.pythonVersion}/site-packages/mlx/share/cmake/*" ]; });
mkApp = cmd: name: pkgs.writeShellApplication {
inherit name;
runtimeEnv = {
EXO_DASHBOARD_DIR = self'.packages.dashboard;
@@ -174,17 +173,17 @@ let
};
runtimeInputs = [
# mlx and mlx-cuda ship clashing cmake files - we dont need them at runtime anyway
((pythonSet.mkVirtualEnv "${name}-env" members).overrideAttrs (_: { venvSkip = [ "lib/python${python.pythonVersion}/site-packages/mlx/share/cmake/*" ]; }))
(venv name)
]
++ lib.optionals isDarwin [ pkgs.macmon ];
text = "exec " + lib.optionalString cudaSupport "${lib.getExe pkgs.nix-gl-host} " + cmd;
};
in
{
inherit pythonSet;
inherit venv;
editablePythonSet = pythonSet.overrideScope editableOverlay;
mkPythonScript = members: name: path: mkApp ''python ${path} "$@"'' name members;
mkExo = name: members: mkApp ''exo "$@"'' name members;
mkPythonScript = path: mkApp ''python ${path} "$@"'';
mkExo = mkApp ''exo "$@"'';
};
in
{
@@ -192,16 +191,21 @@ in
{ self', pkgs, unfreePkgs, lib, ... }:
let
inherit (pkgs.stdenv.hostPlatform) isLinux;
inherit (mkPythonSet { inherit self' pkgs lib; }) pythonSet editablePythonSet mkPythonScript mkExo;
exoVenv = pythonSet.mkVirtualEnv "exo-env" { exo = lib.optionals isLinux [ "cpu" ]; };
inherit (mkPythonSet { inherit self' pkgs lib; members = { exo = [ "cpu" ]; }; }) editablePythonSet mkExo;
# Virtual environment with dev dependencies for testing
testVenv = pythonSet.mkVirtualEnv "exo-test-env" {
exo = [ "dev" ] ++ lib.optionals isLinux [ "cpu" ]; # Include pytest, pytest-asyncio, pytest-env
testVenv = (mkPythonSet {
inherit self' pkgs lib; members = {
exo = [ "dev" "cpu" ]; # Include pytest, pytest-asyncio, pytest-env
};
}).venv "exo-test";
mkBenchScript = mkPythonScript { exo-bench = [ ]; };
mkBenchScript = (mkPythonSet {
inherit self' pkgs lib; members = {
exo = [ "cpu" ];
exo-bench = [ ]; # Include pytest, pytest-asyncio, pytest-env
};
}).mkPythonScript;
mkSimplePythonScript = name: path: pkgs.writeShellApplication {
inherit name;
@@ -212,9 +216,7 @@ in
in
{
packages = {
exo = mkExo "exo" { exo = lib.optionals isLinux [ "cpu" ]; };
# for devShell
exo-venv = exoVenv;
exo = mkExo "exo";
editableVenv = editablePythonSet.mkVirtualEnv "exo-dev-env" { exo = [ "dev" ]; };
# for running tests in ci
exo-test-env = testVenv;
@@ -224,8 +226,8 @@ in
# used by ./tests/run_exo_on.sh
exo-get-all-models-on-cluster = mkSimplePythonScript "exo-get-all-models-on-cluster" (inputs.self + /tests/get_all_models_on_cluster.py);
} // lib.optionalAttrs isLinux {
exo-cuda-12 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_12) pkgs; }).mkExo "exo-cuda-12" { exo = [ "cuda12" ]; };
exo-cuda-13 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_13) pkgs; }).mkExo "exo-cuda-13" { exo = [ "cuda13" ]; };
exo-cuda-12 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_12) pkgs; members = { exo = [ "cuda12" ]; }; }).mkExo "exo-cuda-12";
exo-cuda-13 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_13) pkgs; members = { exo = [ "cuda13" ]; }; }).mkExo "exo-cuda-13";
};
checks = {
@@ -235,19 +237,11 @@ in
touch $out
'';
typecheck = pkgs.runCommand "typecheck"
{
nativeBuildInputs = [
testVenv
pkgs.basedpyright
];
}
''
cd ${inputs.self}
export HOME=$TMPDIR
basedpyright --pythonpath ${testVenv}/bin/python --project ${inputs.self}/pyproject.toml
touch $out
'';
typecheck = pkgs.runCommand "typecheck" { nativeBuildInputs = [ testVenv ]; } ''
cd ${inputs.self}
basedpyright
touch $out
'';
};
};
}
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 131072
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 131072
@@ -0,0 +1,21 @@
model_id = "mlx-community/DeepSeek-V4-Flash"
n_layers = 43
hidden_size = 4096
num_key_value_heads = 1
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V4 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 1048576
[storage_size]
in_bytes = 155095760030
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash
[sampling_defaults]
temperature = 1.0
top_p = 1.0
@@ -0,0 +1,21 @@
model_id = "mlx-community/DeepSeek-V4-Pro"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 1
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V4 Pro"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 1048576
[storage_size]
in_bytes = 849681803879
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro
[sampling_defaults]
temperature = 1.0
top_p = 1.0
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "bf16"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "4bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "6bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "4bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "5bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "6bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "MXFP4-Q8"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "bf16"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -0,0 +1,21 @@
model_id = "mlx-community/GLM-5.1-DQ4plus-q8"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "8bit"
base_model = "GLM-5.1"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
in_bytes = 465173655552
# Source: https://huggingface.co/zai-org/GLM-5.1
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -0,0 +1,21 @@
model_id = "mlx-community/GLM-5.1-MXFP4-Q8"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "MXFP4-Q8"
base_model = "GLM-5.1"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
in_bytes = 405480321024
# Source: https://huggingface.co/zai-org/GLM-5.1
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -0,0 +1,21 @@
model_id = "mlx-community/GLM-5.1"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "bf16"
base_model = "GLM-5.1"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
in_bytes = 1487822475264
# Source: https://huggingface.co/zai-org/GLM-5.1
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -8,7 +8,7 @@ family = "kimi"
quantization = ""
base_model = "Kimi K2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "suffix"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "kimi"
quantization = ""
base_model = "Kimi K2.5"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "suffix"
context_length = 262144
[storage_size]
@@ -0,0 +1,33 @@
model_id = "mlx-community/Kimi-K2.6-mlx-DQ3_K_M-q8"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "kimi"
quantization = "3bit"
base_model = "Kimi K2.6"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "suffix"
context_length = 262144
[storage_size]
in_bytes = 470628683776
[vision]
image_token_id = 163605
model_type = "kimi_vl"
weights_repo = "exolabs/Kimi-K2.6-vision"
processor_repo = "moonshotai/Kimi-K2.6"
# Source: https://huggingface.co/moonshotai/Kimi-K2.6
[sampling_defaults]
temperature = 1.0
top_p = 0.95
min_p = 0.01
# Source: https://huggingface.co/moonshotai/Kimi-K2.6
[sampling_defaults.non_thinking]
temperature = 0.6
top_p = 0.95
min_p = 0.01
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "3bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "8bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "4bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "6bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "8bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "4bit-mxfp4"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "4bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "5bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "6bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "8bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "bf16"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more