Compare commits

..
Author SHA1 Message Date
Andrei Cravtov 6c0ec0ef95 final 2026-05-01 14:13:04 +01:00
Andrei Cravtov 1848f596bf e 2026-04-24 14:42:32 +01:00
Andrei Cravtov df10f1503b e 2026-04-24 14:40:24 +01:00
Andrei Cravtov cd9dcf5320 4 2026-04-24 14:39:31 +01:00
Andrei Cravtov 2d945bc7fb run test 2026-04-24 14:37:20 +01:00
Andrei Cravtov f4d3a30eef push 2026-04-24 14:05:45 +01:00
Andrei Cravtov 84fdccf4df Report benchmark average and stddev 2026-04-24 13:31:39 +01:00
Andrei Cravtov 32f3c99e0f Default torch route benchmark to CPU 2026-04-24 13:28:48 +01:00
Andrei Cravtov 8d34c355db Add torch route interop benchmark 2026-04-24 13:25:08 +01:00
Andrei Cravtov 8e9de4a26a Compare stress ops against destination backends 2026-04-23 19:10:19 +01:00
Andrei Cravtov 8923c09145 Relax float32 stress tolerance 2026-04-23 19:07:44 +01:00
Andrei Cravtov 1d34d3c660 Fix stress matmul baseline on macOS 2026-04-23 19:05:53 +01:00
Andrei Cravtov 3562e6184b Merge remote-tracking branch 'refs/remotes/origin/andrei/mlx-tinygrad-convert' into andrei/mlx-tinygrad-convert
# Conflicts:
#	mlx_tinygrad_interop/stress_interop.py
2026-04-23 18:57:29 +01:00
Andrei Cravtov 8f75712e86 Simplify interop roundtrip stress checks 2026-04-23 18:40:46 +01:00
Andrei Cravtov fe03666e32 Fix interop stress roundtrip dtype checks 2026-04-23 18:30:35 +01:00
Andrei Cravtov 7dfcfbdd48 Harden MLX tinygrad handoff pools 2026-04-23 18:23:37 +01:00
Andrei Cravtov 46b8581927 Harden MLX tinygrad handoff pools 2026-04-23 18:16:20 +01:00
Andrei Cravtov e6c4de1e16 Narrow scoped MLX handoff release fence 2026-04-23 17:37:26 +01:00
Andrei Cravtov 138b775dcb Add scoped MLX handoff API 2026-04-23 16:16:17 +01:00
Andrei Cravtov c1cbacc0d2 Document byte-level metal buffer sizing 2026-04-23 15:24:29 +01:00
Andrei Cravtov 30a822637f Fix soak loop scalar normalization 2026-04-23 15:22:55 +01:00
Andrei Cravtov 39a275f1a2 Add stress-found metal view regression test 2026-04-23 15:22:00 +01:00
Andrei Cravtov c6285c95d4 Use MLX array cast method in stress suite 2026-04-23 15:20:29 +01:00
Andrei Cravtov 3eafe13c51 Normalize stress op scalars 2026-04-23 15:19:50 +01:00
Andrei Cravtov 2f7462f5cb Fix direct script imports for interop tools 2026-04-23 15:19:07 +01:00
Andrei Cravtov 6f0885990e Fix lazy lease pool test scalar 2026-04-23 15:18:17 +01:00
Andrei Cravtov 9ce217e5d4 Add lease-managed MLX interop handoff 2026-04-23 15:17:20 +01:00
Andrei Cravtov 3bd510b079 Record hardened slot benchmark impact 2026-04-23 14:50:19 +01:00
Andrei Cravtov 07d8cb0cfd Update lockfile for hardened slot checks 2026-04-23 14:49:07 +01:00
Andrei Cravtov 9f84d3e264 Harden slot rebind contracts 2026-04-23 14:42:29 +01:00
Andrei Cravtov 21182f9f8e Record slot and ring benchmark results 2026-04-23 14:22:59 +01:00
Andrei Cravtov 8844483ce5 Benchmark rebindable slot interop paths 2026-04-23 14:20:44 +01:00
Andrei Cravtov eca0c90e24 Record lower-level interop benchmark results 2026-04-23 13:59:28 +01:00
Andrei Cravtov 75a2fc7c6f Add lower-level MLX interop benchmarks 2026-04-23 13:52:04 +01:00
Andrei Cravtov ca2361d103 Document alias-only benchmark semantics 2026-04-22 21:24:34 +01:00
Andrei Cravtov e91bc98906 Differentiate alias-only interop paths 2026-04-22 21:16:57 +01:00
Andrei Cravtov 4e1625fe86 Record lower-overhead import benchmark results 2026-04-22 20:41:40 +01:00
Andrei Cravtov fd0b58be17 Benchmark lower-overhead MLX to tinygrad import 2026-04-22 20:39:44 +01:00
Andrei Cravtov 6dd31c209c Record interop microbenchmark findings 2026-04-22 20:29:28 +01:00
Andrei Cravtov 601ab485f9 Refine interop benchmark labels and probes 2026-04-22 20:25:09 +01:00
Andrei Cravtov b1c1debc27 Record validated interop benchmark results 2026-04-22 20:05:18 +01:00
Andrei Cravtov 376fd9bd26 Document uv lock refresh for interop deps 2026-04-22 19:59:02 +01:00
Andrei Cravtov 4ab2b7efda Add MLX tinygrad interop benchmark workspace 2026-04-22 19:47:11 +01:00
Andrei Cravtov 9ea9bf0aca flake updates to make uv build!! 2026-04-22 19:08:58 +01:00
Andrei Cravtov eca2ca0b7e build 2026-04-22 17:49:43 +01:00
Andrei Cravtov fef281c666 init 2026-04-22 17:38:23 +01:00
212 changed files with 5123 additions and 19004 deletions

No files matched your search

+7
View File
@@ -1 +1,8 @@
use flake
# creates .venv if doesn't exist and loads its environment
export VIRTUAL_ENV=".venv"
if ! [ -d "./$VIRTUAL_ENV" ]; then
uv venv
fi
layout python
+2
View File
@@ -32,6 +32,7 @@ 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 }}
@@ -346,6 +347,7 @@ 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
+4 -5
View File
@@ -383,12 +383,11 @@ class GenerationBatch:
state_machines: List[SequenceStateMachine]
max_tokens: List[int]
_current_tokens: Optional[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]
_current_logprobs: List[mx.array]
_next_tokens: mx.array
_next_logprobs: List[mx.array]
_token_context: List[mx.array]
_num_tokens: List[int]
_matcher_states: List[Any]
def __init__(
self,
+6 -3
View File
@@ -191,10 +191,13 @@ class RotatingKVCache(_BaseCache):
def state(self, v): # -> None:
...
@property
def meta_state(self) -> tuple[str, ...]: ...
def meta_state(self): # -> tuple[str, ...]:
...
@meta_state.setter
def meta_state(self, v: tuple[str, ...]) -> None: ...
def is_trimmable(self) -> bool: ...
def meta_state(self, v): # -> None:
...
def is_trimmable(self): # -> bool:
...
def trim(self, n: int) -> int: ...
def to_quantized(
self, group_size: int = ..., bits: int = ...
-280
View File
@@ -1,280 +0,0 @@
"""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]: ...
File diff suppressed because it is too large. Load diff
+482
View File
@@ -0,0 +1,482 @@
# Tensor Conversion Benchmark Notes
## Current Goal
Benchmark raw tinygrad `<->` MLX tensor transformation latency on Apple Silicon
for tensors that are already:
- synchronized
- allocated
- materialized / realized
The timed region should keep source creation and explicit synchronization
outside the loop, while making it clear when helper, binding, owner-pinning,
and wrapper-construction overhead are still inside it.
Current benchmark CSV output reports average per-call latency plus sample
standard deviation:
- `avg_us`
- `stddev_us`
Older notes below that quote min/median values refer to earlier benchmark runs
before the reporting format was changed.
## Repo Layout
- Root notes file:
- `CONVERSION_BENCH_NOTES.md`
- Interop code:
- `mlx_tinygrad_interop/`
- Reusable library code:
- `mlx_tinygrad_interop/lib/`
- Baseline bridge module:
- `mlx_tinygrad_interop/lib/tensor_bridge.py`
- Route benchmark using existing PyTorch interop:
- `mlx_tinygrad_interop/bench_torch_route.py`
- Historical benchmark kept as-is:
- `tmp/bench_pingpong.py`
## Current Fast-Path Design
The first direct benchmark path is intentionally narrow.
- `MLX -> tinygrad`
- export MLX Metal storage metadata
- import into tinygrad by aliasing the existing `MTLBuffer*`
- `tinygrad -> MLX`
- export tinygrad Metal storage metadata
- import into MLX by wrapping the underlying unified-memory pointer with a
no-copy MLX array constructor path
This is asymmetric internally:
- `MLX -> tinygrad` aliases an existing `MTLBuffer*`
- `tinygrad -> MLX` rebuilds an MLX array from a raw unified-memory pointer
So the current bridge benchmark is not a symmetric measure of pure storage
adoption cost. That is acceptable for now: the working goal is "good enough"
bidirectional latency, not symmetry for its own sake.
## Implemented Helper Surface
- MLX
- `mx.metal._unsafe_export_storage(array)`
- `mx.metal._unsafe_to_tinygrad_fast(array, tg_dtype, owner=None)`
- `mx.metal._unsafe_rebind_tinygrad(array, borrower, owner=None)`
- `mx.metal._unsafe_array_from_ptr(raw_ptr, shape, dtype, owner=None)`
- `mx.metal._unsafe_array_from_ptr_alias_only(raw_ptr, shape, dtype, owner=None)`
- tinygrad
- `Tensor._unsafe_metal_storage()`
- `Tensor._unsafe_from_metal_buffer(mtl_buffer_ptr, shape, dtype=..., byte_offset=0, owner=None)`
- `Tensor._unsafe_from_metal_buffer_fast(mtl_buffer_ptr, shape, dtype=..., byte_offset=0, owner=None)`
- `Tensor._unsafe_metal_borrower(mtl_buffer_ptr, shape, dtype=..., byte_offset=0, owner=None)`
- exo handoff layer
- `mlx_tinygrad_interop.lib.lease_pool.MlxToTinygradLeasePool`
- `mlx_tinygrad_interop.lib.lease_pool.MlxToTinygradLeasePools`
- `mlx_tinygrad_interop.lib.lease_pool.MlxToTinygradCopyLeasePool`
- `mlx_tinygrad_interop.lib.lease_pool.MlxToTinygradCopyLeasePools`
- `mlx_tinygrad_interop.stress_interop`
- `mlx_tinygrad_interop.lib.tensor_bridge.tinygrad_to_mlx`
- `mlx_tinygrad_interop.lib.tensor_bridge.mlx_to_tinygrad` (stubbed)
These helpers are intentionally private and unsafe.
The current benchmark still pays Python and binding overhead in several rows.
The older helper rows return Python dicts and unpack them before calling the
import helper, while the newer MLX-side single-entry rows still include the
tinygrad-side wrapper construction they trigger.
## Temporary Eligibility Rules
The current direct path should only accept tensors that are:
- backed by Metal storage
- already realized / available
- single-device
- dense row-major contiguous
- concrete-shaped
- dtype-compatible without conversion
Non-contiguous views, broadcasts, dtype casts, and multi-device tensors should
fall back to slower paths.
For the current MLX exporter, the array must also already be in MLX's
`available` state. The benchmark currently satisfies that with
`mx.array(np_array)`, which is a workaround for the current helper rather than a
claim that arbitrary lazy MLX outputs are already supported by the same path.
## Workflow
Use the `exo` devshell and `uv` workflow.
1. Change code locally.
2. Push the updated `mlx` and `tinygrad` fork branches.
3. In local `exo`, enter the devshell with `nix develop`.
4. Regenerate the lockfile against the new fork heads with:
`uv lock --upgrade-package mlx --refresh-package mlx --upgrade-package tinygrad --refresh-package tinygrad`
5. Commit and push the updated `exo` branch, including the regenerated
`uv.lock`.
6. On the remote Mac, pull the updated `exo` branch.
7. Enter the devshell with `nix develop`.
8. Refresh the environment with `uv sync`.
9. Run tests and benchmarks with `uv run ...`.
Do not rely on ad-hoc per-host build environments when the flake / devshell can
carry the needed toolchain.
For the PyTorch-route benchmark, `torch` must also be present in the normal
Darwin project dependencies, not only in Linux extras.
The current fully pre-existing PyTorch route is only usable with a CPU
intermediate tensor on `e16`. An `mps` intermediate made the documented
`Tensor.from_blob(..., device="METAL")` path fail when tinygrad later tried to
use the imported object as a Metal buffer, so the route benchmark now defaults
to `--torch-device cpu`.
### Important Lockfile Note
For these branch-based git dependencies, plain `uv lock` was not sufficient to
advance the pinned SHAs in `uv.lock` during testing, and `--upgrade-package`
alone still left a stale git revision in a later pass. The working command was:
`uv lock --upgrade-package mlx --refresh-package mlx --upgrade-package tinygrad --refresh-package tinygrad`
## Known Nuances / Footguns
- Unified memory does not mean both frameworks consume shared storage in the
same way. Metal kernels still bind `MTLBuffer` objects.
- Synchronization can dominate measured latency if it leaks into the timed path.
- Python overhead matters at the `1-10 us` scale, so helper calls and wrapper
construction can dominate tiny tensors even when no tensor bytes are copied.
- tinygrad tensors are graph objects, but once realized they do have concrete
underlying storage.
- External mutation and aliasing can bypass autograd expectations in both
frameworks.
- The current fast path is asymmetric:
- MLX exports `MTLBuffer*` for the `MLX -> tinygrad` direction.
- tinygrad exports raw unified-memory pointer for the `tinygrad -> MLX`
direction.
- `mx.array(memoryview(...))` is not an aliasing import path in current MLX.
It goes through MLX's native CPU ndarray conversion path and copies the
bytes.
- The first tinygrad import helper supports byte offsets.
- The first tinygrad import helper now optionally accepts `buffer_nbytes` for
a bounds check. If that metadata is omitted, the helper still cannot prove
the requested view fits inside the borrowed buffer.
- MLX export now distinguishes:
- `logical_nbytes`: the logical bytes in the exported array view
- `buffer_nbytes`: the backing buffer capacity
- Offsetted MLX views must use `buffer_nbytes` semantics for bounds checks.
- The legacy MLX export field `nbytes` was removed to avoid accidental use of
logical-size semantics where backing-buffer-size semantics are required.
- tinygrad's fast Metal import helper must also treat zero-offset logical views
over oversized backing buffers as `BUFFER_VIEW`s. Stress testing caught a bug
where it tried to reshape the whole backing buffer instead.
- MLX `buffer_nbytes` is a raw byte-capacity field, not a promise that the
backing buffer size is a multiple of the tensor dtype itemsize. The fast
tinygrad helper and borrower now use byte-level bounds checks and `ceildiv`
for backing-buffer sizing instead of rejecting those cases.
- The first MLX import helper is raw-pointer based rather than foreign
`MTLBuffer*` based.
- `mx.metal._unsafe_array_from_ptr(...)` may still copy if MLX cannot alias the
pointer directly.
- `mx.metal._unsafe_array_from_ptr_alias_only(...)` fails instead of silently
copying, so it is the right helper for proving aliasing in benchmarks.
- `mx.metal._unsafe_to_tinygrad_fast(...)` is a single MLX binding entrypoint
for `MLX -> tinygrad`, but it still includes tinygrad-side tensor creation.
- `Tensor._unsafe_metal_borrower(...)` is a mutable slot primitive. It reuses
the same tinygrad tensor wrapper and rebinds its borrowed `MTLBuffer*`.
Older references to that tensor are not snapshots.
- `borrower.rebind(...)` is now the checked path and requires explicit
`shape=` and `dtype_name=` compatibility. The only bypass is the private
`borrower._raw_rebind(...)` escape hatch kept for benchmark internals.
- The borrower now updates its internal `external_ptr` metadata on rebind so
the stored buffer metadata matches the live Metal handle.
- `mx.metal._unsafe_export_storage(...)` currently expects an MLX array that is
already in the C++ `available` state. In practice, `mx.array(np_array)` met
that precondition for local smoke testing, while `mx.arange(...)` did not.
- The lease-pool handoff layer now owns that MLX-side availability barrier by
calling `mx.eval(...)` on acquire.
- `tinygrad -> MLX memoryview_copy` also includes per-call runtime ceremony,
because tinygrad's zero-copy Metal memoryview export synchronizes before
exposing the buffer.
- The randomized stress harness now uses `np.einsum(...)` rather than NumPy's
`@` operator for the `matmul_lastdim` baseline. On the current macOS
validation host, a valid contiguous float32 `(16,31) @ (31,7)` case through
NumPy `@` returned an incorrect all-zero result while MLX, tinygrad, and
`np.einsum` agreed on the nonzero result.
- Bench rows named `rebindable_slot_*` measure rebindable-slot cost, not fresh
conversion cost.
- Bench rows named `borrower_ring*` rotate through multiple independent slots.
They are intended to approximate a practical pool/ring design with fewer
footguns than a single slot.
- Bench rows named `copy_pool_*` reuse tinygrad-owned destination tensors and
copy MLX bytes into them before release. They avoid foreign-buffer aliasing
but still rely on slot/pool reuse rather than fresh independent tensors.
- The practical `MLX -> tinygrad` API shape is now a keyed lease pool:
- acquire from an MLX array
- use `lease.tensor`
- release the lease only after downstream work is realized / synchronized
- That raw lease surface is still intentionally unsafe. A saved `lease.tensor`
reference is not a snapshot and can observe new contents if the slot is
later reused.
- The preferred production-shaped handoff is now the scoped callback API:
- `pool.run_with_mlx_tensor(array, fn=...)`
- `pools.run_with_mlx_tensor(array, tg_dtype=..., fn=...)`
- it scopes acquire/use/release together, realizes returned tensors before
release, rejects returning alias views of the borrowed slot, and rejects
leaked live tensors whose graphs still depend on the borrowed tensor
- only independently realized outputs may escape the callback
- the callback still must not stash the raw borrowed tensor object itself;
that remains a contract rule rather than something the current runtime can
prove mechanically
- Safe scoped release intentionally uses the global `Device["METAL"].synchronize()`
barrier again. The narrower callback-local command-buffer wait experiment was
not robust under concurrent Metal enqueue and did not show a meaningful
latency win in the local microbenchmarks.
- Lease pools are keyed by `(shape, dtype, byte_offset)` so variable inference
shapes can be bucketed explicitly instead of silently reusing an
incompatible slot.
- Both alias and copy pool registries are now bounded LRU caches with
`max_pools`. If the registry is full and all pools are in flight, acquire
fails instead of growing unbounded.
- Bench rows named `*_then_use_sum` measure rebinding or conversion followed by
immediate tinygrad consumption through a realized reduction kernel.
- Do not rebind a slot until all work derived from its previous contents has
been realized and synchronized. Otherwise later rebinds can change what
older lazy graphs or in-flight kernels observe.
- Safe release now clears the slot's pinned `_external_owner` after the Metal
barrier. Unsafe `synchronize_on_release=False` flows still require the caller
to provide the fence discipline.
- This optimization is same-process and same-address-space only. It does not
survive a process boundary or a machine boundary, and it does not remove any
later Metal/host -> CUDA transfer when the downstream stage runs on the RTX.
## Stress Suite
There is now a separate randomized stress/soak script:
- `uv run python mlx_tinygrad_interop/stress_interop.py --cases 64 --soak-iterations 512`
It exercises:
- random shapes and dtypes
- offsetted MLX views
- raw conversion correctness against NumPy baselines
- randomized downstream movement / elementwise / broadcast / reduction /
matmul op chains after conversion
- roundtrip `MLX -> tinygrad -> MLX` pipeline checks after those op chains
- repeated scoped-handoff soak loops through both alias and copy keyed pools
- native memory reporting via:
- `mx.get_active_memory()`
- `mx.get_cache_memory()`
- `mx.get_peak_memory()`
- process `ru_maxrss`
- bounded pool-count assertions for both alias and copy pool registries
This is intended to catch value corruption, stale-slot mistakes, obvious
crashes, and gross leak regressions before the interop path is integrated more
deeply into the runtime.
Note: the `matmul_lastdim` stress baseline uses `np.einsum(...)` instead of
NumPy `@` because the current macOS NumPy build produced a demonstrably wrong
all-zero result on one of the randomized float32 cases.
The stress comparator is still strict, but float32 rows now use a slightly
looser `rtol=5e-5, atol=1e-5` budget to absorb backend accumulation-order
differences across long matmul/reduction chains without treating a few-ulps
drift as conversion corruption.
For downstream op-chain checks, the stress suite now compares against the
native destination-framework baseline rather than NumPy directly:
- `MLX -> tinygrad` post-conversion op chains are compared to a native tinygrad
tensor built from the same logical values
- `tinygrad -> MLX` post-conversion op chains are compared to a native MLX
array built from the same logical values
That split is intentional. Raw conversion is still checked against NumPy, but
some downstream integer-promotion and reduction semantics differ between NumPy,
MLX, and tinygrad, so the destination-framework baseline is the right
conversion-integrity check.
## Current Findings
The unsafe bridge was validated through the repo-standard remote flow on `e16`:
1. `git pull --ff-only`
2. `nix develop`
3. `uv sync`
4. `uv run python mlx_tinygrad_interop/bench_raw_conversion.py ...`
The direct helpers worked in both directions:
- `MLX -> tinygrad` unsafe helper bridge returned correct values.
- `tinygrad -> MLX` unsafe helper bridge returned correct values.
- The expanded slot / ring tests passed on `e16`:
- nonzero-offset MLX slice import
- single-entry `MLX -> tinygrad`
- mutable-slot rebinding semantics
- shape/dtype mismatch rejection on rebind
- slot metadata update on rebind
- two-slot independence until a slot is reused
- After adding shape/dtype contract checks, a spot-check rerun at `7168` bytes
showed:
- `rebindable_slot_bridge`: `2.458 us` min, `2.494 us` median
- `borrower_ring4_bridge`: `2.545 us` min, `2.566 us` median
- `rebindable_slot_import_only`: `2.395 us` min, `2.400 us` median
- `borrower_ring4_import_only`: `2.449 us` min, `2.477 us` median
- So the contract hardening added roughly `~1 us`, but the slot/ring path still
remains comfortably inside the target latency class.
Updated remote latency measurements for `float32` and `7168` bytes were:
- `unsafe_helper_bridge`
- `mlx_to_tinygrad`: `21.202 us` min, `21.532 us` median
- `tinygrad_to_mlx`: `28.109 us` min, `28.372 us` median
- `single_entry_bridge`
- `mlx_to_tinygrad`: `21.388 us` min, `21.542 us` median
- `fresh_wrapper_then_use_sum`
- `mlx_to_tinygrad`: `601.812 us` min, `611.458 us` median
- `rebindable_slot_bridge`
- `mlx_to_tinygrad`: `1.505 us` min, `1.542 us` median
- `rebindable_slot_then_use_sum`
- `mlx_to_tinygrad`: `579.583 us` min, `581.833 us` median
- `borrower_ring4_bridge`
- `mlx_to_tinygrad`: `1.531 us` min, `1.573 us` median
- `borrower_ring4_then_use_sum`
- `mlx_to_tinygrad`: `577.730 us` min, `581.000 us` median
- `unsafe_helper_legacy`
- `mlx_to_tinygrad`: `31.938 us` min, `32.214 us` median
- `unsafe_helper_maybe_copy`
- `tinygrad_to_mlx`: `28.153 us` min, `28.277 us` median
- `memoryview_copy`
- `mlx_to_tinygrad`: `35.191 us` min, `35.668 us` median
- `tinygrad_to_mlx`: `2.596 us` min, `2.662 us` median
- `numpy_baseline`
- `mlx_to_tinygrad`: `272.323 us` min, `275.104 us` median
- `tinygrad_to_mlx`: `12.817 us` min, `13.005 us` median
Interpretation:
- The lower-overhead tinygrad import helper cut `MLX -> tinygrad` from about
`32 us` to about `21 us` at `7 kB`, so the old `Tensor.empty(...)` based
helper was a real source of overhead.
- Replacing the exporter dict/unpack stack with a single MLX binding entrypoint
still barely moved `MLX -> tinygrad`: about `21.2 us -> 21.4 us` at `7 kB`.
- That means the remaining fixed cost was not materially in MLX export or
Python exporter marshalling. It was overwhelmingly on the tinygrad side.
- Rebinding a single mutable slot dropped `MLX -> tinygrad` to about `1.5 us`
at `7 kB`. Rotating through a ring of four slots landed at about `1.53 us`,
so the ring preserved essentially all of the latency win.
- Those slot / ring rows are not fresh conversion results. They are
rebind-and-return-slot results.
- That is still inside the target range and strongly indicates that tinygrad
wrapper construction, not storage adoption itself, was the dominant cost.
- On `e16`, the strict alias-only `tinygrad -> MLX` helper succeeded. Its
timings were effectively the same as the maybe-copy helper, so the benchmark
can now report a proven aliasing path in that direction on this host.
- `tinygrad -> MLX` currently has a very cheap copy path because `mx.array()`
over a tinygrad `memoryview` is implemented efficiently in MLX's native C++
import path, even though it still copies.
- At this tensor size, Python call overhead and wrapper construction matter
much more than raw byte movement.
- The `*_then_use_sum` rows are dominated by the tinygrad reduction kernel
itself. They are useful as end-to-end "convert then immediately consume"
probes, not as pure conversion timings.
- Those end-to-end rows still show the same direction: at `7 kB`, slot / ring
rebinding saves roughly `20-25 us` versus the fresh-wrapper path, but the
absolute runtime is around `580-600 us` because the reduction dominates.
- These numbers do not establish that "aliasing costs ~21-28 us". They
establish that creating a fresh tinygrad wrapper through the current helper
stack costs that much, while rebinding a pre-existing slot costs about
`1.5 us` on this host.
- An offsetted MLX slice was also validated through the new export semantics:
`offset_bytes=64`, `logical_nbytes=7168`, `buffer_nbytes=16384`, and the
borrowed tinygrad tensor matched the expected values.
- The rebindable slot is narrower than a normal conversion helper:
- it returns the same tinygrad `Tensor` object each time
- it assumes fixed shape / dtype / byte-offset semantics
- older references are not snapshots
- safe reuse requires that all work from the previous lease has already been
realized and synchronized
- it is therefore best understood as a dangerous but very informative lower
bound and a candidate building block for a specialized converter API
- A ring of multiple slots is the more practical extension of that idea because
it preserves most of the latency win while reducing the worst single-slot
footgun.
Additional remote microbench sweep on `e16` for `256`, `7168`, `65536`, and
`1048576` bytes showed:
- `MLX -> tinygrad`
- `unsafe_helper_bridge`: roughly `21-23 us`
- `single_entry_bridge`: roughly `21-22 us`
- `fresh_wrapper_then_use_sum`: roughly `601-693 us`
- `rebindable_slot_bridge`: roughly `1.49-1.53 us`
- `rebindable_slot_then_use_sum`: roughly `573-670 us`
- `borrower_ring4_bridge`: roughly `1.53-1.62 us`
- `borrower_ring4_then_use_sum`: roughly `566-669 us`
- `unsafe_helper_legacy`: roughly `31-33 us`
- `memoryview_copy`: roughly `34-51 us`
- `numpy_baseline`: roughly `269-309 us`
- `export_helper_only`: roughly `0.58-0.63 us`
- `import_helper_fast_only`: roughly `20.2-21.4 us`
- `rebindable_slot_import_only`: roughly `1.40-1.47 us`
- `borrower_ring4_import_only`: roughly `1.45-1.49 us`
- `import_helper_legacy_only`: roughly `31-33 us`
- `tinygrad -> MLX`
- `unsafe_helper_bridge`: roughly `27-29 us`
- `unsafe_helper_maybe_copy`: roughly `27-29 us`
- `memoryview_copy`: roughly `2.5 us` at `256 B`, `2.5 us` at `7168 B`,
`3.5 us` at `64 KiB`, and `17.4 us` at `1 MiB`
- `numpy_baseline`: roughly `12.7 us` at `256 B`, `12.7 us` at `7168 B`,
`16.6 us` at `64 KiB`, and `56.7 us` at `1 MiB`
- `export_helper_only`: roughly `23.1-24.4 us`
- `import_helper_only`: roughly `2.12-2.22 us`
- `import_helper_maybe_copy_only`: roughly `2.16-2.29 us`
What this means:
- The MLX exporter is already cheap, and even a single MLX binding entrypoint
did not change `MLX -> tinygrad` materially. That closes out the
"Python exporter ceremony" hypothesis for the current bridge.
- The MLX importer from raw pointer is also already cheap, whether measured in
strict alias-only mode or maybe-copy mode on this host.
- The lower-overhead tinygrad import helper bought a real speedup, but the
expensive piece for `MLX -> tinygrad` was still constructing a fresh tinygrad
wrapper around the borrowed storage.
- Rebinding a slot or rotating through a ring changes the latency class
completely. The "rebind one pre-existing slot" lower bound is about
`1.5 us` on this host for the measured sizes, and a ring of four slots keeps
essentially the same latency.
- For `tinygrad -> MLX`, the native copy path is already in the desired latency
class for small tensors and remains competitive well past `7 kB`.
- For `MLX -> tinygrad`, a fresh-wrapper helper is still not close to the
desired `1-10 us` range at `7 kB`, but a slot / ring helper is.
- For end-to-end "convert then immediately use" measurements, the tinygrad
compute dominates. The relevant signal is the delta versus the fresh-wrapper
path, not the absolute `~580-700 us` number.
## Near-Term Plan
1. Treat `tinygrad -> MLX memoryview_copy` as the current practical fast path.
2. Treat `MLX -> tinygrad` slot/ring rebinding as the current latency floor and
likely practical fast path when rebindable slot semantics are acceptable.
3. Prefer a ring/pool of slots over a single slot for any practical design.
4. If `MLX -> tinygrad` must return a fresh tinygrad tensor each time and still
stay under `10 us`, the remaining work is entirely on the tinygrad-side
construction path.
5. Avoid spending time on symmetry unless it becomes necessary for a specific
downstream use case.
6. If this moves into the real MLX/tinygrad disaggregated runtime, the next API
shape should be an explicit pool/lease abstraction with generation tracking,
not bare mutable-slot rebinding.
## Open Questions
- Whether the first fast path should support contiguous slices with byte
offsets, or only base-contiguous tensors.
- Whether the slot/ring primitive should stay benchmark-only or be surfaced as
a deliberate specialized converter API.
+1 -10
View File
@@ -584,18 +584,9 @@ struct ContentView: View {
case .prompting:
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 2) {
Text("Tell us what went wrong (optional)")
.font(.caption2)
.foregroundColor(.secondary)
Text(
"A quick description of what you were doing and what happened helps us track down the bug for you."
)
Text("What's the issue? (optional)")
.font(.caption2)
.foregroundColor(.secondary)
.opacity(0.8)
.fixedSize(horizontal: false, vertical: true)
}
TextEditor(text: $bugReportUserDescription)
.font(.caption2)
.frame(height: 60)
+1 -1
View File
@@ -9,7 +9,7 @@
<key>EXOBuildCommit</key>
<string>$(EXO_BUILD_COMMIT)</string>
<key>EXOBugReportPresignedUrlEndpoint</key>
<string>https://reports.exolabs.net/presigned-urls</string>
<string>$(EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT)</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>
+3 -30
View File
@@ -552,24 +552,15 @@ struct SettingsView: View {
let alert = NSAlert()
alert.messageText = "Uninstall EXO"
alert.informativeText = """
This will remove EXO and all its components:
This will remove EXO and all its system 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")
@@ -579,11 +570,11 @@ struct SettingsView: View {
let response = alert.runModal()
if response == .alertFirstButtonReturn {
performUninstall(keepModels: checkbox.state == .on)
performUninstall()
}
}
private func performUninstall(keepModels: Bool) {
private func performUninstall() {
uninstallInProgress = true
controller.cancelPendingLaunch()
@@ -593,7 +584,6 @@ struct SettingsView: View {
DispatchQueue.global(qos: .utility).async {
do {
try NetworkSetupHelper.uninstall()
try Self.removeExoDirectory(keepModels: keepModels)
DispatchQueue.main.async {
LaunchAtLoginHelper.disable()
@@ -617,23 +607,6 @@ 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 {
+7 -63
View File
@@ -3,55 +3,25 @@
# EXO Uninstaller Script
#
# This script removes all EXO system components that persist after deleting the app.
# Run with: sudo ./uninstall-exo.sh [--keep-models]
#
# Options:
# --keep-models Preserve ~/.exo/models when removing the EXO data directory.
# Run with: sudo ./uninstall-exo.sh
#
# 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"
# 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"
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'
@@ -99,17 +69,11 @@ else
echo_warn "LaunchDaemon plist not found (already removed?)"
fi
# 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
# Remove the script and parent directory
if [[ -f $SCRIPT_DEST ]]; then
rm -f "$SCRIPT_DEST"
echo_info "Removed network setup script"
else
echo_warn "Network setup script not found (already removed?)"
fi
@@ -151,22 +115,6 @@ 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.
@@ -196,10 +144,6 @@ 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)."
+22 -77
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, enable_thinking
# temperature, top_p, max_tokens, reasoning_effort
#
# Fallback defaults (when no per-model config):
# reasoning: temperature=1.0, max_tokens=131072, reasoning_effort="high"
@@ -18,9 +18,10 @@
# ─── Qwen3.5 (Feb 2026) ─────────────────────────────────────────────
# Source: HuggingFace model cards (Qwen/Qwen3.5-*)
# 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).
# 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]]
name = "Qwen3.5 2B"
@@ -28,8 +29,7 @@ patterns = ["Qwen3.5-2B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 81920
[[model]]
name = "Qwen3.5 9B"
@@ -37,8 +37,7 @@ patterns = ["Qwen3.5-9B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 81920
[[model]]
name = "Qwen3.5 27B"
@@ -46,17 +45,15 @@ patterns = ["Qwen3.5-27B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 81920
[[model]]
name = "Qwen3.5 35B A3B"
patterns = ["Qwen3.5-35B-A3B"]
reasoning = true
temperature = 0.6
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 81920
[[model]]
name = "Qwen3.5 122B A10B"
@@ -64,8 +61,7 @@ patterns = ["Qwen3.5-122B-A10B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 81920
[[model]]
name = "Qwen3.5 397B A17B"
@@ -73,14 +69,12 @@ patterns = ["Qwen3.5-397B-A17B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 81920
# ─── Qwen3 (Apr 2025) ───────────────────────────────────────────────
# Source: HuggingFace model cards (Qwen/Qwen3-*)
# 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
# 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, 38912 for complex math/code
[[model]]
@@ -89,7 +83,6 @@ patterns = ["Qwen3-0.6B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -98,7 +91,6 @@ patterns = ["Qwen3-30B-A3B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -107,7 +99,6 @@ patterns = ["Qwen3-235B-A22B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -116,7 +107,6 @@ patterns = ["Qwen3-Next-80B-A3B-Thinking"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -139,9 +129,9 @@ max_tokens = 16384
name = "Qwen3 Coder Next"
patterns = ["Qwen3-Coder-Next"]
reasoning = false
temperature = 1.0
top_p = 0.95
max_tokens = 121072
temperature = 0.7
top_p = 0.8
max_tokens = 16384
# ─── GPT-OSS (OpenAI) ───────────────────────────────────────────────
# Source: OpenAI GitHub README + HuggingFace discussion #21
@@ -175,38 +165,10 @@ 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
# max_tokens=121072 to match vllm eval (131072 context - 10000 safety margin)
# Reasoning tasks: 131072 max_tokens; coding/SWE tasks: temp=0.7
[[model]]
name = "GLM-5"
@@ -214,8 +176,7 @@ patterns = ["GLM-5"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 131072
[[model]]
name = "GLM 4.5 Air"
@@ -230,8 +191,7 @@ patterns = ["GLM-4.7-"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 131072
# Note: matches both GLM-4.7 and GLM-4.7-Flash
# ─── Kimi (Moonshot AI) ─────────────────────────────────────────────
@@ -253,8 +213,7 @@ patterns = ["Kimi-K2.5"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 121072
max_tokens = 131072
[[model]]
name = "Kimi K2 Instruct"
@@ -264,17 +223,7 @@ temperature = 0.6
# ─── MiniMax ─────────────────────────────────────────────────────────
# Source: HuggingFace model cards + generation_config.json
# 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
# All models: temp=1.0, top_p=0.95, top_k=40
[[model]]
name = "MiniMax M2.5"
@@ -282,8 +231,6 @@ patterns = ["MiniMax-M2.5"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 90000
[[model]]
name = "MiniMax M2.1"
@@ -304,8 +251,6 @@ 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
+104 -247
View File
@@ -35,7 +35,6 @@ from harness import (
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
@@ -80,7 +79,7 @@ def load_tokenizer_for_bench(model_id: str) -> Any:
model_path = Path(
snapshot_download(
model_id,
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model", "*.jinja"],
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model"],
)
)
@@ -123,48 +122,8 @@ def load_tokenizer_for_bench(model_id: str) -> Any:
return hf_tokenizer
# TODO: Change back to using only transformers
try:
return AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
except (AttributeError, ValueError):
from huggingface_hub import snapshot_download
from transformers import PretrainedConfig
model_path = Path(
snapshot_download(
model_id,
allow_patterns=[
"*.json",
"*.py",
"tokenizer.model",
"*.tiktoken",
"tiktoken.model",
"*.txt",
"*.jsonl",
"*.jinja",
],
)
)
stub_kwargs: dict[str, Any] = {}
config_file = model_path / "config.json"
if config_file.exists():
with open(config_file) as f:
raw = json.load(f)
for key in (
"model_type",
"max_position_embeddings",
"vocab_size",
"bos_token_id",
"eos_token_id",
"pad_token_id",
):
if key in raw:
stub_kwargs[key] = raw[key]
return AutoTokenizer.from_pretrained(
str(model_path),
config=PretrainedConfig(**stub_kwargs),
trust_remote_code=True,
)
# Default: use AutoTokenizer
return AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
def format_peak_memory(b: float) -> str:
@@ -278,72 +237,28 @@ def run_one_completion(
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
stream: 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,
}
if not stream:
payload["stream"] = False
t0 = time.perf_counter()
out = client.post_bench_chat_completions(payload)
elapsed = time.perf_counter() - t0
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 {}
content = message.get("content") or ""
preview = content[:200] if content else ""
else:
tokens = 0
first_token_time = None
t0 = time.perf_counter()
text_parts: list[str] = []
stats = None
stats = out.get("generation_stats")
for raw_line in client.stream_bench_chat_completions(payload):
line = raw_line.strip()
if line.startswith(": generation_stats "):
with contextlib.suppress(json.JSONDecodeError):
stats = json.loads(line[len(": generation_stats ") :])
continue
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk.get("choices", [{}])[0].get("delta", {})
if delta.get("content"):
if first_token_time is None:
first_token_time = time.perf_counter()
tokens += 1
text_parts.append(delta["content"])
except json.JSONDecodeError:
pass
elapsed = time.perf_counter() - t0
preview = "".join(text_parts)[:200]
if not stats:
ttft = (first_token_time - t0) if first_token_time 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 = {
"prompt_tokens": pp_tokens,
"generation_tokens": tokens,
"prompt_tps": round(prompt_tps, 2),
"generation_tps": round(gen_tps, 2),
"peak_memory_usage": {"inBytes": 0},
}
# 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,
@@ -363,19 +278,9 @@ class PromptSizer:
def _make_counter(tokenizer: Any) -> Callable[[str], int]:
def count_fn(user_content: str) -> int:
messages = [{"role": "user", "content": user_content}]
try:
ids = tokenizer.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.deepseek_v4_encoding import (
encode_messages as encode_v4,
)
prompt = encode_v4(messages, thinking_mode="thinking")
ids = tokenizer.encode(prompt, add_special_tokens=False)
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
@@ -470,11 +375,6 @@ 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",
@@ -540,124 +440,81 @@ def main() -> int:
logger.error("[exo-bench] tokenizer usable but prompt sizing failed")
raise
# 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"
)
selected = settle_and_fetch_placements(
client, full_model_id, args, settle_timeout=args.settle_timeout
)
if reused_instance_id is not None:
# Use the existing instance directly — skip placement iteration
selected = []
download_duration_s = None
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 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:
selected = settle_and_fetch_placements(
client, full_model_id, args, settle_timeout=args.settle_timeout
)
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 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")
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:
created_instance = False
if preview is not None:
instance = preview["instance"]
instance_id = instance_id_from_instance(instance)
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}"
)
# 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)
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
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}")
time.sleep(1)
sampler: SystemMetricsSampler | None = None
if not args.no_system_metrics and preview is not None:
if not args.no_system_metrics:
nids = node_ids_from_instance(instance)
sampler = SystemMetricsSampler(
ExoClient(args.host, args.port, timeout_s=30),
@@ -666,20 +523,16 @@ 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):
_do_one(client, pp_list[0], tg_list[0])
run_one_completion(
client,
full_model_id,
pp_list[0],
tg_list[0],
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
logger.debug(f" warmup {i + 1}/{args.warmup} done")
# If pp and tg lists have same length, run in tandem (zip)
@@ -701,7 +554,14 @@ def main() -> int:
# Sequential: single request
try:
inf_t0 = time.monotonic()
row, actual_pp_tokens = _do_one(client, pp, tg)
row, actual_pp_tokens = run_one_completion(
client,
full_model_id,
pp,
tg,
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
inference_windows.append((inf_t0, time.monotonic()))
except Exception as e:
logger.error(e)
@@ -850,12 +710,10 @@ 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} "
@@ -880,16 +738,15 @@ def main() -> int:
if placement_metrics:
all_system_metrics.update(placement_metrics)
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}")
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:
+56 -427
View File
@@ -47,7 +47,6 @@ from harness import (
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -63,15 +62,6 @@ 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
@@ -281,7 +271,7 @@ def run_humaneval_test(
@dataclass
class QuestionResult:
question_id: int | str
question_id: int
prompt: str
response: str
extracted_answer: str | None
@@ -291,11 +281,7 @@ 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
@@ -531,10 +517,6 @@ 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(
@@ -548,9 +530,6 @@ 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:
@@ -567,12 +546,6 @@ 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",
@@ -581,40 +554,19 @@ async def _call_api(
)
resp.raise_for_status()
data = resp.json()
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:
content = data["choices"][0]["message"]["content"]
if not content or not content.strip():
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,
@@ -626,14 +578,8 @@ 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,
@@ -646,30 +592,8 @@ 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(
@@ -694,16 +618,10 @@ 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
@@ -734,21 +652,7 @@ 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
@@ -756,13 +660,6 @@ 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":
@@ -770,64 +667,16 @@ 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)
@@ -848,50 +697,24 @@ async def evaluate_benchmark(
raise ValueError(f"Unknown benchmark: {benchmark_name}")
async with semaphore:
if instance_failed.is_set():
return
t0 = time.monotonic()
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
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,
)
elapsed = time.monotonic() - t0
if api_result is None:
result = QuestionResult(
question_id=question_id,
question_id=idx,
prompt=prompt,
response="",
extracted_answer=None,
@@ -906,17 +729,13 @@ 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=question_id,
question_id=idx,
prompt=prompt,
response=response,
extracted_answer=extracted,
@@ -930,7 +749,7 @@ async def evaluate_benchmark(
check_aime_answer(extracted, int(gold)) if extracted else False
)
result = QuestionResult(
question_id=question_id,
question_id=idx,
prompt=prompt,
response=response,
extracted_answer=extracted,
@@ -944,7 +763,7 @@ async def evaluate_benchmark(
code = extract_code_block(response, preserve_indent=keep_indent)
if code is None:
result = QuestionResult(
question_id=question_id,
question_id=idx,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -959,7 +778,7 @@ async def evaluate_benchmark(
code,
)
result = QuestionResult(
question_id=question_id,
question_id=idx,
prompt=prompt,
response=response,
extracted_answer="pass" if passed else "fail",
@@ -974,7 +793,7 @@ async def evaluate_benchmark(
exec_meta["sample"],
)
result = QuestionResult(
question_id=question_id,
question_id=idx,
prompt=prompt,
response=response,
extracted_answer="pass" if passed else "fail",
@@ -985,7 +804,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=question_id,
question_id=idx,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -996,7 +815,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=question_id,
question_id=idx,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -1008,82 +827,24 @@ 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
# 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)
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%})"
)
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
# ---------------------------------------------------------------------------
@@ -1106,8 +867,6 @@ 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%})")
@@ -1119,10 +878,6 @@ 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:
@@ -1141,8 +896,6 @@ 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,
}
@@ -1300,11 +1053,7 @@ 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
],
@@ -1320,15 +1069,6 @@ 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:
@@ -1356,12 +1096,6 @@ 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(
@@ -1381,8 +1115,6 @@ 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."
)
@@ -1416,31 +1148,15 @@ 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(
"--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",
"--skip-instance-setup",
action="store_true",
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).",
help="Skip exo instance management (assumes model is already running).",
)
args, _ = ap.parse_known_args()
@@ -1461,26 +1177,13 @@ def main() -> int:
# Instance management
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
instance_id: str | None = None
created_instance = False
_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:
if not args.skip_instance_setup:
short_id, full_model_id = resolve_model_short_id(
client,
args.model,
force_download=args.force_download,
)
selected = settle_and_fetch_placements(
client,
full_model_id,
@@ -1495,7 +1198,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,
)
@@ -1522,18 +1225,6 @@ 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)
@@ -1543,9 +1234,10 @@ def main() -> int:
client.request_json("DELETE", f"/instance/{instance_id}")
return 1
time.sleep(1)
created_instance = True
cluster_snapshot = capture_cluster_snapshot(client)
cluster_snapshot = capture_cluster_snapshot(client)
else:
full_model_id = args.model
cluster_snapshot = None
# Auto-detect reasoning from model config
model_config = load_model_config(full_model_id)
@@ -1599,57 +1291,16 @@ 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)
@@ -1658,11 +1309,6 @@ 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,
@@ -1673,8 +1319,9 @@ def main() -> int:
concurrency=c,
limit=args.limit,
timeout=args.request_timeout,
checkpoint_path=checkpoint_path,
**eval_kwargs,
reasoning_effort=reasoning_effort,
top_p=top_p,
difficulty=args.difficulty,
)
)
if results:
@@ -1689,18 +1336,10 @@ 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,
@@ -1711,8 +1350,9 @@ def main() -> int:
concurrency=args.num_concurrent,
limit=args.limit,
timeout=args.request_timeout,
checkpoint_path=checkpoint_path,
**eval_kwargs,
reasoning_effort=reasoning_effort,
top_p=top_p,
difficulty=args.difficulty,
)
)
if results:
@@ -1726,25 +1366,14 @@ def main() -> int:
scores,
cluster=cluster_snapshot,
)
# Clean up checkpoint on success
if checkpoint_path.exists():
checkpoint_path.unlink()
finally:
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"
)
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)
return 0
+12 -65
View File
@@ -6,7 +6,6 @@ import http.client
import json
import os
import time
from collections.abc import Iterator
from typing import Any
from urllib.parse import urlencode
@@ -70,30 +69,6 @@ class ExoClient:
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.request_json("POST", "/bench/chat/completions", body=payload)
def stream_bench_chat_completions(self, payload: dict[str, Any]) -> Iterator[str]:
"""POST /bench/chat/completions with stream=True, yielding raw SSE lines."""
payload = {**payload, "stream": True}
data = json.dumps(payload).encode("utf-8")
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
try:
conn.request(
"POST",
"/bench/chat/completions",
body=data,
headers={
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
)
resp = conn.getresponse()
if resp.status >= 400:
raw = resp.read().decode("utf-8", errors="replace")
raise ExoHttpError(resp.status, resp.reason, raw[:300])
for line in resp:
yield line.decode("utf-8", errors="replace")
finally:
conn.close()
def get_state_path(self, path: str) -> Any:
try:
return self.request_json("GET", f"/state/{path}")
@@ -293,15 +268,11 @@ def sharding_filter(sharding: str, wanted: str) -> bool:
def fetch_and_filter_placements(
client: ExoClient,
full_model_id: str,
args: argparse.Namespace,
node_id: str | None = None,
client: ExoClient, full_model_id: str, args: argparse.Namespace
) -> list[dict[str, Any]]:
params: dict[str, str] = {"model_id": full_model_id}
if node_id is not None:
params["node_ids"] = node_id
previews_resp = client.request_json("GET", "/instance/previews", params=params)
previews_resp = client.request_json(
"GET", "/instance/previews", params={"model_id": full_model_id}
)
previews = previews_resp.get("previews") or []
selected: list[dict[str, Any]] = []
@@ -361,9 +332,8 @@ def settle_and_fetch_placements(
full_model_id: str,
args: argparse.Namespace,
settle_timeout: float = 0,
node_id: str | None = None,
) -> list[dict[str, Any]]:
selected = fetch_and_filter_placements(client, full_model_id, args, node_id=node_id)
selected = fetch_and_filter_placements(client, full_model_id, args)
if not selected and settle_timeout > 0:
backoff = _SETTLE_INITIAL_BACKOFF_S
@@ -376,9 +346,7 @@ def settle_and_fetch_placements(
)
time.sleep(min(backoff, remaining))
backoff = min(backoff * _SETTLE_BACKOFF_MULTIPLIER, _SETTLE_MAX_BACKOFF_S)
selected = fetch_and_filter_placements(
client, full_model_id, args, node_id=node_id
)
selected = fetch_and_filter_placements(client, full_model_id, args)
return selected
@@ -494,8 +462,9 @@ def run_planning_phase(
)
logger.info(f"Started download on {node_id}")
# Wait for downloads (no timeout — poll until complete or failed)
while True:
# Wait for downloads
start = time.time()
while time.time() - start < timeout:
all_done = True
for node_id in node_ids:
node_downloads = client.get_node_downloads(node_id) or []
@@ -545,24 +514,9 @@ def run_planning_phase(
if download_t0 is not None:
return time.perf_counter() - download_t0
return None
time.sleep(10)
time.sleep(1)
def find_existing_instance(client: ExoClient, model_id: str) -> str | None:
"""Find an existing running instance for the given model."""
try:
state = client.request_json("GET", "/state")
except Exception:
return None
for inst_id, inst in state.get("instances", {}).items():
# Instance structure is nested: {"MlxJacclInstance": {"shardAssignments": {"modelId": ...}}}
for _inst_type, inner in inst.items():
if not isinstance(inner, dict):
continue
sa = inner.get("shardAssignments", {})
if sa.get("modelId") == model_id:
return inst_id
return None
raise TimeoutError("Downloads did not complete in time")
def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
@@ -589,9 +543,7 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
help="Only consider placements using >= this many nodes.",
)
ap.add_argument(
"--instance-meta",
choices=["ring", "jaccl", "vllm", "both"],
default="both",
"--instance-meta", choices=["ring", "jaccl", "both"], default="both"
)
ap.add_argument(
"--sharding", choices=["pipeline", "tensor", "both"], default="both"
@@ -620,8 +572,3 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
action="store_true",
help="Delete existing models from smallest to largest to make room for benchmark model.",
)
ap.add_argument(
"--reuse-instance",
action="store_true",
help="Reuse an existing running instance for this model instead of creating a new one.",
)
-36
View File
@@ -1,36 +0,0 @@
# 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, 8192]
tg = [128]
repeat = 1
warmup = 0
json_out = "bench/prefill_decode_results.json"
[prefill]
model = "sakamakismile/Qwen3.6-27B-NVFP4"
node = "gx10-de89"
instance_meta = "vllm"
sharding = "pipeline"
min_nodes = 1
max_nodes = 1
[decode]
model = "mlx-community/Qwen3.6-27B-4bit"
node = "Ryuichis MacBook Pro"
instance_meta = "ring"
sharding = "pipeline"
min_nodes = 1
max_nodes = 1
-869
View File
@@ -1,869 +0,0 @@
# 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,
SystemMetricsSampler,
format_peak_memory,
load_tokenizer_for_bench,
parse_int_list,
)
from harness import (
ExoClient,
ExoHttpError,
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],
sampler: SystemMetricsSampler | None = None,
) -> 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]] = []
inference_windows: list[tuple[float, float]] = []
for r in range(repeat):
time.sleep(2)
try:
inf_t0 = time.monotonic()
row, actual_pp_tokens = run_one(client, model_id, pp, tg, prompt_sizer)
inference_windows.append((inf_t0, time.monotonic()))
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)
energy_str = ""
if sampler is not None and inference_windows:
joules = sum(
sampler.energy_between(t0, t1) for t0, t1 in inference_windows
)
inf_seconds = sum(t1 - t0 for t0, t1 in inference_windows)
avg_watts = joules / inf_seconds if inf_seconds > 0 else 0.0
energy_per_run = joules / len(runs) if runs else 0.0
energy_str = (
f" energy={joules:.1f}J ({avg_watts:.1f}W avg over "
f"{inf_seconds:.1f}s inference, {energy_per_run:.1f}J/run)"
)
for run_row, (t0, t1) in zip(runs, inference_windows, strict=False):
run_row["energy_joules"] = sampler.energy_between(t0, t1)
run_row["inference_window_s"] = t1 - t0
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{energy_str}"
)
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():
energy_runs = [x.get("energy_joules") for x in runs if "energy_joules" in x]
window_runs = [
x.get("inference_window_s") for x in runs if "inference_window_s" in x
]
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),
"prompt_tokens": mean(x["stats"]["prompt_tokens"] for x in runs),
"gen_tokens": mean(x["stats"]["generation_tokens"] for x in runs),
"energy_j": mean(energy_runs) if energy_runs else 0.0,
"inference_window_s": mean(window_runs) if window_runs else 0.0,
}
return out
def _normalised_seconds(summary: dict[str, float], pp: int, tg: int) -> float | None:
"""Wall-clock time implied by reported tps for the *configured* pp/tg.
elapsed_s is not comparable across phases when models EOS at different
lengths. This formula reconstructs "what would this phase take to do
pp prompt tokens + tg generation tokens" using its own reported rates.
"""
p_tps = summary.get("prompt_tps", 0.0)
g_tps = summary.get("gen_tps", 0.0)
if p_tps <= 0 or g_tps <= 0:
return None
return pp / p_tps + tg / g_tps
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 = 110
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':>9} {'norm':>9} "
f"{'prompt_tps':>11} {'gen_tps':>8} "
f"{'p_tok':>6} {'g_tok':>6} "
f"{'energy':>9} {'avg_W':>7}"
)
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} {'':>9} {'':>9} "
f"{'':>11} {'':>8} {'':>6} {'':>6} "
f"{'':>9} {'':>7}"
)
continue
norm = _normalised_seconds(summary, pp, tg)
norm_str = f"{norm:>8.2f}s" if norm is not None else f"{'':>9}"
energy = summary.get("energy_j", 0.0)
window = summary.get("inference_window_s", 0.0)
energy_str = f"{energy:>8.1f}J" if energy > 0 else f"{'':>9}"
avg_w = energy / window if window > 0 else 0.0
avg_w_str = f"{avg_w:>6.1f}W" if avg_w > 0 else f"{'':>7}"
logger.info(
f" {label:<16} "
f"{summary['elapsed_s']:>8.2f}s "
f"{norm_str} "
f"{summary['prompt_tps']:>11.1f} "
f"{summary['gen_tps']:>8.2f} "
f"{summary['prompt_tokens']:>6.0f} "
f"{summary['gen_tokens']:>6.0f} "
f"{energy_str} "
f"{avg_w_str}"
)
d = disagg.get(key)
da = decode_alone.get(key)
pa = prefill_alone.get(key)
d_norm = _normalised_seconds(d, pp, tg) if d else None
if d_norm and da:
da_norm = _normalised_seconds(da, pp, tg)
if da_norm:
logger.info(
f" norm speedup vs decode_alone: {da_norm / d_norm:.2f}x "
f"(prefill {d['prompt_tps'] / da['prompt_tps']:.2f}x, "
f"decode {d['gen_tps'] / da['gen_tps']:.2f}x)"
)
if d_norm and pa:
pa_norm = _normalised_seconds(pa, pp, tg)
if pa_norm:
logger.info(
f" norm speedup vs prefill_alone: {pa_norm / d_norm:.2f}x "
f"(prefill {d['prompt_tps'] / pa['prompt_tps']:.2f}x, "
f"decode {d['gen_tps'] / pa['gen_tps']:.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
sampler_nodes = sorted(
{
*node_ids_from_instance(prefill_instance),
*node_ids_from_instance(decode_instance),
}
)
sampler = SystemMetricsSampler(
ExoClient(args.host, args.port, timeout_s=30), sampler_nodes
)
sampler.start()
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,
sampler=sampler,
)
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,
sampler=sampler,
)
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,
sampler=sampler,
)
all_rows.extend(decode_alone_rows)
_print_diff(disagg_rows, decode_alone_rows, prefill_alone_rows)
finally:
sampler.stop()
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())
@@ -202,7 +202,6 @@
let instanceType: string | null = null;
if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
else if (instanceTag === "VllmInstance") instanceType = "vLLM";
let sharding: string | null = null;
const inst = instance as {
+2 -292
View File
@@ -9,7 +9,7 @@
*/
interface Props {
/** "macbook pro" | "mac studio" | "mac mini" | "dgx spark" | "linux" etc. */
/** "macbook pro" | "mac studio" | "mac mini" etc. */
deviceType: string;
/** Center X coordinate in SVG space */
cx: number;
@@ -38,43 +38,10 @@
const LOGO_NATIVE_WIDTH = 814;
const LOGO_NATIVE_HEIGHT = 1000;
// NVIDIA logo SVG path
const NVIDIA_LOGO_PATH =
"M0.81 0.429V0.299c0.013 -0.001 0.026 -0.002 0.038 -0.002 0.355 -0.011 0.588 0.306 0.588 0.306S1.186 0.952 0.916 0.952c-0.036 0 -0.071 -0.006 -0.105 -0.017V0.542c0.138 0.017 0.166 0.078 0.249 0.216l0.185 -0.155s-0.135 -0.177 -0.362 -0.177c-0.024 -0.001 -0.048 0.001 -0.072 0.003m0 -0.429v0.194l0.038 -0.002c0.494 -0.017 0.816 0.405 0.816 0.405s-0.37 0.45 -0.754 0.45c-0.034 0 -0.066 -0.003 -0.099 -0.009v0.12c0.027 0.003 0.055 0.006 0.082 0.006 0.358 0 0.618 -0.183 0.869 -0.399 0.042 0.034 0.212 0.114 0.247 0.15 -0.238 0.2 -0.794 0.361 -1.11 0.361 -0.03 0 -0.059 -0.002 -0.088 -0.005v0.169h1.362V0zm0 0.935v0.102c-0.331 -0.059 -0.423 -0.404 -0.423 -0.404s0.159 -0.176 0.423 -0.205v0.112h-0.001C0.671 0.524 0.562 0.654 0.562 0.654s0.062 0.218 0.248 0.282m-0.588 -0.316s0.196 -0.29 0.589 -0.32V0.194C0.376 0.229 0 0.597 0 0.597s0.213 0.616 0.81 0.672v-0.112c-0.438 -0.054 -0.588 -0.538 -0.588 -0.538";
const wireColor = "rgba(179,179,179,0.8)";
const strokeWidth = 1.5;
const modelLower = $derived(deviceType.toLowerCase());
const isSpark = $derived(
modelLower.includes("dgx") || modelLower.includes("gx10"),
);
const isLinux = $derived(!isSpark && modelLower.startsWith("linux"));
const isLinuxLaptop = $derived(isLinux && modelLower.includes("laptop"));
// ── DGX Spark dimensions ──
const dgxW = $derived(size * 1.55);
const dgxH = $derived(size * 0.58);
const dgxX = $derived(cx - dgxW / 2);
const dgxY = $derived(cy - dgxH / 2);
const dgxChassisX = $derived(dgxX - dgxW * 0.03);
const dgxChassisW = $derived(dgxW * 1.05);
const dgxHandleW = $derived(dgxW * 0.27);
const dgxHandleGap = $derived(dgxH * 0.05);
const dgxHandleH = $derived(dgxH - dgxHandleGap * 2);
const dgxHandleY = $derived(dgxY + dgxHandleGap);
const dgxInnerHandleW = $derived(dgxW * 0.12);
const dgxInnerHandleH = $derived(dgxHandleH - dgxH * 0.06);
const dgxLeftHandleX = $derived(dgxX + 4);
const dgxRightHandleX = $derived(dgxX + dgxW - dgxHandleW - 4);
const dgxClipId = $derived(`di-dgx-${uid}`);
const dgxTextureId = $derived(`di-dgx-tex-${uid}`);
// ── Linux Desktop dimensions (reuses Mac Studio proportions) ──
const linuxDesktopClipId = $derived(`di-linux-desktop-${uid}`);
// ── Linux Laptop dimensions (reuses MacBook proportions) ──
const linuxScreenClipId = $derived(`di-linux-screen-${uid}`);
// ── Mac Studio dimensions (same ratios as TopologyGraph) ──
const studioW = $derived(size * 1.25);
@@ -147,264 +114,7 @@
const studioClipId = $derived(`di-studio-${uid}`);
</script>
{#if isSpark}
<!-- DGX Spark -->
<defs>
<clipPath id={dgxClipId}>
<rect x={dgxX} y={dgxY} width={dgxW} height={dgxH} rx="3" />
</clipPath>
<pattern
id={dgxTextureId}
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill="#6f6248" />
<circle cx="2" cy="2" r="1" fill="#5a4f3b" opacity="0.5" />
<circle cx="6" cy="6" r="1" fill="#4a4232" opacity="0.45" />
</pattern>
</defs>
<!-- Main body -->
<rect
x={dgxChassisX}
y={dgxY}
width={dgxChassisW}
height={dgxH}
rx="3"
fill="url(#{dgxTextureId})"
stroke={wireColor}
stroke-width={strokeWidth}
/>
<!-- Side border accents -->
<rect
x={dgxChassisX}
y={dgxY}
width={dgxW * 0.02}
height={dgxH}
fill="#8a7a56"
/>
<rect
x={dgxChassisX + dgxChassisW - dgxW * 0.02}
y={dgxY}
width={dgxW * 0.02}
height={dgxH}
fill="#8a7a56"
/>
<!-- Memory fill -->
{#if ramPercent > 0}
<rect
x={dgxX}
y={dgxY + dgxH - (ramPercent / 100) * dgxH}
width={dgxW}
height={(ramPercent / 100) * dgxH}
fill="rgba(255,215,0,0.45)"
clip-path="url(#{dgxClipId})"
/>
{/if}
<!-- Left handle -->
<rect
x={dgxLeftHandleX}
y={dgxHandleY}
width={dgxHandleW}
height={dgxHandleH}
rx="2.4"
fill="#b3a170"
stroke="#403723"
stroke-width="0.7"
/>
<rect
x={dgxLeftHandleX + dgxHandleW * 0.06}
y={dgxHandleY + dgxH * 0.03}
width={dgxInnerHandleW}
height={dgxInnerHandleH}
rx="1.6"
fill="#8a7a56"
/>
<!-- Right handle -->
<rect
x={dgxRightHandleX}
y={dgxHandleY}
width={dgxHandleW}
height={dgxHandleH}
rx="2.4"
fill="#b3a170"
stroke="#403723"
stroke-width="0.7"
/>
<rect
x={dgxRightHandleX + dgxHandleW - dgxInnerHandleW - dgxHandleW * 0.08}
y={dgxHandleY + dgxH * 0.03}
width={dgxInnerHandleW}
height={dgxInnerHandleH}
rx="1.6"
fill="#8a7a56"
/>
<!-- NVIDIA logo (rotated 90deg on left handle) -->
{@const badgeW = dgxW * 0.09}
{@const badgeH = dgxHandleH * 0.5}
{@const badgeX = dgxLeftHandleX + dgxHandleW - badgeW - dgxHandleW * 0.06}
{@const badgeYPos = dgxHandleY + (dgxHandleH - badgeH) / 2}
{@const textSz = badgeW * 0.58}
{@const logoW = textSz * 1.2}
{@const logoH = logoW * (1.438 / 2.174)}
{@const ctrX = badgeX + badgeW / 2 - badgeW * 0.03}
{@const ctrY = badgeYPos + badgeH / 2}
{@const labelGap = badgeW * 0.15}
{@const totalW = logoW + labelGap + textSz * 3.6}
<g transform="rotate(90 {ctrX} {ctrY})">
<svg
x={ctrX - totalW / 2}
y={ctrY - logoH / 2}
width={logoW}
height={logoH}
viewBox="0 0 2.174 1.438"
>
<path d={NVIDIA_LOGO_PATH} fill="#76b900" />
</svg>
<text
x={ctrX - totalW / 2 + logoW + labelGap}
y={ctrY}
text-anchor="start"
dominant-baseline="middle"
fill="#8a7a56"
font-size={textSz}
font-family="monospace"
font-weight="700">NVIDIA</text
>
</g>
{:else if isLinuxLaptop}
<!-- Linux Laptop — MacBook shape with Tux logo -->
<defs>
<clipPath id={linuxScreenClipId}>
<rect
x={mbScreenX + mbBezel}
y={mbY + mbBezel}
width={mbScreenW - mbBezel * 2}
height={mbScreenH - mbBezel * 2}
rx="2"
/>
</clipPath>
</defs>
<rect
x={mbScreenX}
y={mbY}
width={mbScreenW}
height={mbScreenH}
rx="3"
fill="#1a1a1a"
stroke={wireColor}
stroke-width={strokeWidth}
/>
<rect
x={mbScreenX + mbBezel}
y={mbY + mbBezel}
width={mbScreenW - mbBezel * 2}
height={mbScreenH - mbBezel * 2}
rx="2"
fill="#0a0a12"
/>
{#if ramPercent > 0}
<rect
x={mbScreenX + mbBezel}
y={mbY + mbBezel + (mbMemTotalH - mbMemH)}
width={mbScreenW - mbBezel * 2}
height={mbMemH}
fill="rgba(255,215,0,0.85)"
clip-path="url(#{linuxScreenClipId})"
/>
{/if}
<!-- Terminal prompt on screen -->
<text
x={cx}
y={mbY + mbScreenH / 2}
text-anchor="middle"
dominant-baseline="middle"
fill="#FFFFFF"
opacity="0.9"
font-size={mbScreenH * 0.25}
font-family="SF Mono, Monaco, monospace"
font-weight="700">{">_"}</text
>
<path
d="M {mbBaseTopX} {mbBaseY} L {mbBaseTopX +
mbBaseTopW} {mbBaseY} L {mbBaseBottomX + mbBaseBottomW} {mbBaseY +
mbBaseH} L {mbBaseBottomX} {mbBaseY + mbBaseH} Z"
fill="#2c2c2c"
stroke={wireColor}
stroke-width="1"
/>
<rect
x={mbKbX}
y={mbKbY}
width={mbKbW}
height={mbKbH}
fill="rgba(0,0,0,0.2)"
rx="2"
/>
<rect
x={mbTpX}
y={mbTpY}
width={mbTpW}
height={mbTpH}
fill="rgba(255,255,255,0.08)"
rx="2"
/>
{:else if isLinux}
<!-- Linux Desktop — Mac Studio shape with Tux logo -->
<defs>
<clipPath id={linuxDesktopClipId}>
<rect
x={studioX}
y={studioY + studioTopH}
width={studioW}
height={studioH - studioTopH}
rx={studioCorner - 1}
/>
</clipPath>
</defs>
<rect
x={studioX}
y={studioY}
width={studioW}
height={studioH}
rx={studioCorner}
fill="#1a1a1a"
stroke={wireColor}
stroke-width={strokeWidth}
/>
{#if ramPercent > 0}
<rect
x={studioX}
y={studioY + studioTopH + (studioMemTotalH - studioMemH)}
width={studioW}
height={studioMemH}
fill="rgba(255,215,0,0.75)"
clip-path="url(#{linuxDesktopClipId})"
/>
{/if}
<!-- Terminal prompt on front face -->
<text
x={cx}
y={studioY + studioTopH + (studioH - studioTopH) / 2}
text-anchor="middle"
dominant-baseline="middle"
fill="rgba(255,255,255,0.5)"
font-size={(studioH - studioTopH) * 0.4}
font-family="SF Mono, Monaco, monospace"
font-weight="700">{">_"}</text
>
{:else if modelLower === "mac studio" || modelLower === "mac mini"}
{#if modelLower === "mac studio" || modelLower === "mac mini"}
<!-- Mac Studio / Mac Mini -->
<defs>
<clipPath id={studioClipId}>
@@ -1,8 +1,5 @@
<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;
@@ -300,28 +297,5 @@
</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>
+4 -85
View File
@@ -23,7 +23,7 @@
} | null;
nodes?: Record<string, NodeInfo>;
sharding?: "Pipeline" | "Tensor";
runtime?: "MlxRing" | "MlxJaccl" | "Vllm";
runtime?: "MlxRing" | "MlxJaccl";
onLaunch?: () => void;
tags?: string[];
apiPreview?: PlacementPreview | null;
@@ -168,10 +168,8 @@
function getDeviceType(
name: string,
): "macbook" | "studio" | "mini" | "dgx" | "linux" | "unknown" {
): "macbook" | "studio" | "mini" | "unknown" {
const lower = name.toLowerCase();
if (lower.includes("dgx") || lower.includes("gx10")) return "dgx";
if (lower.includes("linux")) return "linux";
if (lower.includes("macbook")) return "macbook";
if (lower.includes("studio")) return "studio";
if (lower.includes("mini")) return "mini";
@@ -578,17 +576,13 @@
class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40"
title={runtime === "MlxRing"
? "Ring: standard networking. Works over any connection (Wi-Fi, Ethernet, Thunderbolt)."
: runtime === "MlxJaccl"
? "RDMA: direct memory access over Thunderbolt. Significantly faster for multi-device inference."
: "vLLM: NVIDIA CUDA inference engine."}
: "RDMA: direct memory access over Thunderbolt. Significantly faster for multi-device inference."}
>
{runtime === "MlxRing"
? "MLX Ring"
: runtime === "MlxJaccl"
? "MLX RDMA"
: runtime === "Vllm"
? "vLLM"
: runtime}
: runtime}
</span>
</div>
@@ -996,81 +990,6 @@
/>
{/if}
</g>
{:else if node.deviceType === "dgx"}
<!-- DGX Spark icon -->
{@const s = node.iconSize}
{@const dgxW = s * 1.4}
{@const dgxH = s * 0.52}
<g transform="translate({-dgxW / 2}, {-dgxH / 2})">
<!-- Chassis -->
<rect
x="0"
y="0"
width={dgxW}
height={dgxH}
rx="2"
fill="#6f6248"
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
stroke-width="1.5"
/>
<!-- Side accents -->
<rect
x="0"
y="0"
width={dgxW * 0.02}
height={dgxH}
fill="#8a7a56"
/>
<rect
x={dgxW - dgxW * 0.02}
y="0"
width={dgxW * 0.02}
height={dgxH}
fill="#8a7a56"
/>
<!-- Left handle -->
<rect
x={dgxW * 0.04}
y={dgxH * 0.08}
width={dgxW * 0.22}
height={dgxH * 0.84}
rx="2"
fill="#b3a170"
stroke="#403723"
stroke-width="0.5"
/>
<!-- Right handle -->
<rect
x={dgxW - dgxW * 0.04 - dgxW * 0.22}
y={dgxH * 0.08}
width={dgxW * 0.22}
height={dgxH * 0.84}
rx="2"
fill="#b3a170"
stroke="#403723"
stroke-width="0.5"
/>
<!-- Memory fill -->
<rect
x="2"
y={dgxH - dgxH * (node.currentPercent / 100)}
width={dgxW - 4}
height={dgxH * (node.currentPercent / 100)}
fill="rgba(255,215,0,0.35)"
/>
{#if node.modelUsageGB > 0 && node.isUsed}
<rect
x="2"
y={dgxH - dgxH * (node.newPercent / 100)}
width={dgxW - 4}
height={dgxH *
((node.newPercent - node.currentPercent) / 100)}
fill="#FFD700"
filter="url(#memGlow-{filterId})"
class="animate-pulse-slow"
/>
{/if}
</g>
{:else}
<!-- Unknown device - hexagon -->
<g
@@ -9,7 +9,6 @@
capabilities?: string[];
family?: string;
is_custom?: boolean;
requires_vllm?: boolean;
}
interface ModelGroup {
@@ -20,7 +19,6 @@
variants: ModelInfo[];
smallestVariant: ModelInfo;
hasMultipleVariants: boolean;
requiresVllm: boolean;
}
type DownloadAvailability = {
@@ -215,14 +213,6 @@
<span class="font-mono text-sm text-white truncate">
{group.name}
</span>
{#if group.requiresVllm}
<span
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-orange-500/15 text-orange-300 border border-orange-400/30 flex-shrink-0 tracking-wider uppercase"
title="Requires vLLM runtime"
>
vLLM
</span>
{/if}
<!-- Capability icons -->
{#each group.capabilities.filter((c) => c !== "text") as cap}
{#if cap === "thinking"}
@@ -533,15 +523,6 @@
{variant.quantization || "default"}
</span>
{#if variant.requires_vllm}
<span
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-orange-500/15 text-orange-300 border border-orange-400/30 flex-shrink-0 tracking-wider uppercase"
title="Requires vLLM runtime"
>
vLLM
</span>
{/if}
<!-- Size -->
<span
class="text-xs font-mono flex-1 {getSizeClassForFitStatus(
@@ -647,7 +628,6 @@
variants: [variant],
smallestVariant: variant,
hasMultipleVariants: false,
requiresVllm: variant.requires_vllm === true,
});
}}
title="View variant details"
@@ -22,7 +22,6 @@
is_custom?: boolean;
tasks?: string[];
hugging_face_id?: string;
requires_vllm?: boolean;
}
interface ModelGroup {
@@ -33,7 +32,6 @@
variants: ModelInfo[];
smallestVariant: ModelInfo;
hasMultipleVariants: boolean;
requiresVllm: boolean;
}
interface FilterState {
@@ -398,7 +396,6 @@
variants: [],
smallestVariant: model,
hasMultipleVariants: false,
requiresVllm: true,
});
}
@@ -433,7 +430,6 @@
(a.storage_size_megabytes || 0) - (b.storage_size_megabytes || 0),
);
group.hasMultipleVariants = group.variants.length > 1;
group.requiresVllm = group.variants.every((v) => v.requires_vllm);
}
// Convert to array and sort by smallest variant size (biggest first)
@@ -591,7 +587,6 @@
variants: [model],
smallestVariant: model,
hasMultipleVariants: false,
requiresVllm: model.requires_vllm === true,
});
}
}
@@ -1170,17 +1165,6 @@
<span class="text-white/40">Variants:</span>
<span class="text-white/70">{infoGroup.variants.length}</span>
</div>
{#if infoGroup.requiresVllm}
<div class="flex items-center gap-2">
<span class="text-white/40">Runtime:</span>
<span
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-orange-500/15 text-orange-300 border border-orange-400/30 tracking-wider uppercase"
>
vLLM
</span>
<span class="text-white/40 text-[11px]">required</span>
</div>
{/if}
{#if infoGroup.variants.length > 0}
<div class="mt-3 pt-3 border-t border-exo-yellow/10">
<span class="text-white/40">Available quantizations:</span>
@@ -1,565 +0,0 @@
<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-bound 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>
@@ -117,10 +117,6 @@
const LOGO_NATIVE_WIDTH = 814;
const LOGO_NATIVE_HEIGHT = 1000;
// NVIDIA logo SVG path (from exo-nvidia)
const NVIDIA_LOGO_PATH =
"M0.81 0.429V0.299c0.013 -0.001 0.026 -0.002 0.038 -0.002 0.355 -0.011 0.588 0.306 0.588 0.306S1.186 0.952 0.916 0.952c-0.036 0 -0.071 -0.006 -0.105 -0.017V0.542c0.138 0.017 0.166 0.078 0.249 0.216l0.185 -0.155s-0.135 -0.177 -0.362 -0.177c-0.024 -0.001 -0.048 0.001 -0.072 0.003m0 -0.429v0.194l0.038 -0.002c0.494 -0.017 0.816 0.405 0.816 0.405s-0.37 0.45 -0.754 0.45c-0.034 0 -0.066 -0.003 -0.099 -0.009v0.12c0.027 0.003 0.055 0.006 0.082 0.006 0.358 0 0.618 -0.183 0.869 -0.399 0.042 0.034 0.212 0.114 0.247 0.15 -0.238 0.2 -0.794 0.361 -1.11 0.361 -0.03 0 -0.059 -0.002 -0.088 -0.005v0.169h1.362V0zm0 0.935v0.102c-0.331 -0.059 -0.423 -0.404 -0.423 -0.404s0.159 -0.176 0.423 -0.205v0.112h-0.001C0.671 0.524 0.562 0.654 0.562 0.654s0.062 0.218 0.248 0.282m-0.588 -0.316s0.196 -0.29 0.589 -0.32V0.194C0.376 0.229 0 0.597 0 0.597s0.213 0.616 0.81 0.672v-0.112c-0.438 -0.054 -0.588 -0.538 -0.588 -0.538";
function formatBytes(bytes: number, decimals = 1): string {
if (!bytes || bytes === 0) return "0B";
const k = 1024;
@@ -558,13 +554,6 @@
const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
const modelLower = modelId.toLowerCase();
const identity = identitiesData[nodeInfo.id];
const nameLower = (friendlyName || "").toLowerCase();
const isSpark = modelLower.includes("dgx") || modelLower.includes("gx10");
const isLinux =
!isSpark &&
(modelLower.startsWith("linux") || identity?.osVersion === "Linux");
const isLinuxLaptop = isLinux && modelLower.includes("laptop");
// Check node states for styling
const isHighlighted = highlightedNodes.has(nodeInfo.id);
@@ -634,382 +623,7 @@
`${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
);
if (isSpark) {
// NVIDIA DGX Spark — gold chassis with textured front, side handles, and NVIDIA badge
iconBaseWidth = nodeRadius * 1.55;
iconBaseHeight = nodeRadius * 0.58;
const x = nodeInfo.x - iconBaseWidth / 2;
const y = nodeInfo.y - iconBaseHeight / 2;
const chassisX = x - iconBaseWidth * 0.03;
const chassisWidth = iconBaseWidth * 1.05;
const cornerRadius = 3;
const dgxClipId = `dgx-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
defs
.append("clipPath")
.attr("id", dgxClipId)
.append("rect")
.attr("x", x)
.attr("y", y)
.attr("width", iconBaseWidth)
.attr("height", iconBaseHeight)
.attr("rx", cornerRadius);
// Chassis texture pattern
const textureId = `chassis-texture-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
defs
.append("pattern")
.attr("id", textureId)
.attr("patternUnits", "userSpaceOnUse")
.attr("width", 8)
.attr("height", 8);
const texturePattern = defs.select(`#${textureId}`);
texturePattern
.append("rect")
.attr("width", 8)
.attr("height", 8)
.attr("fill", "#6f6248");
texturePattern
.append("circle")
.attr("cx", 2)
.attr("cy", 2)
.attr("r", 1)
.attr("fill", "#5a4f3b")
.attr("opacity", 0.5);
texturePattern
.append("circle")
.attr("cx", 6)
.attr("cy", 6)
.attr("r", 1)
.attr("fill", "#4a4232")
.attr("opacity", 0.45);
// Main body
nodeG
.append("rect")
.attr("class", "node-outline")
.attr("x", chassisX)
.attr("y", y)
.attr("width", chassisWidth)
.attr("height", iconBaseHeight)
.attr("rx", cornerRadius)
.attr("fill", `url(#${textureId})`)
.attr("stroke", wireColor)
.attr("stroke-width", strokeWidth);
// Side border accents
const sideThickness = iconBaseWidth * 0.02;
nodeG
.append("rect")
.attr("x", chassisX)
.attr("y", y)
.attr("width", sideThickness)
.attr("height", iconBaseHeight)
.attr("fill", "#8a7a56");
nodeG
.append("rect")
.attr("x", chassisX + chassisWidth - sideThickness)
.attr("y", y)
.attr("width", sideThickness)
.attr("height", iconBaseHeight)
.attr("fill", "#8a7a56");
// Memory fill (bottom up)
if (ramUsagePercent > 0) {
const memFillHeight = (ramUsagePercent / 100) * iconBaseHeight;
nodeG
.append("rect")
.attr("x", x)
.attr("y", y + iconBaseHeight - memFillHeight)
.attr("width", iconBaseWidth)
.attr("height", memFillHeight)
.attr("fill", "rgba(255,215,0,0.45)")
.attr("clip-path", `url(#${dgxClipId})`);
}
// Side handles with inner recess
const handleWidth = iconBaseWidth * 0.27;
const handleGap = iconBaseHeight * 0.05;
const handleHeight = iconBaseHeight - handleGap * 2;
const handleY = y + handleGap;
const innerHandleWidth = iconBaseWidth * 0.12;
const innerHandleHeight = handleHeight - iconBaseHeight * 0.06;
const leftHandleX = x + 4;
const rightHandleX = x + iconBaseWidth - handleWidth - 4;
// Left handle
nodeG
.append("rect")
.attr("x", leftHandleX)
.attr("y", handleY)
.attr("width", handleWidth)
.attr("height", handleHeight)
.attr("rx", 2.4)
.attr("fill", "#b3a170")
.attr("stroke", "#403723")
.attr("stroke-width", 0.7);
nodeG
.append("rect")
.attr("x", leftHandleX + handleWidth * 0.06)
.attr("y", handleY + iconBaseHeight * 0.03)
.attr("width", innerHandleWidth)
.attr("height", innerHandleHeight)
.attr("rx", 1.6)
.attr("fill", "#8a7a56");
// Right handle
nodeG
.append("rect")
.attr("x", rightHandleX)
.attr("y", handleY)
.attr("width", handleWidth)
.attr("height", handleHeight)
.attr("rx", 2.4)
.attr("fill", "#b3a170")
.attr("stroke", "#403723")
.attr("stroke-width", 0.7);
nodeG
.append("rect")
.attr(
"x",
rightHandleX + handleWidth - innerHandleWidth - handleWidth * 0.08,
)
.attr("y", handleY + iconBaseHeight * 0.03)
.attr("width", innerHandleWidth)
.attr("height", innerHandleHeight)
.attr("rx", 1.6)
.attr("fill", "#8a7a56");
// NVIDIA logo + text label (rotated 90 deg on left handle)
const badgeWidth = iconBaseWidth * 0.09;
const badgeHeight = handleHeight * 0.5;
const badgeX =
leftHandleX + handleWidth - badgeWidth - handleWidth * 0.06;
const badgeY = handleY + (handleHeight - badgeHeight) / 2;
const textSize = badgeWidth * 0.58;
const logoWidth = textSize * 1.2;
const logoHeight = logoWidth * (1.438 / 2.174);
const centerX = badgeX + badgeWidth / 2 - badgeWidth * 0.03;
const centerY = badgeY + badgeHeight / 2;
const gap = badgeWidth * 0.15;
const totalWidth = logoWidth + gap + textSize * 3.6;
const labelGroup = nodeG
.append("g")
.attr("transform", `rotate(90 ${centerX} ${centerY})`);
labelGroup
.append("svg")
.attr("x", centerX - totalWidth / 2)
.attr("y", centerY - logoHeight / 2)
.attr("width", logoWidth)
.attr("height", logoHeight)
.attr("viewBox", "0 0 2.174 1.438")
.append("path")
.attr("d", NVIDIA_LOGO_PATH)
.attr("fill", "#76b900");
labelGroup
.append("text")
.attr("x", centerX - totalWidth / 2 + logoWidth + gap)
.attr("y", centerY)
.attr("text-anchor", "start")
.attr("dominant-baseline", "middle")
.attr("fill", "#8a7a56")
.attr("font-size", textSize)
.attr("font-family", "monospace")
.attr("font-weight", "700")
.text("NVIDIA");
} else if (isLinuxLaptop) {
// Linux Laptop — same shape as MacBook but with Tux logo
iconBaseWidth = nodeRadius * 1.6;
iconBaseHeight = nodeRadius * 1.15;
const x = nodeInfo.x - iconBaseWidth / 2;
const y = nodeInfo.y - iconBaseHeight / 2;
const screenHeight = iconBaseHeight * 0.7;
const baseHeight = iconBaseHeight * 0.3;
const screenWidth = iconBaseWidth * 0.85;
const screenX = nodeInfo.x - screenWidth / 2;
const screenBezel = 3;
const linuxScreenClipId = `linux-screen-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
defs
.append("clipPath")
.attr("id", linuxScreenClipId)
.append("rect")
.attr("x", screenX + screenBezel)
.attr("y", y + screenBezel)
.attr("width", screenWidth - screenBezel * 2)
.attr("height", screenHeight - screenBezel * 2)
.attr("rx", 2);
// Screen outer frame
nodeG
.append("rect")
.attr("class", "node-outline")
.attr("x", screenX)
.attr("y", y)
.attr("width", screenWidth)
.attr("height", screenHeight)
.attr("rx", 3)
.attr("fill", "#1a1a1a")
.attr("stroke", wireColor)
.attr("stroke-width", strokeWidth);
// Screen inner
nodeG
.append("rect")
.attr("x", screenX + screenBezel)
.attr("y", y + screenBezel)
.attr("width", screenWidth - screenBezel * 2)
.attr("height", screenHeight - screenBezel * 2)
.attr("rx", 2)
.attr("fill", "#0a0a12");
// Memory fill on screen
if (ramUsagePercent > 0) {
const memFillTotalHeight = screenHeight - screenBezel * 2;
const memFillActualHeight =
(ramUsagePercent / 100) * memFillTotalHeight;
nodeG
.append("rect")
.attr("x", screenX + screenBezel)
.attr(
"y",
y + screenBezel + (memFillTotalHeight - memFillActualHeight),
)
.attr("width", screenWidth - screenBezel * 2)
.attr("height", memFillActualHeight)
.attr("fill", "rgba(255,215,0,0.85)")
.attr("clip-path", `url(#${linuxScreenClipId})`);
}
// Terminal prompt on screen
nodeG
.append("text")
.attr("x", nodeInfo.x)
.attr("y", y + screenHeight / 2)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#FFFFFF")
.attr("opacity", 0.9)
.attr("font-size", screenHeight * 0.25)
.attr("font-family", "SF Mono, Monaco, monospace")
.attr("font-weight", "700")
.text(">_");
// Keyboard base (trapezoidal)
const baseY = y + screenHeight;
const baseTopWidth = screenWidth;
const baseBottomWidth = iconBaseWidth;
const baseTopX = nodeInfo.x - baseTopWidth / 2;
const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
nodeG
.append("path")
.attr(
"d",
`M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`,
)
.attr("fill", "#2c2c2c")
.attr("stroke", wireColor)
.attr("stroke-width", 1);
// Keyboard area
const keyboardX = baseTopX + 6;
const keyboardY = baseY + 3;
const keyboardWidth = baseTopWidth - 12;
const keyboardHeight = baseHeight * 0.55;
nodeG
.append("rect")
.attr("x", keyboardX)
.attr("y", keyboardY)
.attr("width", keyboardWidth)
.attr("height", keyboardHeight)
.attr("fill", "rgba(0,0,0,0.2)")
.attr("rx", 2);
// Trackpad
const trackpadWidth = baseTopWidth * 0.4;
const trackpadX = nodeInfo.x - trackpadWidth / 2;
const trackpadY = baseY + keyboardHeight + 5;
const trackpadHeight = baseHeight * 0.3;
nodeG
.append("rect")
.attr("x", trackpadX)
.attr("y", trackpadY)
.attr("width", trackpadWidth)
.attr("height", trackpadHeight)
.attr("fill", "rgba(255,255,255,0.08)")
.attr("rx", 2);
} else if (isLinux) {
// Linux Desktop — same shape as Mac Studio but with Tux logo
iconBaseWidth = nodeRadius * 1.25;
iconBaseHeight = nodeRadius * 0.85;
const x = nodeInfo.x - iconBaseWidth / 2;
const y = nodeInfo.y - iconBaseHeight / 2;
const cornerRadius = 4;
const topSurfaceHeight = iconBaseHeight * 0.15;
const linuxDesktopClipId = `linux-desktop-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
defs
.append("clipPath")
.attr("id", linuxDesktopClipId)
.append("rect")
.attr("x", x)
.attr("y", y + topSurfaceHeight)
.attr("width", iconBaseWidth)
.attr("height", iconBaseHeight - topSurfaceHeight)
.attr("rx", cornerRadius - 1);
// Main body
nodeG
.append("rect")
.attr("class", "node-outline")
.attr("x", x)
.attr("y", y)
.attr("width", iconBaseWidth)
.attr("height", iconBaseHeight)
.attr("rx", cornerRadius)
.attr("fill", "#1a1a1a")
.attr("stroke", wireColor)
.attr("stroke-width", strokeWidth);
// Memory fill
if (ramUsagePercent > 0) {
const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
const memFillActualHeight =
(ramUsagePercent / 100) * memFillTotalHeight;
nodeG
.append("rect")
.attr("x", x)
.attr(
"y",
y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
)
.attr("width", iconBaseWidth)
.attr("height", memFillActualHeight)
.attr("fill", "rgba(255,215,0,0.75)")
.attr("clip-path", `url(#${linuxDesktopClipId})`);
}
// Terminal prompt on front face
nodeG
.append("text")
.attr("x", nodeInfo.x)
.attr(
"y",
y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) / 2,
)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "rgba(255,255,255,0.5)")
.attr("font-size", (iconBaseHeight - topSurfaceHeight) * 0.4)
.attr("font-family", "SF Mono, Monaco, monospace")
.attr("font-weight", "700")
.text(">_");
} else if (modelLower === "mac studio") {
if (modelLower === "mac studio") {
// Mac Studio - classic cube with memory fill
iconBaseWidth = nodeRadius * 1.25;
iconBaseHeight = nodeRadius * 0.85;
@@ -1568,12 +1182,8 @@
debugLabelY += debugLineHeight;
}
const dbgIdentity = identitiesData[nodeInfo.id];
if (dbgIdentity?.osVersion) {
const osLabel =
dbgIdentity.osVersion === "Linux"
? "Linux"
: `macOS ${dbgIdentity.osVersion}${dbgIdentity.osBuildVersion ? ` (${dbgIdentity.osBuildVersion})` : ""}`;
const identity = identitiesData[nodeInfo.id];
if (identity?.osVersion) {
nodeG
.append("text")
.attr("x", nodeInfo.x)
@@ -1582,7 +1192,9 @@
.attr("fill", "rgba(179,179,179,0.7)")
.attr("font-size", debugFontSize)
.attr("font-family", "SF Mono, Monaco, monospace")
.text(osLabel);
.text(
`macOS ${identity.osVersion}${identity.osBuildVersion ? ` (${identity.osBuildVersion})` : ""}`,
);
}
}
});
+10 -128
View File
@@ -74,12 +74,6 @@ 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;
@@ -229,7 +223,6 @@ interface RawStateResponse {
}
>;
runners?: Record<string, unknown>;
instanceLinks?: Record<string, RawInstanceLink>;
downloads?: Record<string, unknown[]>;
// New granular node state fields
nodeIdentities?: Record<string, RawNodeIdentity>;
@@ -548,8 +541,6 @@ 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<
@@ -1283,7 +1274,6 @@ class AppStore {
startPolling() {
this.fetchState();
this.fetchFeatureFlags();
this.fetchInterval = setInterval(() => this.fetchState(), 1000);
}
@@ -1295,16 +1285,6 @@ 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");
@@ -1330,11 +1310,6 @@ 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;
}
@@ -1695,15 +1670,7 @@ class AppStore {
}
}
}
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;
return { role: m.role, content: msgContent };
}),
];
@@ -1910,15 +1877,7 @@ class AppStore {
const apiMessages = [
systemPrompt,
...targetConversation.messages.slice(0, -1).map((m) => {
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;
return { role: m.role, content: m.content };
}),
];
@@ -2449,15 +2408,10 @@ class AppStore {
contentParts.push({ type: "text", text: textContent });
}
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;
return {
role: m.role,
content: contentParts,
};
}
// Text-only message (original path)
@@ -2475,15 +2429,10 @@ class AppStore {
}
}
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;
return {
role: m.role,
content: msgContent,
};
}),
];
@@ -3332,60 +3281,6 @@ 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
*/
@@ -3484,19 +3379,6 @@ 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
@@ -1,44 +0,0 @@
// 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;
}
+14 -82
View File
@@ -65,7 +65,6 @@
nodeThunderboltBridge,
nodeIdentities,
isConnected,
featureFlags,
type DownloadProgress,
type PlacementPreview,
} from "$lib/stores/app.svelte";
@@ -703,10 +702,7 @@
? Object.keys(topologyData()!.nodes).length
: 1;
const sharding = nodeCount <= 1 ? "Pipeline" : selectedSharding;
const instanceType =
nodeCount <= 1 && selectedInstanceType === "MlxJaccl"
? "MlxRing"
: selectedInstanceType;
const instanceType = nodeCount <= 1 ? "MlxRing" : selectedInstanceType;
try {
const placementResponse = await fetch(
`/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${sharding}&instance_meta=${instanceType}&min_nodes=1`,
@@ -787,7 +783,6 @@
quantization?: string;
base_model?: string;
capabilities?: string[];
requires_vllm?: boolean;
}>
>([]);
type ModelMemoryFitStatus =
@@ -891,7 +886,7 @@
}
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
type InstanceMeta = "MlxRing" | "MlxJaccl" | "Vllm";
type InstanceMeta = "MlxRing" | "MlxJaccl";
// Launch defaults persistence
const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults-v2";
@@ -937,12 +932,7 @@
// Apply sharding and instance type unconditionally
selectedSharding = defaults.sharding;
selectedInstanceType =
defaults.instanceType === "MlxRing"
? "MlxRing"
: defaults.instanceType === "Vllm"
? "Vllm"
: "MlxJaccl";
userPickedInstanceType = true;
defaults.instanceType === "MlxRing" ? "MlxRing" : "MlxJaccl";
// Apply minNodes if valid (between 1 and maxNodes)
if (
@@ -964,23 +954,6 @@
}
let selectedInstanceType = $state<InstanceMeta>("MlxRing");
let userPickedInstanceType = $state(false);
$effect(() => {
if (!userPickedInstanceType && featureFlags()["vllm_available"]) {
selectedInstanceType = "Vllm";
}
});
const selectedModelRequiresVllm = $derived.by((): boolean => {
const id = selectedPreviewModelId();
if (!id) return false;
const model = models.find((m) => m.id === id);
return model?.requires_vllm === true;
});
$effect(() => {
if (selectedModelRequiresVllm) {
selectedInstanceType = "Vllm";
}
});
let selectedMinNodes = $state<number>(1);
let minNodesInitialized = $state(false);
let launchingModelId = $state<string | null>(null);
@@ -1173,7 +1146,9 @@
}
const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
runtime === selectedInstanceType;
selectedInstanceType === "MlxRing"
? runtime === "MlxRing"
: runtime === "MlxJaccl";
// Helper to check if a model can be launched (has valid placement with >= minNodes)
function canModelFit(modelId: string): boolean {
@@ -2088,7 +2063,6 @@
let instanceType = "Unknown";
if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
else if (instanceTag === "VllmInstance") instanceType = "vLLM";
const inst = instance as {
shardAssignments?: {
@@ -5795,18 +5769,14 @@
</div>
<div class="flex gap-2">
<button
disabled={selectedModelRequiresVllm}
onclick={() => {
if (selectedModelRequiresVllm) return;
selectedInstanceType = "MlxRing";
userPickedInstanceType = true;
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 {selectedModelRequiresVllm
? 'opacity-40 cursor-not-allowed bg-transparent text-white/40 border-exo-medium-gray/30'
: selectedInstanceType === 'MlxRing'
? 'cursor-pointer bg-transparent text-exo-yellow border-exo-yellow'
: 'cursor-pointer bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxRing'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
@@ -5822,18 +5792,14 @@
TCP/IP
</button>
<button
disabled={selectedModelRequiresVllm}
onclick={() => {
if (selectedModelRequiresVllm) return;
selectedInstanceType = "MlxJaccl";
userPickedInstanceType = true;
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 {selectedModelRequiresVllm
? 'opacity-40 cursor-not-allowed bg-transparent text-white/40 border-exo-medium-gray/30'
: selectedInstanceType === 'MlxJaccl'
? 'cursor-pointer bg-transparent text-exo-yellow border-exo-yellow'
: 'cursor-pointer bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxJaccl'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
@@ -5848,41 +5814,7 @@
</span>
RDMA (Fast)
</button>
{#if featureFlags()["vllm_available"] || selectedModelRequiresVllm}
<button
onclick={() => {
selectedInstanceType = "Vllm";
userPickedInstanceType = true;
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'Vllm'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
'Vllm'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedInstanceType === "Vllm"}
<span
class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
vLLM (CUDA)
</button>
{/if}
</div>
{#if selectedModelRequiresVllm}
<div
class="mt-2 text-[11px] font-mono text-orange-300/80"
>
This model requires vLLM.
</div>
{/if}
</div>
<!-- Minimum Devices -->
@@ -1,81 +0,0 @@
<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>
+1 -112
View File
@@ -14,7 +14,6 @@
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[] = [];
@@ -89,12 +88,10 @@
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(
@@ -133,7 +130,6 @@
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) };
@@ -141,27 +137,6 @@
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) {
@@ -243,55 +218,6 @@
),
);
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"}`,
);
@@ -351,7 +277,6 @@
"OpenCode",
"Codex",
"OpenClaw",
"Pi",
"Open WebUI",
"n8n",
"Firefox",
@@ -373,25 +298,16 @@
try {
const resp = await fetch("/v1/models");
const data = (await resp.json()) as {
data: {
id: string;
capabilities: string[];
context_length: number;
reasoning_dialect?: string;
}[];
data: { id: string; capabilities: string[]; context_length: number }[];
};
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 */
}
@@ -599,33 +515,6 @@
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"
+49 -39
View File
@@ -74,7 +74,14 @@
debug = true; # Enable options autocompletion
perSystem = { config, self', pkgs, lib, system, ... }:
perSystem =
{ config
, self'
, pkgs
, lib
, system
, ...
}:
let
pkgsArgs = {
inherit system;
@@ -130,53 +137,56 @@
};
};
packages = {
default = self'.packages.exo;
} //
lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
};
packages =
{
default = self'.packages.exo;
}
// lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
};
devShells.default = with pkgs; pkgs.mkShell {
inputsFrom = [ self'.checks.cargo-build ];
devShells.default = with pkgs;
pkgs.mkShell {
inputsFrom = [ self'.checks.cargo-build ];
packages =
[
# FORMATTING
config.treefmt.build.wrapper
packages =
[
# FORMATTING
config.treefmt.build.wrapper
# PYTHON
self'.packages.exo.passthru.evenv
uv
# PYTHON
self'.packages.editableVenv
uv
# RUST
config.rust.toolchain
maturin
# RUST
config.rust.toolchain
maturin
# NIX
nixd
nixpkgs-fmt
# NIX
nixd
nixpkgs-fmt
# SVELTE
nodejs
# SVELTE
nodejs
# MISC
just
jq
]
++ lib.optionals stdenv.isDarwin [
macmon
];
# MISC
just
jq
]
++ lib.optionals stdenv.isDarwin [
macmon
self'.packages.metal-toolchain
];
OPENSSL_NO_VENDOR = "1";
OPENSSL_NO_VENDOR = "1";
shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${python313}/lib"
${lib.optionalString stdenv.isLinux ''
export LD_LIBRARY_PATH="${openssl.out}/lib:$LD_LIBRARY_PATH"
''}
'';
};
shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${python313}/lib"
${lib.optionalString stdenv.isLinux ''
export LD_LIBRARY_PATH="${openssl.out}/lib:$LD_LIBRARY_PATH"
''}
'';
};
};
};
}
-13
View File
@@ -40,19 +40,6 @@ build-app: rust-rebuild sync-clean package
xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
@echo "\nBuild complete. Run with:\n open {{justfile_directory()}}/app/EXO/build/Build/Products/Debug/EXO.app"
sync-cuda:
#!/usr/bin/env bash
set -euo pipefail
uv sync --extra vllm-cuda13 --extra mlx-cpu --no-install-package vllm
dest=".venv/lib/python3.13/site-packages"
[[ -d $dest/vllm ]] || {
nix build .#exo-cuda-13.passthru.evenv
# will also grab vllm-0.19.1-distinfo
cp -aL result/lib/python3.13/site-packages/vllm* .venv/lib/python3.13/site-packages
chmod -R u+rwX .venv/lib/python3.13/site-packages/vllm*
rm result
}
clean:
rm -rf **/__pycache__
rm -rf target/
+251
View File
@@ -0,0 +1,251 @@
# MLX Tinygrad Interop
Private code for benchmarking MLX `<->` tinygrad tensor conversions.
There is also now a lightweight baseline bridge module in
`mlx_tinygrad_interop/lib/tensor_bridge.py`, modelled after vLLM Metal's
`tensor_bridge.py` shape:
- `tinygrad_to_mlx(...)` is implemented
- `mlx_to_tinygrad(...)` is intentionally stubbed there
That module is meant as a simple public-shaped bridge baseline, separate from
the lower-level benchmark and lease-pool experiments.
Reusable runtime code now lives under `mlx_tinygrad_interop/lib/`.
Benchmarks, stress harnesses, and tests stay in the top-level
`mlx_tinygrad_interop/` package.
There is also a separate benchmark file for the route that relies on existing
tinygrad <-> PyTorch and MLX <-> PyTorch interop instead of new framework
patches:
- `mlx_tinygrad_interop/bench_torch_route.py`
That route requires `torch` in the normal macOS `exo` environment.
The currently working fully pre-existing route is the CPU-intermediate one.
Using an intermediate PyTorch `mps` tensor caused the documented
`Tensor.from_blob(..., device="METAL")` path to fail at runtime on `e16`, so
the benchmark defaults to `--torch-device cpu`.
## Workflow
Use the repo devshell and top-level dependency graph. Do not install ad-hoc
build dependencies or patch around them with one-off environment setups.
1. Change code locally.
2. Push the `mlx` and `tinygrad` fork changes.
3. In local `exo`, enter the devshell with `nix develop`.
4. Refresh `uv.lock` against the new fork heads with:
`uv lock --upgrade-package mlx --refresh-package mlx --upgrade-package tinygrad --refresh-package tinygrad`
5. Commit and push the updated `exo` branch.
6. On the remote Mac, pull the updated repos.
7. Enter the devshell with `nix develop`.
8. Refresh the environment with `uv sync`.
9. Run tests or benchmarks with `uv run ...`.
Plain `uv lock` was not enough to move these git-based dependency SHAs during
testing, and `--upgrade-package` alone still left one stale git revision in a
later pass. The working command was the explicit `--upgrade-package` plus
`--refresh-package` form above.
## Benchmark
The current benchmark keeps source tensor construction and explicit pre-sync
outside the timed loop, but many rows still include per-call helper, binding,
owner pinning, and wrapper-construction overhead.
Current benchmark CSV output reports:
- `avg_us`
- `stddev_us`
Older notes in this file that mention min/median refer to earlier runs before
the reporting format was changed.
- Inputs are assumed to already be synchronized.
- Inputs are assumed to already be allocated.
- Inputs are assumed to already be materialized / realized.
- Setup stays outside the timed loop.
- The current unsafe helper bridge is asymmetric:
- `MLX -> tinygrad` adopts an existing `MTLBuffer*`
- `tinygrad -> MLX` rebuilds an MLX array from a raw pointer
- Newer `MLX -> tinygrad` rows also cover:
- a single MLX-side entrypoint that calls into tinygrad without the exporter
dict round-trip
- a rebindable tinygrad slot that reuses one wrapper and rebinds the
borrowed `MTLBuffer*`
- a small ring of such slots
- a keyed lease pool that owns `mx.eval(...)` on acquire and explicit lease
release on the tinygrad side
- `*_then_use_sum` rows that immediately consume the converted tensor through
a realized tinygrad reduction
- `mx.array(memoryview(...))` is a native copy path in current MLX, not an
aliasing import path.
- Rebindable slots enforce fixed shape/dtype contracts on rebind.
- Do not rebind a slot until all work derived from its previous contents has
been realized and synchronized.
- The practical `MLX -> tinygrad` path is now a lease-managed pool keyed by
`(shape, dtype, byte_offset)`, not a bare mutable borrower.
- There is now also a copy-based `MLX -> tinygrad` pool family:
- `MlxToTinygradCopyLeasePool`
- `MlxToTinygradCopyLeasePools`
- these reuse tinygrad-owned destination tensors and copy MLX bytes into
them instead of aliasing a foreign `MTLBuffer*`
- The raw lease path remains intentionally unsafe:
- `lease.tensor` is not a snapshot
- if that raw tensor escapes beyond the lease and the slot is reused, it can
observe new contents
- The preferred production-shaped API is the scoped callback form:
- `pool.run_with_mlx_tensor(array, fn=...)`
- `pools.run_with_mlx_tensor(array, tg_dtype=..., fn=...)`
- these scope acquire/use/release together and only allow independently
realized outputs to escape
- they reject returning the borrowed tensor directly
- they reject returning alias views of the borrowed slot
- they reject leaked live tensors whose graphs still depend on the borrowed
tensor
- the callback still must not stash the raw borrowed tensor object itself;
that remains a contract rule rather than something the current runtime can
prove mechanically
- Safe scoped release uses `Device["METAL"].synchronize()` again. The narrower
callback-local command-buffer wait experiment was not concurrency-safe enough
to keep as the default runtime behavior.
- Alias and copy pool registries are bounded keyed caches with `max_pools`, so
variable-shape inference can be bucketed without unbounded registry growth.
- This fast path is only valid for same-process, same-address-space Apple
Silicon unified-memory handoff. It does not cross process or machine
boundaries, and it does not remove any later Metal/host -> CUDA transfer.
Test command:
```bash
uv run python -m unittest mlx_tinygrad_interop.test_interop mlx_tinygrad_interop.test_handoff
```
Stress command:
```bash
uv run python mlx_tinygrad_interop/stress_interop.py --cases 64 --soak-iterations 512
```
The stress suite now also reports native memory signals:
- `mx.get_active_memory()`
- `mx.get_cache_memory()`
- `mx.get_peak_memory()`
- process `ru_maxrss`
It also now checks:
- more complex movement / broadcast / reduction / matmul chains
- roundtrip `MLX -> tinygrad -> MLX` correctness after those chains
- bounded alias/copy pool-count behavior during soak runs
For `matmul_lastdim`, the stress harness uses `np.einsum(...)` for the NumPy
baseline instead of NumPy `@`. On the current macOS validation host, a valid
contiguous float32 matmul case through `@` returned an incorrect all-zero
result while MLX, tinygrad, and `np.einsum` agreed on the nonzero output.
Float32 stress comparisons also allow a small `rtol=5e-5, atol=1e-5` tolerance
so mixed matmul/reduction chains are not failed for a few-ulps backend
accumulation-order drift.
Raw conversions are still checked against NumPy values directly, but the
downstream op-chain checks now use the native destination-framework baseline:
- `MLX -> tinygrad` op chains are compared to native tinygrad results
- `tinygrad -> MLX` op chains are compared to native MLX results
That avoids treating real framework semantic differences, such as integer
promotion behavior, as interop failures.
Example:
```bash
uv run python mlx_tinygrad_interop/bench_raw_conversion.py --dtype float32 --sizes 256,512,1024,2048,4096,7168
```
Validated remote command on `e16`:
```bash
uv run python mlx_tinygrad_interop/bench_raw_conversion.py --dtype float32 --sizes 7168 --warmup 64 --samples 7 --min-batch-us 1000
```
Observed `7168`-byte results on that run:
- `unsafe_helper_bridge`
- `mlx_to_tinygrad`: `21.202 us` min, `21.532 us` median
- `tinygrad_to_mlx`: `28.109 us` min, `28.372 us` median
- `single_entry_bridge`
- `mlx_to_tinygrad`: `21.388 us` min, `21.542 us` median
- `fresh_wrapper_then_use_sum`
- `mlx_to_tinygrad`: `601.812 us` min, `611.458 us` median
- `rebindable_slot_bridge`
- `mlx_to_tinygrad`: `1.505 us` min, `1.542 us` median
- `rebindable_slot_then_use_sum`
- `mlx_to_tinygrad`: `579.583 us` min, `581.833 us` median
- `borrower_ring4_bridge`
- `mlx_to_tinygrad`: `1.531 us` min, `1.573 us` median
- `borrower_ring4_then_use_sum`
- `mlx_to_tinygrad`: `577.730 us` min, `581.000 us` median
- `unsafe_helper_legacy`
- `mlx_to_tinygrad`: `31.938 us` min, `32.214 us` median
- `unsafe_helper_maybe_copy`
- `tinygrad_to_mlx`: `28.153 us` min, `28.277 us` median
- `memoryview_copy`
- `mlx_to_tinygrad`: `35.191 us` min, `35.668 us` median
- `tinygrad_to_mlx`: `2.596 us` min, `2.662 us` median
- `numpy_baseline`
- `mlx_to_tinygrad`: `272.323 us` min, `275.104 us` median
- `tinygrad_to_mlx`: `12.817 us` min, `13.005 us` median
Later remote microbench runs showed the split more clearly:
- `MLX -> tinygrad single_entry_bridge` barely changes the fresh-wrapper cost,
so exporter dict marshalling was never the main problem.
- `MLX -> tinygrad` is dominated by tinygrad import / wrapper construction when
a fresh tensor is created each time.
- The rebindable tinygrad slot drops `MLX -> tinygrad` to about `1.5 us`, and
a ring of four slots stays at essentially the same latency. That means
wrapper reuse, not exporter marshalling, is the decisive optimization on this
host.
- After adding hardened shape/dtype contract checks, a spot-check at `7168`
bytes moved those rows to about `2.46 us` for the single slot and `2.55 us`
for the ring. That is still comfortably inside the target latency range.
- The strict alias-only `tinygrad -> MLX` helper succeeds on `e16`; its timing
is effectively the same as the maybe-copy helper on that host.
- `tinygrad -> MLX` is dominated by tinygrad export in the unsafe helper path.
- `tinygrad -> MLX memoryview_copy` is already the practical low-latency path
for small tensors.
- Offsetted MLX slices now export both logical bytes and backing-buffer bytes,
and a nonzero-offset slice was validated successfully into tinygrad.
- The randomized stress suite also caught and fixed the zero-offset variant of
that problem: oversized backing buffers now import through a logical tinygrad
buffer view instead of reshaping the entire backing allocation.
- The same stress suite also found that MLX backing-buffer capacity is a raw
byte count, not necessarily a dtype-aligned element count. The fast tinygrad
path now handles that with byte-level bounds checks and `ceildiv`.
- The rebindable slot returns the same tinygrad `Tensor` object rebound to new
Metal storage, so it is narrower than an ordinary "new tensor each call"
conversion helper.
- The current ring rows are still benchmark primitives, not a production lease
API. If this path is used in the real disaggregated MLX/tinygrad runtime, it
should use the scoped pool callback API rather than a raw escaping lease
tensor wherever possible.
- Safe lease release now clears the slot's pinned MLX owner reference after the
Metal barrier. Unsafe `synchronize_on_release=False` use is still the
caller's responsibility.
- The `*_then_use_sum` rows are dominated by the realized tinygrad reduction.
They should be read as end-to-end "convert then immediately consume" probes.
They still show the same relative story: slot/ring rebinding saves about
`20-25 us` versus the fresh-wrapper path at `7 kB`.
## Current Scope
- Private / unsafe helpers only.
- Metal / unified-memory path only.
- Dense contiguous tensors only.
- Same-dtype conversions only.
- Current exporter/importer microbenchmarks are intended to separate helper
overhead from end-to-end bridge cost.
+23
View File
@@ -0,0 +1,23 @@
"""Private MLX <-> tinygrad interop experiments, benchmarks, and handoff helpers."""
from mlx_tinygrad_interop.lib import (
MlxToTinygradLease,
MlxToTinygradLeaseKey,
MlxToTinygradLeasePool,
MlxToTinygradLeasePools,
mlx_to_tinygrad,
sync_mlx,
sync_tinygrad,
tinygrad_to_mlx,
)
__all__ = [
"MlxToTinygradLease",
"MlxToTinygradLeaseKey",
"MlxToTinygradLeasePool",
"MlxToTinygradLeasePools",
"mlx_to_tinygrad",
"sync_mlx",
"sync_tinygrad",
"tinygrad_to_mlx",
]
@@ -0,0 +1,443 @@
import argparse
import gc
import platform
import statistics
import sys
import time
from typing import Any, Callable, cast
import mlx.core as mx
import numpy as np
from tinygrad import Device, Tensor, dtypes
from tinygrad.device import Buffer
try:
from mlx_tinygrad_interop.lib.lease_pool import (
MlxToTinygradCopyLeasePool,
MlxToTinygradCopyLeasePools,
MlxToTinygradLeasePool,
MlxToTinygradLeasePools,
)
except ModuleNotFoundError:
from lib.lease_pool import MlxToTinygradCopyLeasePool, MlxToTinygradCopyLeasePools, MlxToTinygradLeasePool, MlxToTinygradLeasePools
blackhole: Any = None
DTYPES: dict[str, tuple[Any, Any, np.dtype[Any]]] = {
"float16": (mx.float16, dtypes.float16, np.dtype(np.float16)),
"float32": (mx.float32, dtypes.float32, np.dtype(np.float32)),
"int32": (mx.int32, dtypes.int32, np.dtype(np.int32)),
"uint8": (mx.uint8, dtypes.uint8, np.dtype(np.uint8)),
}
class Alternator:
def __init__(self, *items: Any):
assert items, "Alternator needs at least one item"
self.items = items
self.index = 0
def next(self) -> Any:
item = self.items[self.index]
self.index = (self.index + 1) % len(self.items)
return item
class BorrowerRing:
def __init__(self, *borrowers: Any):
assert borrowers, "BorrowerRing needs at least one borrower"
self.borrowers = borrowers
self.index = 0
def next(self) -> Any:
borrower = self.borrowers[self.index]
self.index = (self.index + 1) % len(self.borrowers)
return borrower
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Benchmark raw tinygrad <-> MLX tensor conversion overhead.")
parser.add_argument("--dtype", choices=sorted(DTYPES), default="float32")
parser.add_argument("--sizes", default="256,512,1024,2048,4096,7168,8192,16384,32768,65536,262144,1048576",
help="Comma-separated tensor sizes in bytes.")
parser.add_argument("--warmup", type=int, default=128)
parser.add_argument("--samples", type=int, default=12)
parser.add_argument("--min-batch-us", type=float, default=2000.0,
help="Minimum target batch duration per sample in microseconds.")
return parser.parse_args()
def bytes_view(mv: memoryview) -> memoryview:
return mv if mv.format == "B" and mv.ndim == 1 else mv.cast("B")
def mlx_dtype_name(dtype: Any) -> str:
return repr(dtype).removeprefix("mlx.core.")
def tinygrad_zero_copy_memoryview(t: Tensor) -> memoryview:
assert t.device == "METAL", f"expected METAL tensor, got {t.device}"
buf = cast(Buffer, t.uop.buffer).ensure_allocated()
assert t.dtype.base.fmt is not None, f"no buffer format for dtype {t.dtype.base}"
return buf.as_memoryview(force_zero_copy=True).cast(t.dtype.base.fmt, t.shape)
def tinygrad_from_mlx_legacy(x: Any, tg_dtype: Any) -> Tensor:
storage = mx.metal._unsafe_export_storage(x)
return Tensor._unsafe_from_metal_buffer(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=tg_dtype,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=x,
)
def tinygrad_from_mlx_fast(x: Any, tg_dtype: Any) -> Tensor:
storage = mx.metal._unsafe_export_storage(x)
return Tensor._unsafe_from_metal_buffer_fast(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=tg_dtype,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=x,
)
def tinygrad_from_mlx_single_entry(x: Any, tg_dtype: Any) -> Tensor:
return mx.metal._unsafe_to_tinygrad_fast(x, tg_dtype, owner=x)
def tinygrad_from_mlx_reuse(x: Any, borrower: Any) -> Tensor:
return mx.metal._unsafe_rebind_tinygrad(x, borrower, owner=x)
def tinygrad_from_mlx_lease_acquire_release(x: Any, pool: MlxToTinygradLeasePool) -> int:
lease = pool.acquire_from_mlx(x)
generation = lease.generation
lease.release(synchronize=False)
return generation
def tinygrad_from_mlx_lease_then_use_sum(x: Any, pool: MlxToTinygradLeasePool) -> Tensor:
lease = pool.acquire_from_mlx(x)
try:
return tinygrad_consume_sum(lease.tensor)
finally:
lease.release(synchronize=False)
def tinygrad_from_mlx_scoped_handoff_noop(x: Any, pools: MlxToTinygradLeasePools, tg_dtype: Any) -> int:
return pools.run_with_mlx_tensor(x, tg_dtype=tg_dtype, fn=lambda _: 0)
def tinygrad_from_mlx_scoped_handoff_then_use_sum(x: Any, pools: MlxToTinygradLeasePools, tg_dtype: Any) -> Tensor:
return pools.run_with_mlx_tensor(x, tg_dtype=tg_dtype, fn=lambda t: tinygrad_consume_sum(t))
def tinygrad_from_mlx_copy_pool_acquire_release(x: Any, pool: MlxToTinygradCopyLeasePool) -> int:
lease = pool.acquire_from_mlx(x)
generation = lease.generation
lease.release(synchronize=False)
return generation
def tinygrad_from_mlx_copy_pool_then_use_sum(x: Any, pool: MlxToTinygradCopyLeasePool) -> Tensor:
lease = pool.acquire_from_mlx(x)
try:
return tinygrad_consume_sum(lease.tensor)
finally:
lease.release(synchronize=False)
def tinygrad_from_mlx_scoped_copy_handoff_noop(x: Any, pools: MlxToTinygradCopyLeasePools, tg_dtype: Any) -> int:
return pools.run_with_mlx_tensor(x, tg_dtype=tg_dtype, fn=lambda _: 0)
def tinygrad_from_mlx_scoped_copy_handoff_then_use_sum(x: Any, pools: MlxToTinygradCopyLeasePools, tg_dtype: Any) -> Tensor:
return pools.run_with_mlx_tensor(x, tg_dtype=tg_dtype, fn=lambda t: tinygrad_consume_sum(t))
def tinygrad_consume_sum(t: Tensor) -> Tensor:
out = (t + 1).sum()
out.realize()
Device["METAL"].synchronize()
return out
def mlx_from_tinygrad_maybe_copy(t: Tensor, mx_dtype: Any) -> Any:
storage = t._unsafe_metal_storage()
return mx.metal._unsafe_array_from_ptr(
int(storage["raw_ptr"]),
tuple(storage["shape"]),
mx_dtype,
owner=t,
)
def mlx_from_tinygrad_alias_only(t: Tensor, mx_dtype: Any) -> Any:
storage = t._unsafe_metal_storage()
return mx.metal._unsafe_array_from_ptr_alias_only(
int(storage["raw_ptr"]),
tuple(storage["shape"]),
mx_dtype,
owner=t,
)
def tinygrad_from_mlx_copy(x: Any, tg_dtype: Any) -> Tensor:
out = Tensor.empty(*tuple(int(dim) for dim in x.shape), device="METAL", dtype=tg_dtype)
cast(Buffer, out.uop.buffer).ensure_allocated().copyin(bytes_view(memoryview(x)))
return out
def mlx_from_tinygrad_copy(t: Tensor) -> Any:
return mx.array(tinygrad_zero_copy_memoryview(t))
def tinygrad_from_mlx_numpy(x: Any) -> Tensor:
out = Tensor(np.array(x, copy=True), device="METAL")
out.realize()
Device["METAL"].synchronize()
return out
def mlx_from_tinygrad_numpy(t: Tensor) -> Any:
return mx.array(t.numpy())
def mlx_export_storage(x: Any) -> Any:
return mx.metal._unsafe_export_storage(x)
def tinygrad_import_from_storage(storage: Any, tg_dtype: Any, owner: Any) -> Tensor:
return Tensor._unsafe_from_metal_buffer(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=tg_dtype,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=owner,
)
def tinygrad_import_from_storage_fast(storage: Any, tg_dtype: Any, owner: Any) -> Tensor:
return Tensor._unsafe_from_metal_buffer_fast(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=tg_dtype,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=owner,
)
def tinygrad_import_from_storage_reuse(storage: Any, owner: Any, borrower: Any) -> Tensor:
return borrower.rebind(
int(storage["mtl_buffer_ptr"]),
owner=owner,
shape=tuple(storage["shape"]),
dtype_name=mlx_dtype_name(storage["dtype"]),
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
)
def tinygrad_export_storage(t: Tensor) -> Any:
return t._unsafe_metal_storage()
def mlx_import_from_storage(storage: Any, mx_dtype: Any, owner: Any) -> Any:
return mx.metal._unsafe_array_from_ptr(
int(storage["raw_ptr"]),
tuple(storage["shape"]),
mx_dtype,
owner=owner,
)
def mlx_import_from_storage_alias_only(storage: Any, mx_dtype: Any, owner: Any) -> Any:
return mx.metal._unsafe_array_from_ptr_alias_only(
int(storage["raw_ptr"]),
tuple(storage["shape"]),
mx_dtype,
owner=owner,
)
def bench_callable(fn: Callable[[], Any], warmup: int, samples: int, min_batch_us: float) -> dict[str, float]:
global blackhole
for _ in range(warmup):
blackhole = fn()
min_batch_ns = int(min_batch_us * 1000.0)
iters = 1
while True:
start = time.perf_counter_ns()
for _ in range(iters):
blackhole = fn()
elapsed = time.perf_counter_ns() - start
if elapsed >= min_batch_ns or iters >= (1 << 20):
break
iters *= 2
vals_us: list[float] = []
for _ in range(samples):
start = time.perf_counter_ns()
for _ in range(iters):
blackhole = fn()
elapsed = time.perf_counter_ns() - start
vals_us.append(elapsed / iters / 1000.0)
return {
"iters": float(iters),
"avg_us": statistics.mean(vals_us),
"stddev_us": statistics.stdev(vals_us) if len(vals_us) > 1 else 0.0,
}
def print_header(dtype_name: str) -> None:
print(f"# python={platform.python_version()} platform={platform.platform()}")
print(f"# dtype={dtype_name} mlx_metal_available={mx.metal.is_available()} tinygrad_device=METAL")
print("# sizes are source tensor sizes in bytes")
print("# timed loop excludes source tensor construction and explicit pre-sync, but still includes per-call helper, binding, and wrapper overhead")
print("# reported latency is average per-call time with sample standard deviation")
print("# rebindable_slot_* rows rebind and return the same tinygrad Tensor object each time; ring rows rotate through multiple such slots")
print("# copy_pool_* rows reuse tinygrad-owned destination tensors and copy source bytes into them before release")
print("size_bytes,method,direction,avg_us,stddev_us,iters")
def main() -> None:
args = parse_args()
mx_dtype, tg_dtype, np_dtype = DTYPES[args.dtype]
sizes = [int(x.strip()) for x in args.sizes.split(",") if x.strip()]
required = [
("mx.metal._unsafe_export_storage", getattr(mx.metal, "_unsafe_export_storage", None)),
("mx.metal._unsafe_to_tinygrad_fast", getattr(mx.metal, "_unsafe_to_tinygrad_fast", None)),
("mx.metal._unsafe_rebind_tinygrad", getattr(mx.metal, "_unsafe_rebind_tinygrad", None)),
("mx.metal._unsafe_array_from_ptr", getattr(mx.metal, "_unsafe_array_from_ptr", None)),
("mx.metal._unsafe_array_from_ptr_alias_only", getattr(mx.metal, "_unsafe_array_from_ptr_alias_only", None)),
("Tensor._unsafe_from_metal_buffer", getattr(Tensor, "_unsafe_from_metal_buffer", None)),
("Tensor._unsafe_from_metal_buffer_fast", getattr(Tensor, "_unsafe_from_metal_buffer_fast", None)),
("Tensor._unsafe_metal_borrower", getattr(Tensor, "_unsafe_metal_borrower", None)),
("Tensor._unsafe_metal_storage", getattr(Tensor, "_unsafe_metal_storage", None)),
]
missing = [name for name, value in required if value is None]
if missing:
raise RuntimeError(f"Missing required helper(s): {', '.join(missing)}")
gc.disable()
try:
print_header(args.dtype)
for size_bytes in sizes:
if size_bytes <= 0:
continue
if size_bytes % np_dtype.itemsize != 0:
print(f"# skipping size {size_bytes}: not divisible by dtype itemsize {np_dtype.itemsize}", file=sys.stderr)
continue
numel = size_bytes // np_dtype.itemsize
# Build one realized source tensor on each side. Source creation and the
# explicit pre-sync stay outside the timed loop, but per-call helper,
# binding, owner-pinning, and wrapper construction still remain inside it.
src_mx_pool = [mx.array(np.arange(numel, dtype=np_dtype) + np.array(i, dtype=np_dtype), dtype=mx_dtype) for i in range(4)]
src_mx = src_mx_pool[0]
src_tg = Tensor(np.arange(numel, dtype=np_dtype), device="METAL").realize()
Device["METAL"].synchronize()
mx_storage_pool = [(mlx_export_storage(src), src) for src in src_mx_pool]
mx_storage = mx_storage_pool[0][0]
tg_storage = tinygrad_export_storage(src_tg)
mx_slot_borrower = Tensor._unsafe_metal_borrower(
int(mx_storage["mtl_buffer_ptr"]),
tuple(mx_storage["shape"]),
dtype=tg_dtype,
byte_offset=int(mx_storage["offset_bytes"]),
buffer_nbytes=int(mx_storage["buffer_nbytes"]),
owner=src_mx,
)
mx_slot_sources = Alternator(*src_mx_pool)
mx_slot_storage = Alternator(*mx_storage_pool)
mx_ring = BorrowerRing(*[
Tensor._unsafe_metal_borrower(
int(mx_storage["mtl_buffer_ptr"]),
tuple(mx_storage["shape"]),
dtype=tg_dtype,
byte_offset=int(mx_storage["offset_bytes"]),
buffer_nbytes=int(mx_storage["buffer_nbytes"]),
owner=src_mx,
) for _ in range(4)
])
lease_pool = MlxToTinygradLeasePool.from_mlx(src_mx, tg_dtype=tg_dtype, capacity=4, synchronize_on_release=True)
scoped_handoff_pools = MlxToTinygradLeasePools(capacity_per_key=4, synchronize_on_release=True)
copy_lease_pool = MlxToTinygradCopyLeasePool.from_mlx(src_mx, tg_dtype=tg_dtype, capacity=4, synchronize_on_release=True)
scoped_copy_handoff_pools = MlxToTinygradCopyLeasePools(capacity_per_key=4, synchronize_on_release=True)
benches: list[tuple[str, str, Callable[[], Any]]] = [
("unsafe_helper_bridge", "mlx_to_tinygrad", lambda s=src_mx: tinygrad_from_mlx_fast(s, tg_dtype)),
("single_entry_bridge", "mlx_to_tinygrad", lambda s=src_mx: tinygrad_from_mlx_single_entry(s, tg_dtype)),
("fresh_wrapper_then_use_sum", "mlx_to_tinygrad", lambda s=src_mx: tinygrad_consume_sum(tinygrad_from_mlx_fast(s, tg_dtype))),
("rebindable_slot_bridge", "mlx_to_tinygrad", lambda alt=mx_slot_sources, b=mx_slot_borrower: tinygrad_from_mlx_reuse(alt.next(), b)),
("rebindable_slot_then_use_sum", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, b=mx_slot_borrower: tinygrad_consume_sum(tinygrad_from_mlx_reuse(alt.next(), b))),
("borrower_ring4_bridge", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, ring=mx_ring: tinygrad_from_mlx_reuse(alt.next(), ring.next())),
("borrower_ring4_then_use_sum", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, ring=mx_ring: tinygrad_consume_sum(tinygrad_from_mlx_reuse(alt.next(), ring.next()))),
("lease_pool_acquire_release", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pool=lease_pool: tinygrad_from_mlx_lease_acquire_release(alt.next(), pool)),
("lease_pool_then_use_sum", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pool=lease_pool: tinygrad_from_mlx_lease_then_use_sum(alt.next(), pool)),
("scoped_handoff_noop", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pools=scoped_handoff_pools, dtype=tg_dtype: tinygrad_from_mlx_scoped_handoff_noop(alt.next(), pools, dtype)),
("scoped_handoff_then_use_sum", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pools=scoped_handoff_pools, dtype=tg_dtype: tinygrad_from_mlx_scoped_handoff_then_use_sum(alt.next(), pools, dtype)),
("copy_pool_acquire_release", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pool=copy_lease_pool: tinygrad_from_mlx_copy_pool_acquire_release(alt.next(), pool)),
("copy_pool_then_use_sum", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pool=copy_lease_pool: tinygrad_from_mlx_copy_pool_then_use_sum(alt.next(), pool)),
("scoped_copy_handoff_noop", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pools=scoped_copy_handoff_pools, dtype=tg_dtype: tinygrad_from_mlx_scoped_copy_handoff_noop(alt.next(), pools, dtype)),
("scoped_copy_handoff_then_use_sum", "mlx_to_tinygrad",
lambda alt=mx_slot_sources, pools=scoped_copy_handoff_pools, dtype=tg_dtype: tinygrad_from_mlx_scoped_copy_handoff_then_use_sum(alt.next(), pools, dtype)),
("unsafe_helper_legacy", "mlx_to_tinygrad", lambda s=src_mx: tinygrad_from_mlx_legacy(s, tg_dtype)),
("memoryview_copy", "mlx_to_tinygrad", lambda s=src_mx: tinygrad_from_mlx_copy(s, tg_dtype)),
("numpy_baseline", "mlx_to_tinygrad", lambda s=src_mx: tinygrad_from_mlx_numpy(s)),
("unsafe_helper_bridge", "tinygrad_to_mlx", lambda s=src_tg: mlx_from_tinygrad_alias_only(s, mx_dtype)),
("unsafe_helper_maybe_copy", "tinygrad_to_mlx", lambda s=src_tg: mlx_from_tinygrad_maybe_copy(s, mx_dtype)),
("memoryview_copy", "tinygrad_to_mlx", lambda s=src_tg: mlx_from_tinygrad_copy(s)),
("numpy_baseline", "tinygrad_to_mlx", lambda s=src_tg: mlx_from_tinygrad_numpy(s)),
("export_helper_only", "mlx_to_tinygrad", lambda s=src_mx: mlx_export_storage(s)),
("import_helper_fast_only", "mlx_to_tinygrad", lambda st=mx_storage, s=src_mx: tinygrad_import_from_storage_fast(st, tg_dtype, s)),
("rebindable_slot_import_only", "mlx_to_tinygrad",
lambda alt=mx_slot_storage, b=mx_slot_borrower: tinygrad_import_from_storage_reuse(*alt.next(), borrower=b)),
("borrower_ring4_import_only", "mlx_to_tinygrad",
lambda alt=mx_slot_storage, ring=mx_ring: tinygrad_import_from_storage_reuse(*alt.next(), borrower=ring.next())),
("import_helper_legacy_only", "mlx_to_tinygrad", lambda st=mx_storage, s=src_mx: tinygrad_import_from_storage(st, tg_dtype, s)),
("export_helper_only", "tinygrad_to_mlx", lambda s=src_tg: tinygrad_export_storage(s)),
("import_helper_only", "tinygrad_to_mlx", lambda st=tg_storage, s=src_tg: mlx_import_from_storage_alias_only(st, mx_dtype, s)),
("import_helper_maybe_copy_only", "tinygrad_to_mlx", lambda st=tg_storage, s=src_tg: mlx_import_from_storage(st, mx_dtype, s)),
]
for method, direction, fn in benches:
try:
blackhole = fn()
except Exception as exc:
print(f"# skipping {size_bytes},{method},{direction}: {exc}", file=sys.stderr)
continue
stats = bench_callable(fn, warmup=args.warmup, samples=args.samples, min_batch_us=args.min_batch_us)
print(
f"{size_bytes},{method},{direction},"
f"{stats['avg_us']:.3f},{stats['stddev_us']:.3f},{int(stats['iters'])}"
)
finally:
gc.enable()
if __name__ == "__main__":
main()
+341
View File
@@ -0,0 +1,341 @@
"""Benchmark tinygrad <-> PyTorch <-> MLX bridge routes.
This file intentionally uses pre-existing interop surfaces instead of adding
new framework patches:
- MLX <-> PyTorch bridge shape based on:
https://github.com/vllm-project/vllm-metal/blob/main/vllm_metal/pytorch_backend/tensor_bridge.py
- PyTorch -> tinygrad via tinygrad's documented Tensor.from_blob runtime interop
The timed loop excludes source tensor construction and explicit pre-sync, but
still includes the per-call helper and wrapper overhead of the route.
"""
from __future__ import annotations
import argparse
import gc
import platform
import statistics
import sys
import time
from typing import Any, Callable, Literal, cast
import mlx.core as mx
import numpy as np
import torch
from tinygrad import Device, Tensor, dtypes
from tinygrad.device import Buffer
from tinygrad.dtype import DType, _from_torch_dtype, _to_torch_dtype
blackhole: Any = None
_MPS_SAFE_SIZE_BYTES = 1 << 30
DTYPES: dict[str, tuple[Any, Any, np.dtype[Any]]] = {
"float16": (mx.float16, dtypes.float16, np.dtype(np.float16)),
"float32": (mx.float32, dtypes.float32, np.dtype(np.float32)),
"int32": (mx.int32, dtypes.int32, np.dtype(np.int32)),
"uint8": (mx.uint8, dtypes.uint8, np.dtype(np.uint8)),
}
MLX_TO_TORCH_DTYPE: dict[mx.Dtype, torch.dtype] = {
mx.float32: torch.float32,
mx.float16: torch.float16,
mx.bfloat16: torch.bfloat16,
mx.int32: torch.int32,
mx.int64: torch.int64,
mx.int16: torch.int16,
mx.int8: torch.int8,
mx.uint8: torch.uint8,
mx.bool_: torch.bool,
}
TORCH_TO_MLX_DTYPE: dict[torch.dtype, mx.Dtype] = {v: k for k, v in MLX_TO_TORCH_DTYPE.items()}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Benchmark tinygrad <-> PyTorch <-> MLX bridge overhead.")
parser.add_argument("--dtype", choices=sorted(DTYPES), default="float32")
parser.add_argument("--sizes", default="256,512,1024,2048,4096,7168,8192,16384,32768,65536",
help="Comma-separated tensor sizes in bytes.")
parser.add_argument("--warmup", type=int, default=64)
parser.add_argument("--samples", type=int, default=8)
parser.add_argument("--min-batch-us", type=float, default=2000.0)
parser.add_argument("--torch-device", choices=("cpu", "mps", "auto"), default="cpu",
help="Intermediate torch device to use for the route.")
return parser.parse_args()
def bytes_view(mv: memoryview) -> memoryview:
return mv if mv.format == "B" and mv.ndim == 1 else mv.cast("B")
def get_torch_device(kind: Literal["cpu", "mps", "auto"] = "auto") -> torch.device:
if kind == "cpu":
return torch.device("cpu")
if kind == "mps":
if not torch.backends.mps.is_available():
raise RuntimeError("torch MPS backend is not available")
return torch.device("mps")
if torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
def _get_tensor_size_bytes(shape: tuple[int, ...], dtype_itemsize: int) -> int:
size = dtype_itemsize
for dim in shape:
size *= dim
return size
def sync_mlx() -> None:
try:
mx.synchronize()
except (AttributeError, TypeError):
mx.eval(mx.array(0, dtype=mx.int32))
def sync_tinygrad() -> None:
Device["METAL"].synchronize()
def sync_torch(device: torch.device) -> None:
if device.type == "mps":
torch.mps.synchronize()
elif device.type == "cuda":
torch.cuda.synchronize()
def tinygrad_zero_copy_memoryview(t: Tensor) -> memoryview:
assert t.device == "METAL", f"expected METAL tensor, got {t.device}"
buf = cast(Buffer, t.uop.buffer).ensure_allocated()
return bytes_view(buf.as_memoryview(force_zero_copy=True))
def tinygrad_to_torch(tensor: Tensor, *, device: torch.device | Literal["cpu", "mps"] | None = None,
already_contiguous: bool = False) -> torch.Tensor:
if device is None:
device = get_torch_device("auto")
elif isinstance(device, str):
device = torch.device(device)
if not already_contiguous:
tensor = tensor.contiguous()
tensor = tensor.cast(tensor.dtype.base).realize()
torch_dtype = _to_torch_dtype(tensor.dtype.base)
if torch_dtype is None:
raise ValueError(f"Unsupported tinygrad dtype: {tensor.dtype}")
if tensor.device == "METAL":
sync_tinygrad()
buffer = tinygrad_zero_copy_memoryview(tensor)
else:
if tensor.device != "CPU":
tensor = tensor.to("CPU").realize()
buffer = bytes_view(tensor.data())
out = torch.frombuffer(buffer, dtype=torch_dtype).reshape(tuple(int(dim) for dim in tensor.shape))
if device.type == "mps":
if _get_tensor_size_bytes(tuple(int(dim) for dim in tensor.shape), tensor.dtype.itemsize) < _MPS_SAFE_SIZE_BYTES:
out = out.to(device)
elif device.type != "cpu":
out = out.to(device)
return out
def torch_to_mlx(tensor: torch.Tensor) -> mx.array:
if tensor.device.type != "cpu":
sync_torch(tensor.device)
tensor = tensor.cpu()
tensor = tensor.detach()
if tensor.dtype == torch.bfloat16:
return mx.array(tensor)
return mx.array(tensor.numpy())
def mlx_to_torch(array: mx.array, *, device: torch.device | Literal["cpu", "mps"] | None = None,
already_contiguous: bool = False) -> torch.Tensor:
if device is None:
device = get_torch_device("auto")
elif isinstance(device, str):
device = torch.device(device)
torch_dtype = MLX_TO_TORCH_DTYPE.get(array.dtype)
if torch_dtype is None:
raise ValueError(f"Unsupported MLX dtype: {array.dtype}")
if not already_contiguous:
array = mx.contiguous(array)
mx.eval(array)
out = torch.frombuffer(memoryview(array), dtype=torch_dtype).reshape(tuple(int(dim) for dim in array.shape))
if device.type == "mps":
if _get_tensor_size_bytes(tuple(int(dim) for dim in array.shape), int(array.dtype.size)) < _MPS_SAFE_SIZE_BYTES:
out = out.to(device)
elif device.type != "cpu":
out = out.to(device)
return out
def torch_to_tinygrad(tensor: torch.Tensor) -> Tensor:
tensor = tensor.detach()
if not tensor.is_contiguous():
tensor = tensor.contiguous()
if tensor.device.type == "mps":
sync_torch(tensor.device)
target_device = "METAL"
elif tensor.device.type == "cuda":
sync_torch(tensor.device)
target_device = "CUDA"
elif tensor.device.type == "cpu":
target_device = "CPU"
else:
raise ValueError(f"Unsupported torch device: {tensor.device}")
out = Tensor.from_blob(
tensor.data_ptr(),
tuple(int(dim) for dim in tensor.shape),
dtype=_from_torch_dtype(tensor.dtype),
device=target_device,
)
if out.uop.has_buffer_identity():
setattr(cast(Buffer, out.uop.buffer).base, "_external_owner", tensor)
return out
def tinygrad_to_mlx_via_torch(tensor: Tensor, *, torch_device: torch.device) -> mx.array:
return torch_to_mlx(tinygrad_to_torch(tensor, device=torch_device))
def mlx_to_tinygrad_via_torch(array: mx.array, *, torch_device: torch.device) -> Tensor:
return torch_to_tinygrad(mlx_to_torch(array, device=torch_device))
def tinygrad_to_mlx_direct(tensor: Tensor) -> mx.array:
return mx.array(tinygrad_zero_copy_memoryview(tensor))
def mlx_to_tinygrad_direct(array: mx.array, tg_dtype: DType) -> Tensor:
storage = mx.metal._unsafe_export_storage(array)
return Tensor._unsafe_from_metal_buffer_fast(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=tg_dtype,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=array,
)
def bench_callable(fn: Callable[[], Any], warmup: int, samples: int, min_batch_us: float) -> dict[str, float]:
global blackhole
for _ in range(warmup):
blackhole = fn()
min_batch_ns = int(min_batch_us * 1000.0)
iters = 1
while True:
start = time.perf_counter_ns()
for _ in range(iters):
blackhole = fn()
elapsed = time.perf_counter_ns() - start
if elapsed >= min_batch_ns or iters >= (1 << 20):
break
iters *= 2
vals_us: list[float] = []
for _ in range(samples):
start = time.perf_counter_ns()
for _ in range(iters):
blackhole = fn()
elapsed = time.perf_counter_ns() - start
vals_us.append(elapsed / iters / 1000.0)
return {
"iters": float(iters),
"avg_us": statistics.mean(vals_us),
"stddev_us": statistics.stdev(vals_us) if len(vals_us) > 1 else 0.0,
}
def assert_equal(name: str, actual: np.ndarray, expected: np.ndarray) -> None:
if np.issubdtype(expected.dtype, np.floating):
np.testing.assert_allclose(actual, expected, rtol=5e-5 if expected.dtype == np.float32 else 5e-3, atol=1e-5 if expected.dtype == np.float32 else 5e-3, err_msg=name)
else:
np.testing.assert_array_equal(actual, expected, err_msg=name)
def print_header(dtype_name: str, torch_device: torch.device) -> None:
print(f"# python={platform.python_version()} platform={platform.platform()}")
print(f"# dtype={dtype_name} mlx_metal_available={mx.metal.is_available()} tinygrad_device=METAL torch_device={torch_device}")
print("# sizes are source tensor sizes in bytes")
print("# timed loop excludes source tensor construction and explicit pre-sync, but still includes helper and wrapper overhead")
print("# reported latency is average per-call time with sample standard deviation")
print("# via_torch_route rows use pre-existing tinygrad<->torch and mlx<->torch bridges without framework patches")
print("size_bytes,method,direction,avg_us,stddev_us,iters")
def main() -> None:
args = parse_args()
if not mx.metal.is_available():
raise RuntimeError("MLX Metal is not available")
torch_device = get_torch_device(cast(Literal["cpu", "mps", "auto"], args.torch_device))
mx_dtype, tg_dtype, np_dtype = DTYPES[args.dtype]
sizes = [int(x.strip()) for x in args.sizes.split(",") if x.strip()]
gc.disable()
try:
print_header(args.dtype, torch_device)
for size_bytes in sizes:
if size_bytes <= 0:
continue
if size_bytes % np_dtype.itemsize != 0:
print(f"# skipping size {size_bytes}: not divisible by dtype itemsize {np_dtype.itemsize}", file=sys.stderr)
continue
numel = size_bytes // np_dtype.itemsize
values = np.arange(numel, dtype=np_dtype)
src_tg = Tensor(values, device="METAL", dtype=tg_dtype).realize()
src_mx = mx.array(values, dtype=mx_dtype)
sync_tinygrad()
sync_mlx()
# correctness checks stay outside the timed loop
assert_equal("tinygrad->mlx via torch raw", np.array(tinygrad_to_mlx_via_torch(src_tg, torch_device=torch_device)), values)
assert_equal("mlx->tinygrad via torch raw", mlx_to_tinygrad_via_torch(src_mx, torch_device=torch_device).numpy(), values)
benches: list[tuple[str, str, Callable[[], Any]]] = [
("via_torch_route", "tinygrad_to_mlx", lambda s=src_tg, td=torch_device: tinygrad_to_mlx_via_torch(s, torch_device=td)),
("via_torch_route", "mlx_to_tinygrad", lambda s=src_mx, td=torch_device: mlx_to_tinygrad_via_torch(s, torch_device=td)),
("bridge_half", "tinygrad_to_torch", lambda s=src_tg, td=torch_device: tinygrad_to_torch(s, device=td)),
("bridge_half", "torch_to_mlx", lambda s=src_tg, td=torch_device: torch_to_mlx(tinygrad_to_torch(s, device=td))),
("bridge_half", "mlx_to_torch", lambda s=src_mx, td=torch_device: mlx_to_torch(s, device=td)),
("bridge_half", "torch_to_tinygrad", lambda s=src_mx, td=torch_device: torch_to_tinygrad(mlx_to_torch(s, device=td))),
("direct_baseline", "tinygrad_to_mlx", lambda s=src_tg: tinygrad_to_mlx_direct(s)),
("direct_baseline", "mlx_to_tinygrad", lambda s=src_mx, td=tg_dtype: mlx_to_tinygrad_direct(s, td)),
]
for method, direction, fn in benches:
try:
blackhole = fn()
except Exception as exc:
print(f"# skipping {size_bytes},{method},{direction}: {exc}", file=sys.stderr)
continue
stats = bench_callable(fn, warmup=args.warmup, samples=args.samples, min_batch_us=args.min_batch_us)
print(
f"{size_bytes},{method},{direction},"
f"{stats['avg_us']:.3f},{stats['stddev_us']:.3f},{int(stats['iters'])}"
)
finally:
gc.enable()
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
"""Compatibility shim for the moved lease-pool implementation.
Reusable interop code now lives under `mlx_tinygrad_interop.lib`.
Benchmarks and tests remain at the package top level.
"""
from mlx_tinygrad_interop.lib.lease_pool import * # noqa: F401,F403
+31
View File
@@ -0,0 +1,31 @@
"""Reusable MLX <-> tinygrad interop helpers.
Benchmarks, stress harnesses, and tests stay in the top-level
`mlx_tinygrad_interop/` package. Reusable bridge and lease-pool code lives
under `mlx_tinygrad_interop/lib/`.
"""
from mlx_tinygrad_interop.lib.lease_pool import (
MlxToTinygradCopyKey,
MlxToTinygradCopyLeasePool,
MlxToTinygradCopyLeasePools,
MlxToTinygradLease,
MlxToTinygradLeaseKey,
MlxToTinygradLeasePool,
MlxToTinygradLeasePools,
)
from mlx_tinygrad_interop.lib.tensor_bridge import mlx_to_tinygrad, sync_mlx, sync_tinygrad, tinygrad_to_mlx
__all__ = [
"MlxToTinygradCopyKey",
"MlxToTinygradCopyLeasePool",
"MlxToTinygradCopyLeasePools",
"MlxToTinygradLease",
"MlxToTinygradLeaseKey",
"MlxToTinygradLeasePool",
"MlxToTinygradLeasePools",
"mlx_to_tinygrad",
"sync_mlx",
"sync_tinygrad",
"tinygrad_to_mlx",
]
+447
View File
@@ -0,0 +1,447 @@
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, cast
import mlx.core as mx
from tinygrad import Device, Tensor
from tinygrad.device import Buffer
from tinygrad.dtype import DTypeLike, to_dtype
from tinygrad.tensor import all_tensors
def _export_realized_storage(array: Any) -> dict[str, Any]:
mx.eval(array)
return mx.metal._unsafe_export_storage(array)
def _mlx_dtype_name(dtype: Any) -> str:
return repr(dtype).removeprefix("mlx.core.")
def _bytes_view(mv: memoryview) -> memoryview:
return mv if mv.format == "B" and mv.ndim == 1 else mv.cast("B")
def _iter_tensors(obj: Any, seen: set[int] | None = None):
if seen is None: seen = set()
obj_id = id(obj)
if obj_id in seen: return
seen.add(obj_id)
if isinstance(obj, Tensor):
yield obj
return
if isinstance(obj, dict):
for value in obj.values():
yield from _iter_tensors(value, seen)
return
if isinstance(obj, (list, tuple, set, frozenset)):
for value in obj:
yield from _iter_tensors(value, seen)
def _snapshot_live_tensors() -> dict[int, Tensor]:
return {id(t): t for tref in list(all_tensors) if (t := tref()) is not None}
def _tensor_base_buffer(t: Tensor) -> Buffer | None:
if not t.uop.has_buffer_identity(): return None
try:
return cast(Buffer, t.uop.buffer).base
except Exception:
return None
def _uop_depends_on(uop: Any, target: Any) -> bool:
seen: set[Any] = set()
stack = [uop]
while stack:
cur = stack.pop()
if cur is target: return True
if cur in seen: continue
seen.add(cur)
stack.extend(cur.src)
return False
def _reject_scoped_tensor_escapes(borrowed: Tensor, returned_tensors: tuple[Tensor, ...], pre_live_ids: set[int]) -> None:
borrowed_base = _tensor_base_buffer(borrowed)
if any(t is borrowed for t in returned_tensors):
raise RuntimeError("callback returned the borrowed tensor directly; return a copied or independently realized result instead")
if borrowed_base is not None and any(_tensor_base_buffer(t) is borrowed_base for t in returned_tensors):
raise RuntimeError("callback returned tensor(s) that still alias the borrowed slot; copy or realize independent storage before returning")
for tensor in returned_tensors:
tensor.realize()
returned_ids = {id(t) for t in returned_tensors}
escaped: list[Tensor] = []
for tensor_id, tensor in _snapshot_live_tensors().items():
if tensor_id in pre_live_ids or tensor_id in returned_ids or tensor is borrowed:
continue
if borrowed_base is not None and _tensor_base_buffer(tensor) is borrowed_base:
escaped.append(tensor)
continue
if _uop_depends_on(tensor.uop, borrowed.uop):
escaped.append(tensor)
if escaped:
raise RuntimeError(
"callback leaked tensor(s) derived from the borrowed tensor; only independent realized outputs may escape the callback"
)
def _run_with_scoped_lease(lease: "MlxToTinygradLease", fn, *, synchronize_on_release: bool | None = None):
borrowed = lease.tensor
pre_live_ids = set(_snapshot_live_tensors())
try:
result = fn(borrowed)
returned_tensors = tuple(_iter_tensors(result))
_reject_scoped_tensor_escapes(borrowed, returned_tensors, pre_live_ids)
return result
finally:
if not lease._released:
lease.release(synchronize=synchronize_on_release)
def _evict_lru_idle_pool(pools: "OrderedDict[Any, Any]", *, max_pools: int | None) -> None:
if max_pools is None or len(pools) < max_pools: return
for key, pool in list(pools.items()):
if pool.in_flight == 0:
del pools[key]
return
raise RuntimeError(f"pool cache is full ({max_pools} keys) and every pool is still in use")
@dataclass(frozen=True)
class MlxToTinygradLeaseKey:
shape: tuple[int, ...]
mlx_dtype_name: str
tinygrad_dtype_name: str
byte_offset: int
@staticmethod
def from_storage(storage: dict[str, Any], *, tg_dtype: DTypeLike) -> "MlxToTinygradLeaseKey":
return MlxToTinygradLeaseKey(
shape=tuple(int(dim) for dim in storage["shape"]),
mlx_dtype_name=_mlx_dtype_name(storage["dtype"]),
tinygrad_dtype_name=to_dtype(tg_dtype).base.name,
byte_offset=int(storage["offset_bytes"]),
)
@dataclass(frozen=True)
class MlxToTinygradCopyKey:
shape: tuple[int, ...]
mlx_dtype_name: str
tinygrad_dtype_name: str
@staticmethod
def from_storage(storage: dict[str, Any], *, tg_dtype: DTypeLike) -> "MlxToTinygradCopyKey":
return MlxToTinygradCopyKey(
shape=tuple(int(dim) for dim in storage["shape"]),
mlx_dtype_name=_mlx_dtype_name(storage["dtype"]),
tinygrad_dtype_name=to_dtype(tg_dtype).base.name,
)
@dataclass
class _AliasLeaseSlot:
borrower: Any
tensor: Tensor
generation: int = 0
in_use: bool = False
@dataclass
class _CopyLeaseSlot:
tensor: Tensor
generation: int = 0
in_use: bool = False
class MlxToTinygradLease:
__slots__ = ("_pool", "_slot_index", "_generation", "_tensor", "_released")
def __init__(self, pool: Any, slot_index: int, generation: int, tensor: Tensor):
self._pool, self._slot_index, self._generation = pool, slot_index, generation
self._tensor, self._released = tensor, False
@property
def generation(self) -> int: return self._generation
@property
def key(self) -> Any: return self._pool.key
@property
def tensor(self) -> Tensor:
# This is the unsafe low-level lease surface. The preferred production API
# is `run_with_mlx_tensor(...)`, which scopes use and release together.
if self._released: raise RuntimeError("lease already released")
return self._tensor
def release(self, *, synchronize: bool | None = None) -> None:
if self._released: raise RuntimeError("lease already released")
self._pool._release(self._slot_index, self._generation, synchronize=synchronize)
self._released = True
def __enter__(self) -> "MlxToTinygradLease": return self
def __exit__(self, exc_type, exc, tb) -> bool:
if not self._released: self.release()
return False
class MlxToTinygradLeasePool:
__slots__ = ("key", "tg_dtype", "capacity", "_slots", "_next_slot", "_synchronize_on_release")
def __init__(self, *, key: MlxToTinygradLeaseKey, tg_dtype: DTypeLike, template_storage: dict[str, Any], template_owner: Any,
capacity: int = 4, synchronize_on_release: bool = True):
if capacity <= 0: raise ValueError(f"capacity must be positive, got {capacity}")
self.key, self.tg_dtype, self.capacity = key, to_dtype(tg_dtype), capacity
self._next_slot, self._synchronize_on_release = 0, synchronize_on_release
self._slots = [
_AliasLeaseSlot(
borrower := Tensor._unsafe_metal_borrower(
int(template_storage["mtl_buffer_ptr"]),
tuple(template_storage["shape"]),
dtype=self.tg_dtype,
byte_offset=int(template_storage["offset_bytes"]),
buffer_nbytes=int(template_storage["buffer_nbytes"]),
owner=template_owner,
),
borrower.tensor,
) for _ in range(capacity)
]
@classmethod
def from_mlx(cls, array: Any, *, tg_dtype: DTypeLike, owner: Any | None = None, capacity: int = 4,
synchronize_on_release: bool = True) -> "MlxToTinygradLeasePool":
storage = _export_realized_storage(array)
owner_obj = array if owner is None else owner
return cls(
key=MlxToTinygradLeaseKey.from_storage(storage, tg_dtype=tg_dtype),
tg_dtype=tg_dtype,
template_storage=storage,
template_owner=owner_obj,
capacity=capacity,
synchronize_on_release=synchronize_on_release,
)
def _next_available_slot(self) -> tuple[int, _AliasLeaseSlot]:
for _ in range(self.capacity):
slot_index = self._next_slot
self._next_slot = (self._next_slot + 1) % self.capacity
slot = self._slots[slot_index]
if not slot.in_use: return slot_index, slot
raise RuntimeError(
f"all {self.capacity} lease slots for key={self.key} are still in use; "
"release leases or increase pool capacity"
)
def _validate_storage(self, storage: dict[str, Any]) -> None:
incoming = MlxToTinygradLeaseKey.from_storage(storage, tg_dtype=self.tg_dtype)
if incoming != self.key:
raise ValueError(f"pool key mismatch: expected {self.key}, got {incoming}")
def acquire_from_storage(self, storage: dict[str, Any], *, owner: Any) -> MlxToTinygradLease:
self._validate_storage(storage)
slot_index, slot = self._next_available_slot()
tensor = slot.borrower.rebind(
int(storage["mtl_buffer_ptr"]),
owner=owner,
shape=tuple(storage["shape"]),
dtype_name=_mlx_dtype_name(storage["dtype"]),
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
)
slot.tensor = tensor
slot.generation += 1
slot.in_use = True
return MlxToTinygradLease(self, slot_index, slot.generation, tensor)
def acquire_from_mlx(self, array: Any, *, owner: Any | None = None) -> MlxToTinygradLease:
storage = _export_realized_storage(array)
return self.acquire_from_storage(storage, owner=array if owner is None else owner)
def run_with_mlx_tensor(self, array: Any, fn, *, owner: Any | None = None,
synchronize_on_release: bool | None = None):
return _run_with_scoped_lease(
self.acquire_from_mlx(array, owner=owner),
fn,
synchronize_on_release=synchronize_on_release,
)
def _release(self, slot_index: int, generation: int, *, synchronize: bool | None = None) -> None:
slot = self._slots[slot_index]
if not slot.in_use: raise RuntimeError(f"slot {slot_index} is not currently leased")
if slot.generation != generation:
raise RuntimeError(f"stale lease generation for slot {slot_index}: expected {slot.generation}, got {generation}")
do_synchronize = synchronize if synchronize is not None else self._synchronize_on_release
if do_synchronize:
Device["METAL"].synchronize()
slot.borrower.clear_owner()
slot.in_use = False
@property
def in_flight(self) -> int: return sum(int(slot.in_use) for slot in self._slots)
class MlxToTinygradLeasePools:
__slots__ = ("capacity_per_key", "max_pools", "_synchronize_on_release", "_pools")
def __init__(self, *, capacity_per_key: int = 4, max_pools: int | None = 64, synchronize_on_release: bool = True):
if capacity_per_key <= 0: raise ValueError(f"capacity_per_key must be positive, got {capacity_per_key}")
if max_pools is not None and max_pools <= 0: raise ValueError(f"max_pools must be positive, got {max_pools}")
self.capacity_per_key, self.max_pools, self._synchronize_on_release = capacity_per_key, max_pools, synchronize_on_release
self._pools: OrderedDict[MlxToTinygradLeaseKey, MlxToTinygradLeasePool] = OrderedDict()
def _get_or_create_pool(self, storage: dict[str, Any], *, tg_dtype: DTypeLike, owner_obj: Any) -> MlxToTinygradLeasePool:
key = MlxToTinygradLeaseKey.from_storage(storage, tg_dtype=tg_dtype)
pool = self._pools.get(key)
if pool is not None:
self._pools.move_to_end(key)
return pool
_evict_lru_idle_pool(self._pools, max_pools=self.max_pools)
pool = MlxToTinygradLeasePool(
key=key,
tg_dtype=tg_dtype,
template_storage=storage,
template_owner=owner_obj,
capacity=self.capacity_per_key,
synchronize_on_release=self._synchronize_on_release,
)
self._pools[key] = pool
return pool
def acquire_from_mlx(self, array: Any, *, tg_dtype: DTypeLike, owner: Any | None = None) -> MlxToTinygradLease:
storage = _export_realized_storage(array)
owner_obj = array if owner is None else owner
pool = self._get_or_create_pool(storage, tg_dtype=tg_dtype, owner_obj=owner_obj)
return pool.acquire_from_storage(storage, owner=owner_obj)
def run_with_mlx_tensor(self, array: Any, *, tg_dtype: DTypeLike, fn, owner: Any | None = None,
synchronize_on_release: bool | None = None):
return _run_with_scoped_lease(
self.acquire_from_mlx(array, tg_dtype=tg_dtype, owner=owner),
fn,
synchronize_on_release=synchronize_on_release,
)
@property
def pool_count(self) -> int: return len(self._pools)
def get_pool(self, key: MlxToTinygradLeaseKey) -> MlxToTinygradLeasePool | None:
return self._pools.get(key)
class MlxToTinygradCopyLeasePool:
__slots__ = ("key", "tg_dtype", "capacity", "_slots", "_next_slot", "_synchronize_on_release")
def __init__(self, *, key: MlxToTinygradCopyKey, tg_dtype: DTypeLike, capacity: int = 4,
synchronize_on_release: bool = True):
if capacity <= 0: raise ValueError(f"capacity must be positive, got {capacity}")
self.key, self.tg_dtype, self.capacity = key, to_dtype(tg_dtype), capacity
self._next_slot, self._synchronize_on_release = 0, synchronize_on_release
self._slots = [_CopyLeaseSlot(Tensor.empty(*key.shape, dtype=self.tg_dtype, device="METAL").realize()) for _ in range(capacity)]
@classmethod
def from_mlx(cls, array: Any, *, tg_dtype: DTypeLike, capacity: int = 4,
synchronize_on_release: bool = True) -> "MlxToTinygradCopyLeasePool":
storage = _export_realized_storage(array)
return cls(
key=MlxToTinygradCopyKey.from_storage(storage, tg_dtype=tg_dtype),
tg_dtype=tg_dtype,
capacity=capacity,
synchronize_on_release=synchronize_on_release,
)
def _next_available_slot(self) -> tuple[int, _CopyLeaseSlot]:
for _ in range(self.capacity):
slot_index = self._next_slot
self._next_slot = (self._next_slot + 1) % self.capacity
slot = self._slots[slot_index]
if not slot.in_use: return slot_index, slot
raise RuntimeError(
f"all {self.capacity} copy slots for key={self.key} are still in use; "
"release leases or increase pool capacity"
)
def _validate_storage(self, storage: dict[str, Any]) -> None:
incoming = MlxToTinygradCopyKey.from_storage(storage, tg_dtype=self.tg_dtype)
if incoming != self.key:
raise ValueError(f"copy pool key mismatch: expected {self.key}, got {incoming}")
def acquire_from_mlx(self, array: Any) -> MlxToTinygradLease:
storage = _export_realized_storage(array)
self._validate_storage(storage)
slot_index, slot = self._next_available_slot()
cast(Buffer, slot.tensor.uop.buffer).ensure_allocated().copyin(_bytes_view(memoryview(array)))
slot.generation += 1
slot.in_use = True
return MlxToTinygradLease(self, slot_index, slot.generation, slot.tensor)
def run_with_mlx_tensor(self, array: Any, fn, *, synchronize_on_release: bool | None = None):
return _run_with_scoped_lease(
self.acquire_from_mlx(array),
fn,
synchronize_on_release=synchronize_on_release,
)
def _release(self, slot_index: int, generation: int, *, synchronize: bool | None = None) -> None:
slot = self._slots[slot_index]
if not slot.in_use: raise RuntimeError(f"slot {slot_index} is not currently leased")
if slot.generation != generation:
raise RuntimeError(f"stale lease generation for slot {slot_index}: expected {slot.generation}, got {generation}")
do_synchronize = synchronize if synchronize is not None else self._synchronize_on_release
if do_synchronize:
Device["METAL"].synchronize()
slot.in_use = False
@property
def in_flight(self) -> int: return sum(int(slot.in_use) for slot in self._slots)
class MlxToTinygradCopyLeasePools:
__slots__ = ("capacity_per_key", "max_pools", "_synchronize_on_release", "_pools")
def __init__(self, *, capacity_per_key: int = 4, max_pools: int | None = 64, synchronize_on_release: bool = True):
if capacity_per_key <= 0: raise ValueError(f"capacity_per_key must be positive, got {capacity_per_key}")
if max_pools is not None and max_pools <= 0: raise ValueError(f"max_pools must be positive, got {max_pools}")
self.capacity_per_key, self.max_pools, self._synchronize_on_release = capacity_per_key, max_pools, synchronize_on_release
self._pools: OrderedDict[MlxToTinygradCopyKey, MlxToTinygradCopyLeasePool] = OrderedDict()
def _get_or_create_pool(self, storage: dict[str, Any], *, tg_dtype: DTypeLike) -> MlxToTinygradCopyLeasePool:
key = MlxToTinygradCopyKey.from_storage(storage, tg_dtype=tg_dtype)
pool = self._pools.get(key)
if pool is not None:
self._pools.move_to_end(key)
return pool
_evict_lru_idle_pool(self._pools, max_pools=self.max_pools)
pool = MlxToTinygradCopyLeasePool(
key=key,
tg_dtype=tg_dtype,
capacity=self.capacity_per_key,
synchronize_on_release=self._synchronize_on_release,
)
self._pools[key] = pool
return pool
def acquire_from_mlx(self, array: Any, *, tg_dtype: DTypeLike) -> MlxToTinygradLease:
storage = _export_realized_storage(array)
return self._get_or_create_pool(storage, tg_dtype=tg_dtype).acquire_from_mlx(array)
def run_with_mlx_tensor(self, array: Any, *, tg_dtype: DTypeLike, fn, synchronize_on_release: bool | None = None):
return _run_with_scoped_lease(
self.acquire_from_mlx(array, tg_dtype=tg_dtype),
fn,
synchronize_on_release=synchronize_on_release,
)
@property
def pool_count(self) -> int: return len(self._pools)
def get_pool(self, key: MlxToTinygradCopyKey) -> MlxToTinygradCopyLeasePool | None:
return self._pools.get(key)
+128
View File
@@ -0,0 +1,128 @@
"""Tensor bridge between tinygrad and MLX.
Baseline implementation modelled after:
https://github.com/vllm-project/vllm-metal/blob/main/vllm_metal/pytorch_backend/tensor_bridge.py
This module currently implements the `tinygrad -> MLX` direction only.
`MLX -> tinygrad` is left stubbed on purpose.
"""
from __future__ import annotations
import logging
from typing import Literal, cast
import mlx.core as mx
from tinygrad.tensor import Tensor
from tinygrad.device import Buffer, Device
from tinygrad.dtype import DType, dtypes
logger = logging.getLogger(__name__)
# MPS has a 4GB (2^32 bytes) limit for MPSTemporaryNDArray allocations.
# Metal may allocate multiple temporary buffers internally, so we use a
# conservative threshold of 1GB to avoid hitting the limit.
# See: https://github.com/anthropics/vllm-metal/issues/43
_MPS_SAFE_SIZE_BYTES = 1 << 30 # 1GB
# MLX to tinygrad dtype mapping
MLX_TO_TINYGRAD_DTYPE: dict[mx.Dtype, DType] = {
mx.float32: dtypes.float32,
mx.float16: dtypes.float16,
mx.bfloat16: dtypes.bfloat16,
mx.int32: dtypes.int32,
mx.int64: dtypes.int64,
mx.int16: dtypes.int16,
mx.int8: dtypes.int8,
mx.uint8: dtypes.uint8,
mx.bool_: dtypes.bool,
}
# tinygrad to MLX dtype mapping
TINYGRAD_TO_MLX_DTYPE: dict[DType, mx.Dtype] = {
v: k for k, v in MLX_TO_TINYGRAD_DTYPE.items()
}
def _get_tensor_size_bytes(tensor: Tensor) -> int:
"""Calculate the size of a tinygrad tensor in bytes."""
return tensor.numel() * tensor.dtype.itemsize
def _get_buffer_view(tensor: Tensor, *, already_contiguous: bool = False) -> memoryview:
"""Expose a tinygrad tensor as a Python buffer.
For METAL tensors on Apple Silicon, this uses tinygrad's zero-copy
`as_memoryview(force_zero_copy=True)` path after forcing realization and a
Metal synchronize. For other devices, it falls back to the standard
`Tensor.data()` path.
"""
tensor = tensor.cast(tensor.dtype.base)
if not already_contiguous:
tensor = tensor.contiguous()
if tensor.device == "METAL":
tensor = tensor.realize()
sync_tinygrad()
if tensor.dtype.base.fmt is None:
raise ValueError(f"Unsupported tinygrad dtype for memoryview bridge: {tensor.dtype}")
buf = cast(Buffer, tensor.uop.buffer).ensure_allocated()
return buf.as_memoryview(force_zero_copy=True).cast(tensor.dtype.base.fmt, tensor.shape)
if tensor.device != "CPU":
tensor = tensor.to("CPU").realize()
return tensor.data()
def tinygrad_to_mlx(tensor: Tensor, *, already_contiguous: bool = False) -> mx.array:
"""Convert a tinygrad tensor to an MLX array.
Uses a buffer-protocol / memoryview path when possible. In current MLX this
still creates a fresh MLX array rather than aliasing the tinygrad buffer, but
it is the closest analogue to the reference vLLM bridge's public shape.
Args:
tensor: tinygrad tensor
already_contiguous: Skip the contiguity step if the tensor is already known
to be dense row-major contiguous.
Returns:
MLX array with the same logical values.
"""
if tensor.dtype.base not in TINYGRAD_TO_MLX_DTYPE:
raise ValueError(f"Unsupported tinygrad dtype: {tensor.dtype}")
buffer = _get_buffer_view(tensor, already_contiguous=already_contiguous)
array = mx.array(buffer)
if array.dtype != TINYGRAD_TO_MLX_DTYPE[tensor.dtype.base]:
array = array.astype(TINYGRAD_TO_MLX_DTYPE[tensor.dtype.base])
return array
def mlx_to_tinygrad(array: mx.array) -> Tensor:
"""Convert an MLX array to a tinygrad tensor.
This direction is intentionally left stubbed here. The current repo already
carries more specialized MLX -> tinygrad experiments in the lease-pool and
benchmark helpers, and this baseline bridge module is only meant to mirror
the public shape of the vLLM bridge for `tinygrad -> MLX`.
"""
raise NotImplementedError("mlx_to_tinygrad() is intentionally stubbed in this baseline bridge")
def sync_mlx() -> None:
"""Synchronize MLX operations."""
try:
mx.synchronize()
except (AttributeError, TypeError):
mx.eval(mx.array(0, dtype=mx.int32))
def sync_tinygrad() -> None:
"""Synchronize tinygrad METAL operations."""
try:
Device["METAL"].synchronize()
except Exception:
logger.debug("tinygrad METAL synchronize unavailable", exc_info=True)
@@ -0,0 +1,184 @@
# SPDX-License-Identifier: Apache-2.0
"""Tensor bridge between MLX and PyTorch.
Provides zero-copy conversion when possible using Apple Silicon's unified memory.
"""
import logging
from typing import Literal
import mlx.core as mx
import torch
logger = logging.getLogger(__name__)
# MPS has a 4GB (2^32 bytes) limit for MPSTemporaryNDArray allocations.
# Metal may allocate multiple temporary buffers internally, so we use a
# conservative threshold of 1GB to avoid hitting the limit.
# See: https://github.com/anthropics/vllm-metal/issues/43
_MPS_SAFE_SIZE_BYTES = 1 << 30 # 1GB
# MLX to PyTorch dtype mapping
MLX_TO_TORCH_DTYPE: dict[mx.Dtype, torch.dtype] = {
mx.float32: torch.float32,
mx.float16: torch.float16,
mx.bfloat16: torch.bfloat16,
mx.int32: torch.int32,
mx.int64: torch.int64,
mx.int16: torch.int16,
mx.int8: torch.int8,
mx.uint8: torch.uint8,
mx.bool_: torch.bool,
}
# PyTorch to MLX dtype mapping
TORCH_TO_MLX_DTYPE: dict[torch.dtype, mx.Dtype] = {
v: k for k, v in MLX_TO_TORCH_DTYPE.items()
}
def get_torch_device() -> torch.device:
"""Get the PyTorch device for Metal/MPS.
Returns:
torch.device for MPS if available, else CPU
"""
if torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
def _get_tensor_size_bytes(array: mx.array) -> int:
"""Calculate the size of an MLX array in bytes.
Args:
array: MLX array
Returns:
Size in bytes
"""
return array.size * array.dtype.size
def _is_safe_for_mps(array: mx.array) -> bool:
"""Check if an array is safe to transfer to MPS without hitting size limits.
MPS has a 4GB limit for MPSTemporaryNDArray, but Metal may allocate
multiple temporary buffers internally. We use a conservative threshold.
Args:
array: MLX array to check
Returns:
True if safe to transfer to MPS, False if should stay on CPU
"""
return _get_tensor_size_bytes(array) < _MPS_SAFE_SIZE_BYTES
def torch_to_mlx(tensor: torch.Tensor) -> mx.array:
"""Convert PyTorch tensor to MLX array.
Uses numpy as an intermediate to enable zero-copy on unified memory.
Args:
tensor: PyTorch tensor (can be on any device)
Returns:
MLX array with the same data
"""
# Move to CPU if on MPS for numpy conversion
if tensor.device.type != "cpu":
tensor = tensor.cpu()
tensor = tensor.detach()
# Note: numpy does not support bfloat16.
if tensor.dtype == torch.bfloat16:
return mx.array(tensor)
return mx.array(tensor.numpy())
def mlx_to_torch(
array: mx.array,
device: torch.device | Literal["mps", "cpu"] | None = None,
already_contiguous: bool = False,
) -> torch.Tensor:
"""Convert MLX array to PyTorch tensor.
Uses numpy as an intermediate to enable zero-copy on unified memory.
Args:
array: MLX array
device: Target PyTorch device (default: MPS if available)
already_contiguous: Skip contiguity check if array is known contiguous
Returns:
PyTorch tensor with the same data
"""
if device is None:
device = get_torch_device()
elif isinstance(device, str):
device = torch.device(device)
# Use memoryview for zero-copy conversion (bypasses numpy for bfloat16)
# reference: https://github.com/ml-explore/mlx/issues/403
torch_dtype = MLX_TO_TORCH_DTYPE.get(array.dtype)
if torch_dtype is not None:
if already_contiguous:
# Fast path: skip contiguity check, single eval
mx.eval(array)
buffer = memoryview(array)
else:
# MLX views / non-contiguous arrays expose a non-contiguous buffer (or
# sometimes no usable buffer), which `torch.frombuffer` can't consume.
# Make contiguous first, then eval once
array = mx.contiguous(array)
mx.eval(array)
buffer = memoryview(array)
tensor = torch.frombuffer(buffer, dtype=torch_dtype).reshape(array.shape)
else:
# Fallback to numpy path for unsupported dtypes
raise ValueError(f"Unsupported MLX dtype: {array.dtype}")
# Move to target device, but check for MPS size limits first
if device.type == "mps":
# Ensure all MLX Metal commands complete before MPS uses the GPU
sync_mlx()
if _is_safe_for_mps(array):
tensor = tensor.to(device)
else:
# Large tensor - keep on CPU to avoid MPS 4GB limit crash
# See: https://github.com/anthropics/vllm-metal/issues/43
logger.debug(
"Tensor too large for MPS (%d bytes > %d limit), keeping on CPU",
_get_tensor_size_bytes(array),
_MPS_SAFE_SIZE_BYTES,
)
elif device.type != "cpu":
tensor = tensor.to(device)
return tensor
def sync_mlx() -> None:
"""Synchronize MLX operations.
Call this before converting MLX arrays to ensure all operations complete.
"""
# Prefer an explicit MLX barrier when available; otherwise force evaluation.
# `mx.eval([])` is a no-op, so we evaluate a tiny scalar as a safe fallback.
try:
mx.synchronize()
except (AttributeError, TypeError):
mx.eval(mx.array(0, dtype=mx.int32))
def sync_torch() -> None:
"""Synchronize PyTorch MPS operations.
Call this before converting PyTorch tensors to ensure all operations complete.
"""
if torch.backends.mps.is_available():
torch.mps.synchronize()
+34
View File
@@ -0,0 +1,34 @@
from tinygrad.helpers import GlobalCounters
from tinygrad.tensor import Tensor
from tinygrad.device import Device
from tinygrad.dtype import _from_torch_dtype, _from_np_dtype
import torch
import time
import statistics
def main() -> None:
for i in range(8):
N = 256 * (4 ** i)
x = torch.zeros(N, device=torch.device("mps"))
vals = []
for j in range(1000):
x = x.uniform_()
torch.mps.synchronize()
old = time.perf_counter_ns()
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
Tensor.from_blob(x.data_ptr(), x.shape, dtype=_from_torch_dtype(x.dtype), device="METAL")
new = time.perf_counter_ns()
vals.append(float(new - old) / 8)
print( f"result: {N*4:8d} pytorch to tinygrad in {statistics.mean(vals):.2f}ns with stddev {statistics.stdev(vals):.2f}ns" )
if __name__=="__main__":
main()
+473
View File
@@ -0,0 +1,473 @@
from __future__ import annotations
import argparse
import gc
import resource
import sys
import tracemalloc
from typing import Any, cast
import mlx.core as mx
import numpy as np
from tinygrad import Device, Tensor, dtypes
from tinygrad.device import Buffer
try:
from mlx_tinygrad_interop.lib.lease_pool import MlxToTinygradCopyLeasePools, MlxToTinygradLeasePools
except ModuleNotFoundError:
from lib.lease_pool import MlxToTinygradCopyLeasePools, MlxToTinygradLeasePools
DTYPES: dict[str, tuple[Any, Any, np.dtype[Any]]] = {
"float16": (mx.float16, dtypes.float16, np.dtype(np.float16)),
"float32": (mx.float32, dtypes.float32, np.dtype(np.float32)),
"int32": (mx.int32, dtypes.int32, np.dtype(np.int32)),
"uint8": (mx.uint8, dtypes.uint8, np.dtype(np.uint8)),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Randomized correctness and soak checks for MLX <-> tinygrad interop.")
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--cases", type=int, default=64, help="Number of randomized correctness cases.")
parser.add_argument("--soak-iterations", type=int, default=512, help="Number of repeated pool/copy iterations after correctness checks.")
parser.add_argument("--max-elements", type=int, default=4096, help="Upper bound on random tensor element count.")
parser.add_argument("--max-pools", type=int, default=16, help="Maximum keyed pools to retain for alias and copy handoff managers.")
parser.add_argument("--dtypes", default="float16,float32,int32,uint8", help="Comma-separated dtype names to exercise.")
return parser.parse_args()
def tinygrad_zero_copy_memoryview(t: Tensor) -> memoryview:
buf = cast(Buffer, t.uop.buffer).ensure_allocated()
assert t.dtype.base.fmt is not None, f"no buffer format for dtype {t.dtype.base}"
return buf.as_memoryview(force_zero_copy=True).cast(t.dtype.base.fmt, t.shape)
def tinygrad_from_mlx_fast(x: Any, tg_dtype: Any) -> Tensor:
storage = mx.metal._unsafe_export_storage(x)
return Tensor._unsafe_from_metal_buffer_fast(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=tg_dtype,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=x,
)
def tinygrad_from_mlx_single_entry(x: Any, tg_dtype: Any) -> Tensor:
return mx.metal._unsafe_to_tinygrad_fast(x, tg_dtype, owner=x)
def mlx_from_tinygrad_alias_only(t: Tensor, mx_dtype: Any) -> Any:
storage = t._unsafe_metal_storage()
return mx.metal._unsafe_array_from_ptr_alias_only(
int(storage["raw_ptr"]),
tuple(storage["shape"]),
mx_dtype,
owner=t,
)
def mlx_from_tinygrad_maybe_copy(t: Tensor, mx_dtype: Any) -> Any:
storage = t._unsafe_metal_storage()
return mx.metal._unsafe_array_from_ptr(
int(storage["raw_ptr"]),
tuple(storage["shape"]),
mx_dtype,
owner=t,
)
def mlx_from_tinygrad_copy(t: Tensor) -> Any:
return mx.array(tinygrad_zero_copy_memoryview(t))
def assert_array_close(name: str, actual: np.ndarray, expected: np.ndarray) -> None:
if np.issubdtype(expected.dtype, np.floating):
if expected.dtype == np.float16:
rtol, atol = 5e-3, 5e-3
else:
# Mixed matmul/reduction chains across NumPy/MLX/tinygrad can drift by a
# few ulps from accumulation-order differences even when the conversion is
# correct. Keep float32 strict, but not unrealistically bit-exact.
rtol, atol = 5e-5, 1e-5
np.testing.assert_allclose(actual, expected, rtol=rtol, atol=atol, err_msg=name)
else:
np.testing.assert_array_equal(actual, expected, err_msg=name)
def rss_max_bytes() -> int:
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return int(rss if sys.platform == "darwin" else rss * 1024)
def mlx_memory_snapshot() -> dict[str, int]:
stats: dict[str, int] = {}
for name in ("get_active_memory", "get_cache_memory", "get_peak_memory"):
fn = getattr(mx, name, None)
if callable(fn): stats[name] = int(fn())
return stats
def random_shape(rng: np.random.Generator, max_elements: int) -> tuple[int, ...]:
ndim = int(rng.integers(1, 5))
remaining = max(1, int(rng.integers(1, max_elements + 1)))
shape: list[int] = []
for dim_index in range(ndim):
dims_left = ndim - dim_index
if dims_left == 1:
shape.append(remaining)
break
dim = int(rng.integers(1, max(2, int(round(remaining ** (1 / dims_left))) + 2)))
shape.append(dim)
remaining = max(1, remaining // dim)
return tuple(shape)
def random_values(rng: np.random.Generator, np_dtype: np.dtype[Any], shape: tuple[int, ...]) -> np.ndarray:
if np.issubdtype(np_dtype, np.floating):
data = rng.standard_normal(np.prod(shape, dtype=np.int64)).astype(np.float32)
return data.astype(np_dtype).reshape(shape)
if np.issubdtype(np_dtype, np.unsignedinteger):
info = np.iinfo(np_dtype)
return rng.integers(0, min(info.max, 255) + 1, size=shape, dtype=np_dtype)
info = np.iinfo(np_dtype)
return rng.integers(max(info.min, -128), min(info.max, 127) + 1, size=shape, dtype=np_dtype)
def make_mlx_source(values: np.ndarray, mx_dtype: Any, rng: np.random.Generator) -> Any:
flat = values.reshape(-1)
if flat.size > 0 and rng.random() < 0.5:
offset_elems = int(rng.integers(1, 33))
suffix = int(rng.integers(1, 33))
backing = np.zeros(offset_elems + flat.size + suffix, dtype=flat.dtype)
backing[offset_elems:offset_elems + flat.size] = flat
base = mx.array(backing, dtype=mx_dtype)
view = base[offset_elems:offset_elems + flat.size].reshape(values.shape)
mx.eval(view)
return view
return mx.array(values, dtype=mx_dtype)
def random_scalar(rng: np.random.Generator, np_dtype: np.dtype[Any]) -> Any:
if np.issubdtype(np_dtype, np.floating):
return np_dtype.type(rng.uniform(-2.0, 2.0)).item()
if np.issubdtype(np_dtype, np.unsignedinteger):
return int(rng.integers(0, 4))
return int(rng.integers(-3, 4))
def random_slice(axis_size: int, rng: np.random.Generator) -> tuple[int, int]:
start = int(rng.integers(0, axis_size))
end = int(rng.integers(start + 1, axis_size + 1))
return start, end
def random_ops(rng: np.random.Generator, shape: tuple[int, ...], np_dtype: np.dtype[Any]) -> list[tuple[str, Any]]:
ops: list[tuple[str, Any]] = []
cur_shape = shape
if len(cur_shape) > 1 and rng.random() < 0.7:
perm = tuple(int(x) for x in rng.permutation(len(cur_shape)))
ops.append(("transpose", perm))
cur_shape = tuple(cur_shape[i] for i in perm)
if len(cur_shape) > 1 and rng.random() < 0.6:
reshaped = tuple(reversed(cur_shape))
ops.append(("reshape", reshaped))
cur_shape = reshaped
if any(dim > 1 for dim in cur_shape) and rng.random() < 0.5:
axis = int(rng.choice([i for i, dim in enumerate(cur_shape) if dim > 1]))
start, end = random_slice(cur_shape[axis], rng)
ops.append(("slice", (axis, start, end)))
cur_shape = cur_shape[:axis] + (end - start,) + cur_shape[axis + 1:]
ops.append(("add", random_scalar(rng, np_dtype)))
ops.append(("mul", random_scalar(rng, np_dtype)))
if cur_shape and rng.random() < 0.7:
bshape = tuple(dim if rng.random() < 0.5 else 1 for dim in cur_shape)
ops.append(("broadcast_add", random_values(rng, np_dtype, bshape)))
if rng.random() < 0.5:
ops.append(("relu", None))
if np.issubdtype(np_dtype, np.floating) and cur_shape and rng.random() < 0.35:
out_cols = int(rng.integers(1, min(8, cur_shape[-1]) + 1))
weight = random_values(rng, np_dtype, (cur_shape[-1], out_cols))
ops.append(("matmul_lastdim", weight))
cur_shape = (int(np.prod(cur_shape[:-1], dtype=np.int64)), out_cols)
if cur_shape and rng.random() < 0.5:
axis = int(rng.integers(0, len(cur_shape)))
keepdim = bool(rng.integers(0, 2))
ops.append(("sum", (axis, keepdim)))
cur_shape = cur_shape[:axis] + ((1,) if keepdim else ()) + cur_shape[axis + 1:]
if cur_shape and rng.random() < 0.35:
axis = int(rng.integers(0, len(cur_shape)))
ops.append(("concat_self", axis))
return ops
def _slice_spec(shape: tuple[int, ...], axis: int, start: int, end: int) -> tuple[slice, ...]:
return tuple(slice(start, end) if i == axis else slice(None) for i in range(len(shape)))
def apply_numpy_ops(x: np.ndarray, ops: list[tuple[str, Any]]) -> np.ndarray:
out = x.copy()
for op, arg in ops:
if op == "transpose":
out = np.transpose(out, arg)
elif op == "reshape":
out = out.reshape(arg)
elif op == "slice":
axis, start, end = arg
out = out[_slice_spec(out.shape, axis, start, end)]
elif op == "add":
out = out + arg
elif op == "mul":
out = out * arg
elif op == "broadcast_add":
out = out + arg
elif op == "relu":
out = np.maximum(out, 0)
elif op == "matmul_lastdim":
# NumPy's `@` path on the current macOS validation host produced an
# incorrect all-zero result for a valid contiguous float32 case that MLX,
# tinygrad, and `np.einsum` all agreed on. Use einsum here so the stress
# harness keeps a trustworthy numerical baseline.
out = np.einsum("ik,kj->ij", out.reshape(-1, out.shape[-1]), arg, optimize=True)
elif op == "sum":
axis, keepdim = arg
out = out.sum(axis=axis, keepdims=keepdim)
elif op == "concat_self":
out = np.concatenate([out, out], axis=arg)
else:
raise RuntimeError(f"unknown op {op}")
return out
def apply_tinygrad_ops(x: Tensor, ops: list[tuple[str, Any]]) -> Tensor:
out = x
for op, arg in ops:
if op == "transpose":
out = out.permute(arg)
elif op == "reshape":
out = out.reshape(arg)
elif op == "slice":
axis, start, end = arg
out = out[_slice_spec(out.shape, axis, start, end)]
elif op == "add":
out = out + arg
elif op == "mul":
out = out * arg
elif op == "broadcast_add":
out = out + Tensor(arg, device=out.device, dtype=out.dtype)
elif op == "relu":
out = out.relu()
elif op == "matmul_lastdim":
out = out.reshape(-1, out.shape[-1]) @ Tensor(arg, device=out.device, dtype=out.dtype)
elif op == "sum":
axis, keepdim = arg
out = out.sum(axis=axis, keepdim=keepdim)
elif op == "concat_self":
out = out.cat(out, dim=arg)
else:
raise RuntimeError(f"unknown op {op}")
return out.realize()
def apply_mlx_ops(x: Any, ops: list[tuple[str, Any]]) -> Any:
out = x
for op, arg in ops:
if op == "transpose":
out = mx.transpose(out, arg)
elif op == "reshape":
out = mx.reshape(out, arg)
elif op == "slice":
axis, start, end = arg
out = out[_slice_spec(out.shape, axis, start, end)]
elif op == "add":
out = out + arg
elif op == "mul":
out = out * arg
elif op == "broadcast_add":
out = out + mx.array(arg, dtype=out.dtype)
elif op == "relu":
out = mx.maximum(out, 0)
elif op == "matmul_lastdim":
out = mx.reshape(out, (-1, out.shape[-1])) @ mx.array(arg, dtype=out.dtype)
elif op == "sum":
axis, keepdim = arg
out = mx.sum(out, axis=axis, keepdims=keepdim)
elif op == "concat_self":
out = mx.concatenate([out, out], axis=arg)
else:
raise RuntimeError(f"unknown op {op}")
mx.eval(out)
return out
def run_case(case_index: int, rng: np.random.Generator, mx_dtype: Any, tg_dtype: Any, np_dtype: np.dtype[Any],
max_elements: int, alias_pools: MlxToTinygradLeasePools, copy_pools: MlxToTinygradCopyLeasePools) -> None:
shape = random_shape(rng, max_elements)
values = random_values(rng, np_dtype, shape)
ops = random_ops(rng, shape, np_dtype)
mx_source = make_mlx_source(values, mx_dtype, rng)
tg_source = Tensor(values, device="METAL", dtype=tg_dtype).realize()
mx_baseline_source = mx.array(values, dtype=mx_dtype)
Device["METAL"].synchronize()
expected_raw = values
tg_expected_after_ops = apply_tinygrad_ops(tg_source, ops).numpy()
mlx_expected_after_ops = np.array(apply_mlx_ops(mx_baseline_source, ops))
tg_fast = tinygrad_from_mlx_fast(mx_source, tg_dtype)
tg_single = tinygrad_from_mlx_single_entry(mx_source, tg_dtype)
assert_array_close(f"case {case_index} mlx->tinygrad fast raw", tg_fast.numpy(), expected_raw)
assert_array_close(f"case {case_index} mlx->tinygrad single raw", tg_single.numpy(), expected_raw)
assert_array_close(
f"case {case_index} mlx->tinygrad fast ops",
apply_tinygrad_ops(tg_fast, ops).numpy(),
tg_expected_after_ops,
)
assert_array_close(
f"case {case_index} mlx->tinygrad single ops",
apply_tinygrad_ops(tg_single, ops).numpy(),
tg_expected_after_ops,
)
alias_result = alias_pools.run_with_mlx_tensor(
mx_source,
tg_dtype=tg_dtype,
fn=lambda tg: apply_tinygrad_ops(tg, ops),
)
copy_result = copy_pools.run_with_mlx_tensor(
mx_source,
tg_dtype=tg_dtype,
fn=lambda tg: apply_tinygrad_ops(tg, ops),
)
assert_array_close(f"case {case_index} alias scoped ops", alias_result.numpy(), tg_expected_after_ops)
assert_array_close(f"case {case_index} copy scoped ops", copy_result.numpy(), tg_expected_after_ops)
alias_roundtrip = np.array(mlx_from_tinygrad_copy(alias_result.cast(tg_dtype).realize()))
copy_roundtrip = np.array(mlx_from_tinygrad_copy(copy_result.cast(tg_dtype).realize()))
assert_array_close(f"case {case_index} alias roundtrip raw", alias_roundtrip, np.array(alias_result.cast(tg_dtype).numpy(), copy=True))
assert_array_close(f"case {case_index} copy roundtrip raw", copy_roundtrip, np.array(copy_result.cast(tg_dtype).numpy(), copy=True))
mx_alias = mlx_from_tinygrad_alias_only(tg_source, mx_dtype)
mx_maybe_copy = mlx_from_tinygrad_maybe_copy(tg_source, mx_dtype)
mx_copy = mlx_from_tinygrad_copy(tg_source)
assert_array_close(f"case {case_index} tinygrad->mlx alias raw", np.array(mx_alias), expected_raw)
assert_array_close(f"case {case_index} tinygrad->mlx maybe_copy raw", np.array(mx_maybe_copy), expected_raw)
assert_array_close(f"case {case_index} tinygrad->mlx copy raw", np.array(mx_copy), expected_raw)
assert_array_close(
f"case {case_index} tinygrad->mlx alias ops",
np.array(apply_mlx_ops(mx_alias, ops)),
mlx_expected_after_ops,
)
assert_array_close(
f"case {case_index} tinygrad->mlx maybe_copy ops",
np.array(apply_mlx_ops(mx_maybe_copy, ops)),
mlx_expected_after_ops,
)
assert_array_close(
f"case {case_index} tinygrad->mlx copy ops",
np.array(apply_mlx_ops(mx_copy, ops)),
mlx_expected_after_ops,
)
def run_soak(rng: np.random.Generator, dtype_names: list[str], iterations: int, max_elements: int, max_pools: int) -> tuple[float, list[tuple[int, dict[str, int], int, int, int]]]:
alias_pools = MlxToTinygradLeasePools(capacity_per_key=8, max_pools=max_pools, synchronize_on_release=True)
copy_pools = MlxToTinygradCopyLeasePools(capacity_per_key=8, max_pools=max_pools, synchronize_on_release=True)
checksum = 0.0
native_checkpoints: list[tuple[int, dict[str, int], int, int, int]] = []
for iteration in range(iterations):
dtype_name = dtype_names[iteration % len(dtype_names)]
mx_dtype, tg_dtype, np_dtype = DTYPES[dtype_name]
shape = random_shape(rng, max_elements)
values = random_values(rng, np_dtype, shape)
mx_source = make_mlx_source(values, mx_dtype, rng)
tg_source = Tensor(values, device="METAL", dtype=tg_dtype).realize()
Device["METAL"].synchronize()
checksum += float(alias_pools.run_with_mlx_tensor(
mx_source,
tg_dtype=tg_dtype,
fn=lambda tg: apply_tinygrad_ops(tg, [("add", 1.0 if np.issubdtype(np_dtype, np.floating) else 1)]).sum(),
).item())
checksum += float(copy_pools.run_with_mlx_tensor(
mx_source,
tg_dtype=tg_dtype,
fn=lambda tg: apply_tinygrad_ops(tg, [("mul", 1.0 if np.issubdtype(np_dtype, np.floating) else 1)]).sum(),
).item())
checksum += float(np.array(mlx_from_tinygrad_copy(tg_source)).astype(np.float64).sum())
if (iteration + 1) % 64 == 0:
gc.collect()
native_checkpoints.append((iteration + 1, mlx_memory_snapshot(), rss_max_bytes(), alias_pools.pool_count, copy_pools.pool_count))
if alias_pools.pool_count > max_pools:
raise AssertionError(f"alias pool registry exceeded cap: {alias_pools.pool_count} > {max_pools}")
if copy_pools.pool_count > max_pools:
raise AssertionError(f"copy pool registry exceeded cap: {copy_pools.pool_count} > {max_pools}")
print(f"# soak_checksum={checksum:.6f} alias_pool_count={alias_pools.pool_count} copy_pool_count={copy_pools.pool_count}")
return checksum, native_checkpoints
def main() -> None:
args = parse_args()
if not mx.metal.is_available():
raise RuntimeError("Metal is not available")
dtype_names = [name.strip() for name in args.dtypes.split(",") if name.strip()]
missing = [name for name in dtype_names if name not in DTYPES]
if missing:
raise RuntimeError(f"unknown dtype(s): {', '.join(missing)}")
rng = np.random.default_rng(args.seed)
tracemalloc.start()
before_cur, before_peak = tracemalloc.get_traced_memory()
reset_peak = getattr(mx, "reset_peak_memory", None)
if callable(reset_peak): reset_peak()
native_before = mlx_memory_snapshot()
rss_before = rss_max_bytes()
alias_pools = MlxToTinygradLeasePools(capacity_per_key=8, max_pools=args.max_pools, synchronize_on_release=True)
copy_pools = MlxToTinygradCopyLeasePools(capacity_per_key=8, max_pools=args.max_pools, synchronize_on_release=True)
for case_index in range(args.cases):
dtype_name = dtype_names[case_index % len(dtype_names)]
run_case(case_index, rng, *DTYPES[dtype_name], max_elements=args.max_elements, alias_pools=alias_pools, copy_pools=copy_pools)
_, native_checkpoints = run_soak(rng, dtype_names, iterations=args.soak_iterations, max_elements=args.max_elements, max_pools=args.max_pools)
gc.collect()
after_cur, after_peak = tracemalloc.get_traced_memory()
native_after = mlx_memory_snapshot()
rss_after = rss_max_bytes()
active_peak = max([native_before.get("get_active_memory", 0), native_after.get("get_active_memory", 0),
*[stats.get("get_active_memory", 0) for _, stats, _, _, _ in native_checkpoints]], default=0)
cache_peak = max([native_before.get("get_cache_memory", 0), native_after.get("get_cache_memory", 0),
*[stats.get("get_cache_memory", 0) for _, stats, _, _, _ in native_checkpoints]], default=0)
rss_peak = max([rss_before, rss_after, *[rss for _, _, rss, _, _ in native_checkpoints]], default=0)
alias_pool_peak = max([alias_pools.pool_count, *[alias_pool_count for _, _, _, alias_pool_count, _ in native_checkpoints]], default=0)
copy_pool_peak = max([copy_pools.pool_count, *[copy_pool_count for _, _, _, _, copy_pool_count in native_checkpoints]], default=0)
print(
"# native_memory"
f" mlx_active_start={native_before.get('get_active_memory', -1)}"
f" mlx_active_end={native_after.get('get_active_memory', -1)}"
f" mlx_active_peak={active_peak}"
f" mlx_cache_start={native_before.get('get_cache_memory', -1)}"
f" mlx_cache_end={native_after.get('get_cache_memory', -1)}"
f" mlx_cache_peak={cache_peak}"
f" mlx_reported_peak={native_after.get('get_peak_memory', -1)}"
f" rss_max_start={rss_before}"
f" rss_max_end={rss_after}"
f" rss_max_peak={rss_peak}"
f" alias_pool_peak={alias_pool_peak}"
f" copy_pool_peak={copy_pool_peak}"
)
print(
"# stress_ok"
f" cases={args.cases}"
f" soak_iterations={args.soak_iterations}"
f" max_pools={args.max_pools}"
f" tracemalloc_current={after_cur - before_cur}"
f" tracemalloc_peak={after_peak - before_peak}"
)
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility shim for the moved tensor bridge implementation."""
from mlx_tinygrad_interop.lib.tensor_bridge import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility shim for the moved vLLM-style torch bridge module."""
from mlx_tinygrad_interop.lib.tensor_bridge_vllm import * # noqa: F401,F403
+221
View File
@@ -0,0 +1,221 @@
import unittest
import mlx.core as mx
import numpy as np
from tinygrad import Tensor, dtypes
from mlx_tinygrad_interop.lib.lease_pool import (
MlxToTinygradCopyKey,
MlxToTinygradCopyLeasePool,
MlxToTinygradCopyLeasePools,
MlxToTinygradLeaseKey,
MlxToTinygradLeasePool,
MlxToTinygradLeasePools,
)
@unittest.skipUnless(mx.metal.is_available(), "Metal is not available")
class TestMlxTinygradLeasePool(unittest.TestCase):
def test_checked_rebind_requires_shape_and_dtype(self):
array = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
storage = mx.metal._unsafe_export_storage(array)
borrower = Tensor._unsafe_metal_borrower(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=array,
)
with self.assertRaises(TypeError):
borrower.rebind(
int(storage["mtl_buffer_ptr"]),
owner=array,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
)
def test_lease_pool_evaluates_lazy_array_on_acquire(self):
lazy = mx.arange(32, dtype=mx.float32) + 3
pool = MlxToTinygradLeasePool.from_mlx(lazy, tg_dtype=dtypes.float32, capacity=1)
with pool.acquire_from_mlx(lazy) as lease:
np.testing.assert_array_equal(lease.tensor.numpy(), np.arange(32, dtype=np.float32) + np.float32(3))
def test_raw_lease_tensor_can_observe_reused_slot_contents(self):
first = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
second = mx.array(np.arange(16, dtype=np.float32) + np.float32(10), dtype=mx.float32)
pool = MlxToTinygradLeasePool.from_mlx(first, tg_dtype=dtypes.float32, capacity=1)
lease_a = pool.acquire_from_mlx(first)
raw_tensor = lease_a.tensor
lease_a.release()
with pool.acquire_from_mlx(second):
np.testing.assert_array_equal(raw_tensor.numpy(), np.arange(16, dtype=np.float32) + np.float32(10))
def test_lease_pool_capacity_requires_release(self):
first = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
second = mx.array(np.arange(16, dtype=np.float32) + np.float32(1), dtype=mx.float32)
pool = MlxToTinygradLeasePool.from_mlx(first, tg_dtype=dtypes.float32, capacity=1)
lease = pool.acquire_from_mlx(first)
with self.assertRaisesRegex(RuntimeError, "still in use"):
pool.acquire_from_mlx(second)
lease.release(synchronize=False)
with pool.acquire_from_mlx(second) as second_lease:
np.testing.assert_array_equal(second_lease.tensor.numpy(), np.arange(16, dtype=np.float32) + np.float32(1))
def test_lease_release_invalidates_future_access(self):
array = mx.array(np.arange(8, dtype=np.float32), dtype=mx.float32)
pool = MlxToTinygradLeasePool.from_mlx(array, tg_dtype=dtypes.float32, capacity=1)
lease = pool.acquire_from_mlx(array)
self.assertEqual(lease.generation, 1)
lease.release(synchronize=False)
with self.assertRaisesRegex(RuntimeError, "already released"):
_ = lease.tensor
with self.assertRaisesRegex(RuntimeError, "already released"):
lease.release(synchronize=False)
def test_safe_release_clears_owner_reference(self):
array = mx.array(np.arange(8, dtype=np.float32), dtype=mx.float32)
pool = MlxToTinygradLeasePool.from_mlx(array, tg_dtype=dtypes.float32, capacity=1)
lease = pool.acquire_from_mlx(array)
self.assertTrue(hasattr(pool._slots[0].borrower._base_buf, "_external_owner"))
lease.release()
self.assertFalse(hasattr(pool._slots[0].borrower._base_buf, "_external_owner"))
def test_unsafe_release_without_sync_retains_owner_reference(self):
array = mx.array(np.arange(8, dtype=np.float32), dtype=mx.float32)
pool = MlxToTinygradLeasePool.from_mlx(array, tg_dtype=dtypes.float32, capacity=1, synchronize_on_release=False)
lease = pool.acquire_from_mlx(array)
lease.release(synchronize=False)
self.assertTrue(hasattr(pool._slots[0].borrower._base_buf, "_external_owner"))
def test_scoped_handoff_realizes_returned_tensor_before_release(self):
first = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
second = mx.array(np.arange(16, dtype=np.float32) + np.float32(5), dtype=mx.float32)
pools = MlxToTinygradLeasePools(capacity_per_key=1, synchronize_on_release=True)
result_first = pools.run_with_mlx_tensor(first, tg_dtype=dtypes.float32, fn=lambda t: (t + 1).sum())
result_second = pools.run_with_mlx_tensor(second, tg_dtype=dtypes.float32, fn=lambda t: (t + 1).sum())
np.testing.assert_allclose(result_first.numpy(), np.array((np.arange(16, dtype=np.float32) + 1).sum(), dtype=np.float32))
np.testing.assert_allclose(result_second.numpy(), np.array((np.arange(16, dtype=np.float32) + 6).sum(), dtype=np.float32))
def test_scoped_handoff_rejects_returning_alias_view(self):
array = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
pools = MlxToTinygradLeasePools(capacity_per_key=1, synchronize_on_release=True)
with self.assertRaisesRegex(RuntimeError, "alias the borrowed slot"):
pools.run_with_mlx_tensor(array, tg_dtype=dtypes.float32, fn=lambda t: t.reshape(2, 8))
def test_scoped_handoff_rejects_stashed_lazy_tensor(self):
array = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
pools = MlxToTinygradLeasePools(capacity_per_key=1, synchronize_on_release=True)
stash: dict[str, Tensor] = {}
with self.assertRaisesRegex(RuntimeError, "leaked tensor\\(s\\) derived"):
pools.run_with_mlx_tensor(
array,
tg_dtype=dtypes.float32,
fn=lambda t: (stash.setdefault("u", t + 1), (t + 2).sum())[1],
)
def test_scoped_handoff_rejects_stashed_alias_view(self):
array = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
pools = MlxToTinygradLeasePools(capacity_per_key=1, synchronize_on_release=True)
stash: dict[str, Tensor] = {}
with self.assertRaisesRegex(RuntimeError, "leaked tensor\\(s\\) derived"):
pools.run_with_mlx_tensor(
array,
tg_dtype=dtypes.float32,
fn=lambda t: (stash.setdefault("u", t.reshape(2, 8)), (t + 2).sum())[1],
)
def test_scoped_handoff_rejects_returning_borrowed_tensor(self):
array = mx.array(np.arange(8, dtype=np.float32), dtype=mx.float32)
pool = MlxToTinygradLeasePool.from_mlx(array, tg_dtype=dtypes.float32, capacity=1)
with self.assertRaisesRegex(RuntimeError, "borrowed tensor directly"):
pool.run_with_mlx_tensor(array, lambda t: t)
def test_keyed_lease_pools_bucket_by_contract(self):
flat = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
matrix = mx.array(np.arange(16, dtype=np.float32).reshape(4, 4), dtype=mx.float32)
pools = MlxToTinygradLeasePools(capacity_per_key=2, synchronize_on_release=False)
with pools.acquire_from_mlx(flat, tg_dtype=dtypes.float32) as flat_lease:
self.assertEqual(flat_lease.key, MlxToTinygradLeaseKey((16,), "float32", dtypes.float32.base.name, 0))
with pools.acquire_from_mlx(matrix, tg_dtype=dtypes.float32) as matrix_lease:
self.assertEqual(matrix_lease.key, MlxToTinygradLeaseKey((4, 4), "float32", dtypes.float32.base.name, 0))
self.assertEqual(pools.pool_count, 2)
def test_alias_pool_registry_eviction_is_bounded(self):
pools = MlxToTinygradLeasePools(capacity_per_key=1, max_pools=2, synchronize_on_release=True)
arrays = [
mx.array(np.arange(4, dtype=np.float32), dtype=mx.float32),
mx.array(np.arange(6, dtype=np.float32).reshape(2, 3), dtype=mx.float32),
mx.array(np.arange(8, dtype=np.float32).reshape(2, 2, 2), dtype=mx.float32),
]
keys = [MlxToTinygradLeaseKey.from_storage(mx.metal._unsafe_export_storage(arr), tg_dtype=dtypes.float32) for arr in arrays]
for arr in arrays:
result = pools.run_with_mlx_tensor(arr, tg_dtype=dtypes.float32, fn=lambda t: (t + 1).sum())
self.assertIsInstance(result, Tensor)
self.assertEqual(pools.pool_count, 2)
self.assertIsNone(pools.get_pool(keys[0]))
self.assertIsNotNone(pools.get_pool(keys[1]))
self.assertIsNotNone(pools.get_pool(keys[2]))
def test_copy_pool_scoped_handoff_realizes_returned_tensor(self):
first = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
second = mx.array(np.arange(16, dtype=np.float32) + np.float32(3), dtype=mx.float32)
pools = MlxToTinygradCopyLeasePools(capacity_per_key=1, synchronize_on_release=True)
result_first = pools.run_with_mlx_tensor(first, tg_dtype=dtypes.float32, fn=lambda t: (t + 1).sum())
result_second = pools.run_with_mlx_tensor(second, tg_dtype=dtypes.float32, fn=lambda t: (t + 1).sum())
np.testing.assert_allclose(result_first.numpy(), np.array((np.arange(16, dtype=np.float32) + 1).sum(), dtype=np.float32))
np.testing.assert_allclose(result_second.numpy(), np.array((np.arange(16, dtype=np.float32) + 4).sum(), dtype=np.float32))
def test_copy_pool_registry_eviction_is_bounded(self):
pools = MlxToTinygradCopyLeasePools(capacity_per_key=1, max_pools=2, synchronize_on_release=True)
arrays = [
mx.array(np.arange(4, dtype=np.float32), dtype=mx.float32),
mx.array(np.arange(6, dtype=np.float32).reshape(2, 3), dtype=mx.float32),
mx.array(np.arange(8, dtype=np.float32).reshape(2, 2, 2), dtype=mx.float32),
]
keys = [MlxToTinygradCopyKey.from_storage(mx.metal._unsafe_export_storage(arr), tg_dtype=dtypes.float32) for arr in arrays]
for arr in arrays:
result = pools.run_with_mlx_tensor(arr, tg_dtype=dtypes.float32, fn=lambda t: (t + 1).sum())
self.assertIsInstance(result, Tensor)
self.assertEqual(pools.pool_count, 2)
self.assertIsNone(pools.get_pool(keys[0]))
self.assertIsNotNone(pools.get_pool(keys[1]))
self.assertIsNotNone(pools.get_pool(keys[2]))
def test_copy_pool_capacity_requires_release(self):
array = mx.array(np.arange(16, dtype=np.float32), dtype=mx.float32)
pool = MlxToTinygradCopyLeasePool.from_mlx(array, tg_dtype=dtypes.float32, capacity=1)
lease = pool.acquire_from_mlx(array)
with self.assertRaisesRegex(RuntimeError, "still in use"):
pool.acquire_from_mlx(array)
lease.release(synchronize=False)
if __name__ == "__main__":
unittest.main()
+184
View File
@@ -0,0 +1,184 @@
import unittest
import mlx.core as mx
import numpy as np
from tinygrad import Tensor, dtypes
from mlx_tinygrad_interop.stress_interop import apply_numpy_ops
class TestStressHarnessNumerics(unittest.TestCase):
def test_numpy_matmul_lastdim_uses_reliable_baseline(self):
lhs = np.arange(2 * 2 * 4 * 31, dtype=np.float32).reshape(2, 2, 4, 31)
lhs = np.transpose(lhs, (3, 0, 2, 1)).reshape(2, 2, 4, 31)
weight = np.arange(31 * 7, dtype=np.float32).reshape(31, 7) / np.float32(17)
actual = apply_numpy_ops(lhs, [("matmul_lastdim", weight)])
expected = np.einsum("ik,kj->ij", lhs.reshape(-1, lhs.shape[-1]), weight, optimize=True)
np.testing.assert_allclose(actual, expected, rtol=1e-5, atol=1e-6)
@unittest.skipUnless(mx.metal.is_available(), "Metal is not available")
class TestMlxTinygradInterop(unittest.TestCase):
def test_mlx_slice_import_uses_backing_buffer_bytes(self):
backing = np.arange(4096, dtype=np.float32)
base = mx.array(backing, dtype=mx.float32)
view = base[16:1808]
mx.eval(view)
storage = mx.metal._unsafe_export_storage(view)
self.assertNotIn("nbytes", storage)
self.assertEqual(int(storage["offset_bytes"]), 64)
self.assertEqual(int(storage["logical_nbytes"]), 7168)
self.assertEqual(int(storage["buffer_nbytes"]), 16384)
tensor = Tensor._unsafe_from_metal_buffer_fast(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=view,
)
np.testing.assert_array_equal(tensor.numpy(), backing[16:1808])
def test_single_entry_mlx_to_tinygrad_fast(self):
values = np.arange(128, dtype=np.float32)
array = mx.array(values, dtype=mx.float32)
tensor = mx.metal._unsafe_to_tinygrad_fast(array, dtypes.float32, owner=array)
np.testing.assert_array_equal(tensor.numpy(), values)
def test_fast_import_handles_zero_offset_oversized_backing_buffer(self):
backing = np.arange(2048, dtype=np.float32)
view = mx.array(backing, dtype=mx.float32)[:1792].reshape(7, 256)
mx.eval(view)
storage = mx.metal._unsafe_export_storage(view)
self.assertEqual(int(storage["offset_bytes"]), 0)
self.assertGreater(int(storage["buffer_nbytes"]), int(storage["logical_nbytes"]))
tensor = Tensor._unsafe_from_metal_buffer_fast(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=view,
)
np.testing.assert_array_equal(tensor.numpy(), backing[:1792].reshape(7, 256))
def test_rebindable_slot_mutates_previous_reference(self):
first = np.arange(64, dtype=np.float32)
second = first + np.float32(1)
array_a = mx.array(first, dtype=mx.float32)
array_b = mx.array(second, dtype=mx.float32)
storage_a = mx.metal._unsafe_export_storage(array_a)
borrower = Tensor._unsafe_metal_borrower(
int(storage_a["mtl_buffer_ptr"]),
tuple(storage_a["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage_a["offset_bytes"]),
buffer_nbytes=int(storage_a["buffer_nbytes"]),
owner=array_a,
)
tensor_a = mx.metal._unsafe_rebind_tinygrad(array_a, borrower, owner=array_a)
tensor_b = mx.metal._unsafe_rebind_tinygrad(array_b, borrower, owner=array_b)
self.assertIs(tensor_a, tensor_b)
np.testing.assert_array_equal(tensor_a.numpy(), second)
np.testing.assert_array_equal(tensor_b.numpy(), second)
def test_rebindable_slot_updates_external_ptr_metadata(self):
first = np.arange(32, dtype=np.float32)
second = first + np.float32(2)
array_a = mx.array(first, dtype=mx.float32)
array_b = mx.array(second, dtype=mx.float32)
storage_a = mx.metal._unsafe_export_storage(array_a)
storage_b = mx.metal._unsafe_export_storage(array_b)
borrower = Tensor._unsafe_metal_borrower(
int(storage_a["mtl_buffer_ptr"]),
tuple(storage_a["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage_a["offset_bytes"]),
buffer_nbytes=int(storage_a["buffer_nbytes"]),
owner=array_a,
)
mx.metal._unsafe_rebind_tinygrad(array_b, borrower, owner=array_b)
self.assertIsNotNone(borrower._base_buf.options)
self.assertEqual(borrower._base_buf.options.external_ptr, int(storage_b["mtl_buffer_ptr"]))
def test_two_slots_hold_two_distinct_snapshots_until_reused(self):
first = np.arange(16, dtype=np.float32)
second = first + np.float32(1)
third = first + np.float32(2)
array_a = mx.array(first, dtype=mx.float32)
array_b = mx.array(second, dtype=mx.float32)
array_c = mx.array(third, dtype=mx.float32)
storage_a = mx.metal._unsafe_export_storage(array_a)
storage_b = mx.metal._unsafe_export_storage(array_b)
slot_a = Tensor._unsafe_metal_borrower(
int(storage_a["mtl_buffer_ptr"]),
tuple(storage_a["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage_a["offset_bytes"]),
buffer_nbytes=int(storage_a["buffer_nbytes"]),
owner=array_a,
)
slot_b = Tensor._unsafe_metal_borrower(
int(storage_b["mtl_buffer_ptr"]),
tuple(storage_b["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage_b["offset_bytes"]),
buffer_nbytes=int(storage_b["buffer_nbytes"]),
owner=array_b,
)
tensor_a = mx.metal._unsafe_rebind_tinygrad(array_a, slot_a, owner=array_a)
tensor_b = mx.metal._unsafe_rebind_tinygrad(array_b, slot_b, owner=array_b)
mx.metal._unsafe_rebind_tinygrad(array_c, slot_a, owner=array_c)
self.assertIsNot(tensor_a, tensor_b)
np.testing.assert_array_equal(tensor_a.numpy(), third)
np.testing.assert_array_equal(tensor_b.numpy(), second)
def test_rebindable_slot_rejects_shape_mismatch(self):
base = np.arange(32, dtype=np.float32)
array_ok = mx.array(base, dtype=mx.float32)
array_bad = mx.array(base.reshape(8, 4), dtype=mx.float32)
storage = mx.metal._unsafe_export_storage(array_ok)
borrower = Tensor._unsafe_metal_borrower(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=array_ok,
)
with self.assertRaisesRegex(ValueError, "shape="):
mx.metal._unsafe_rebind_tinygrad(array_bad, borrower, owner=array_bad)
def test_rebindable_slot_rejects_dtype_mismatch(self):
base = np.arange(32, dtype=np.float32)
array_ok = mx.array(base, dtype=mx.float32)
array_bad = mx.array(base.view(np.int32), dtype=mx.int32)
storage = mx.metal._unsafe_export_storage(array_ok)
borrower = Tensor._unsafe_metal_borrower(
int(storage["mtl_buffer_ptr"]),
tuple(storage["shape"]),
dtype=dtypes.float32,
byte_offset=int(storage["offset_bytes"]),
buffer_nbytes=int(storage["buffer_nbytes"]),
owner=array_ok,
)
with self.assertRaisesRegex(ValueError, "dtype="):
mx.metal._unsafe_rebind_tinygrad(array_bad, borrower, owner=array_bad)
if __name__ == "__main__":
unittest.main()
-26
View File
@@ -1,26 +0,0 @@
diff --git a/setup.py b/setup.py
index 6dc2ed028..bdcc6354a 100644
--- a/setup.py
+++ b/setup.py
@@ -18,6 +18,13 @@ from setuptools import Extension, setup
from setuptools.command.build_ext import build_ext
+if "NIX_ATTRS_JSON_FILE" in os.environ:
+ with open(os.environ["NIX_ATTRS_JSON_FILE"], "r") as f:
+ NIX_ATTRS = json.load(f)
+else:
+ NIX_ATTRS = { "cmakeFlags": os.environ.get("cmakeFlags", "").split() }
+
+
def load_module_from_path(module_name, path):
spec = importlib.util.spec_from_file_location(module_name, path)
module = importlib.util.module_from_spec(spec)
@@ -184,6 +191,7 @@ class cmake_build_ext(build_ext):
cmake_args = [
"-DCMAKE_BUILD_TYPE={}".format(cfg),
"-DVLLM_TARGET_DEVICE={}".format(VLLM_TARGET_DEVICE),
+ *NIX_ATTRS["cmakeFlags"],
]
verbose = envs.VERBOSE
-6
View File
@@ -1,6 +0,0 @@
{
"name": "exo",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+40 -60
View File
@@ -15,18 +15,23 @@ 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",
"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",
"tomlkit>=0.14.0",
"mflux==0.17.2; sys_platform == 'darwin'",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
"transformers>=5.6.2",
"nvidia-ml-py>=13.595.45",
"mlx-vlm>=0.3.11",
"transformers>=5.0.0,<5.4.0",
"tinygrad", # TODO: maybe add a version or something idk lol
"torch>=2.10.0",
]
[project.scripts]
@@ -45,30 +50,23 @@ dev = [
[project.optional-dependencies]
build = ["nanobind"]
mlx-none = ["anyio"]
mlx = [
"mlx==0.31.2",
"mlx-lm",
"mlx-vlm>=0.3.11",
"mflux==0.17.5",
# pinning vllms versions for consistency.
"torch==2.10.0; sys_platform == 'darwin'",
"torch==2.10.0; sys_platform == 'linux'",
"torchaudio==2.10.0; sys_platform == 'darwin'",
"torchaudio==2.10.0; sys_platform == 'linux'",
"torchvision==0.25.0; sys_platform == 'darwin'",
"torchvision==0.25.0; sys_platform == 'linux'",
cpu = [
"mlx==0.31.1; sys_platform == 'linux'",
"mlx-cpu==0.31.1; sys_platform == 'linux'",
"mlx-lm; sys_platform == 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
mlx-cpu = ["exo[mlx]", "mlx-cpu==0.31.2; sys_platform == 'linux'"]
mlx-cuda12 = ["exo[mlx]", "mlx-cuda-12==0.31.1; sys_platform == 'linux'"]
mlx-cuda13 = ["exo[mlx]", "mlx-cuda-13==0.31.1; sys_platform == 'linux'"]
vllm-none = ["anyio"]
vllm-cuda13 = [
"vllm[cuda13, fastsafetensors]; sys_platform == 'linux'",
"torch==2.10.0; sys_platform == 'linux'",
"torchaudio==2.10.0; sys_platform == 'linux'",
"torchvision==0.25.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'",
"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'",
"torch>=2.10.0; sys_platform == 'linux'",
]
###
@@ -80,25 +78,15 @@ members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources]
exo-pyo3-bindings = { workspace = true }
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/deepseek-v4" }
mflux = { git = "http://github.com/evanev7/mflux", branch = "exo" }
vllm = { git = "http://github.com/evanev7/vllm", branch = "exo2" }
mlx = { git = "https://github.com/AndreiCravtov/mlx.git", branch = "andrei/mlx-tinygrad-convert", marker = "sys_platform == 'darwin'" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-arrayscache-leak" }
torch = [
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'mlx-cpu' and extra != 'vllm-cuda13' and extra != 'mlx-cuda13' and extra != 'mlx-cuda12'" },
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and extra == 'mlx-cuda12' and extra != 'mlx-cuda13' and extra != 'vllm-cuda13'" },
{ index = "pytorch-cu130", marker = "sys_platform == 'linux' and (extra == 'mlx-cuda13' or extra == 'vllm-cuda13')" },
]
torchvision = [
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'mlx-cpu' and extra != 'vllm-cuda13' and extra != 'mlx-cuda13' and extra != 'mlx-cuda12'" },
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and extra == 'mlx-cuda12' and extra != 'mlx-cuda13' and extra != 'vllm-cuda13'" },
{ index = "pytorch-cu130", marker = "sys_platform == 'linux' and (extra == 'mlx-cuda13' or extra == 'vllm-cuda13')" },
]
torchaudio = [
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'mlx-cpu' and extra != 'vllm-cuda13' and extra != 'mlx-cuda13' and extra != 'mlx-cuda12'" },
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and extra == 'mlx-cuda12' and extra != 'mlx-cuda13' and extra != 'vllm-cuda13'" },
{ index = "pytorch-cu130", marker = "sys_platform == 'linux' and (extra == 'mlx-cuda13' or extra == 'vllm-cuda13')" },
{ 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 = "(extra != 'cuda12' and extra != 'cuda13' and sys_platform == 'linux') or sys_platform == 'darwin'" },
]
vllm = { git = "https://github.com/hmellor/vllm.git", branch = "transformers-v5" }
tinygrad = { git = "https://github.com/AndreiCravtov/tinygrad.git", branch = "andrei/tinygrad-mlx-convert" }
[[tool.uv.index]]
name = "pytorch-cu130"
@@ -106,8 +94,8 @@ url = "https://download.pytorch.org/whl/cu130"
explicit = true
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
name = "pytorch-cu120"
url = "https://download.pytorch.org/whl/cu120"
explicit = true
[[tool.uv.index]]
@@ -168,19 +156,11 @@ root = "src"
required-version = ">=0.8.6"
prerelease = "allow"
environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
override-dependencies = ["opencv-python; python_version < '0'"]
conflicts = [
[
{ extra = "mlx-cuda13" },
{ extra = "mlx-cuda12" },
{ extra = "mlx-cpu" },
{ extra = "mlx-none" },
],
[
{ extra = "vllm-cuda13" },
{ extra = "mlx-cuda12" },
{ extra = "vllm-none" },
],
conflicts = [[{ extra = "cuda12" }, { extra = "cuda13" }, { extra = "cpu" }]]
constraint-dependencies = ["transformers>=5.0.0,<5.4.0"]
override-dependencies = [
"mlx==0.31.1; sys_platform=='linux'",
"mlx; sys_platform=='darwin'",
]
[tool.uv.extra-build-dependencies]
@@ -195,7 +175,7 @@ mlx = [
"ninja",
]
mlx-lm = ["setuptools"]
mflux = ["uv_build"]
tinygrad = ["setuptools"]
xgrammar = [
"nanobind",
"setuptools",
+20 -202
View File
@@ -10,10 +10,8 @@ let
inherit (pkgs.stdenv.hostPlatform) isLinux isDarwin isx86_64;
inherit (pkgs.config) cudaSupport;
inherit (pkgs) cudaPackages;
libmlx_source =
if (builtins.elem "mlx-cuda13" members.exo or [ ]) then "mlx-cuda-13"
else if (builtins.elem "mlx-cuda12" members.exo or [ ]) then "mlx-cuda-12"
else "mlx-cpu";
cuda13Support = cudaSupport && cudaPackages.cudaMajorVersion == "13";
libmlx_source = if cuda13Support then "mlx-cuda-13" else if cudaSupport then "mlx-cuda-12" else "mlx-cpu";
python = pkgs.python313;
cudaLibs = with cudaPackages; [
cuda_cudart
@@ -115,213 +113,37 @@ let
});
} // lib.optionalAttrs isLinux {
mlx = prev.mlx.overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ lib.optionals cudaSupport [ pkgs.autoAddDriverRunpath ];
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/"
'';
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
} // lib.optionalAttrs cudaSupport {
"${libmlx_source}" = prev."${libmlx_source}".overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cufile = prev.nvidia-cufile.overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
buildInputs = old.buildInputs ++ [ pkgs.rdma-core ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusolver = prev.nvidia-cusolver.overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-nvshmem-cu13 = prev.nvidia-nvshmem-cu13.overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
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: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
buildInputs = old.buildInputs ++ cudaLibs;
buildInputs = old.buildInputs ++ [ cudaLibs ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
torch = prev.torch.overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
torchaudio = prev.torchaudio.overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
buildInputs = old.buildInputs ++ [ cudaPackages.cuda_cudart ];
preFixup = "addAutoPatchelfSearchPath '${final.torch}'";
});
torchvision = prev.torchvision.overrideAttrs (old: {
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.autoAddDriverRunpath ];
preFixup = "addAutoPatchelfSearchPath '${final.torch}'";
});
torch-c-dlpack-ext = prev.torch-c-dlpack-ext.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
preFixup = "addAutoPatchelfSearchPath '${final.torch}'";
});
# Currently treating vllm as a cuda dep. it obviously exists as a non cuda dep
vllm = prev.vllm.overrideAttrs (old:
let
cuda_cccl_compat = pkgs.runCommand "cuda-cccl-compat" { } ''
mkdir -p $out/include
ln -s ${cudaPackages.cuda_cccl}/include $out/include/cccl
'';
cudaRoot = pkgs.symlinkJoin {
name = "cuda-merged-exo";
paths = builtins.concatMap (p: [ (lib.getBin p) (lib.getLib p) (lib.getDev p) ]) (cudaLibs ++ [ cudaPackages.cuda_nvcc cuda_cccl_compat ]);
};
cutlass = pkgs.fetchFromGitHub {
name = "cutlass-source";
owner = "NVIDIA";
repo = "cutlass";
tag = "v4.2.1";
hash = "sha256-iP560D5Vwuj6wX1otJhwbvqe/X4mYVeKTpK533Wr5gY=";
};
triton-kernels = pkgs.fetchFromGitHub {
owner = "triton-lang";
repo = "triton";
tag = "v3.6.0";
hash = "sha256-JFSpQn+WsNnh7CAPlcpOcUp0nyKXNbJEANdXqmkt4Tc=";
};
cutlass-flashmla = pkgs.fetchFromGitHub {
owner = "NVIDIA";
repo = "cutlass";
rev = "147f5673d0c1c3dcf66f78d677fd647e4a020219";
hash = "sha256-dHQto08IwTDOIuFUp9jwm1MWkFi8v2YJ/UESrLuG71g=";
};
flashmla = pkgs.stdenv.mkDerivation {
pname = "flashmla";
version = "1.0.0";
src = pkgs.fetchFromGitHub {
name = "FlashMLA-source";
owner = "vllm-project";
repo = "FlashMLA";
rev = "c2afa9cb93e674d5a9120a170a6da57b89267208";
hash = "sha256-pKlwxV6G9iHag/jbu3bAyvYvnu5TbrQwUMFV0AlGC3s=";
};
dontConfigure = true;
buildPhase = ''
rm -rf csrc/cutlass
ln -sf ${cutlass-flashmla} csrc/cutlass
'';
installPhase = ''
cp -rva . $out
'';
};
qutlass = pkgs.fetchFromGitHub {
name = "qutlass-source";
owner = "IST-DASLab";
repo = "qutlass";
rev = "830d2c4537c7396e14a02a46fbddd18b5d107c65";
hash = "sha256-aG4qd0vlwP+8gudfvHwhtXCFmBOJKQQTvcwahpEqC84=";
};
vllm-flash-attn = pkgs.stdenv.mkDerivation {
pname = "vllm-flash-attn";
version = "2.7.2.post1";
src = pkgs.fetchFromGitHub {
name = "flash-attention-source";
owner = "vllm-project";
repo = "flash-attention";
rev = "188be16520ceefdc625fdf71365585d2ee348fe2";
hash = "sha256-Osec+/IF3+UDtbIhDMBXzUeWJ7hDJNb5FpaVaziPSgM=";
};
patches = [
(pkgs.fetchpatch {
url = "https://github.com/Dao-AILab/flash-attention/commit/dad67c88d4b6122c69d0bed1cebded0cded71cea.patch";
hash = "sha256-JSgXWItOp5KRpFbTQj/cZk+Tqez+4mEz5kmH5EUeQN4=";
})
(pkgs.fetchpatch {
url = "https://github.com/Dao-AILab/flash-attention/commit/e26dd28e487117ee3e6bc4908682f41f31e6f83a.patch";
hash = "sha256-NkCEowXSi+tiWu74Qt+VPKKavx0H9JeteovSJKToK9A=";
})
];
dontConfigure = true;
buildPhase = ''
rm -rf csrc/cutlass
ln -sf ${cutlass} csrc/cutlass
'';
installPhase = ''
cp -rva . $out
'';
};
in
{
patches = (old.patches or [ ]) ++ [ ../nix/vllm-setuppy-cmake.patch ];
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
pkgs.cmake
pkgs.ninja
pkgs.autoAddDriverRunpath
] ++ lib.optionals cudaSupport [
cudaPackages.cuda_nvcc
];
# TODO: vllm rocm/cpu
VLLM_TARGET_DEVICE = "empty";
preConfigure = ''
export MAX_JOBS="$NIX_BUILD_CORES"
'';
# TODO: vllm non cuda13 support, more arch's, etc.
} // lib.optionalAttrs cudaSupport {
buildInputs = cudaLibs ++ [ cudaRoot ];
VLLM_CUDA_VERSION = cudaPackages.cudaMajorMinorVersion;
CUDA_HOME = "${cudaRoot}";
CUDAToolkit_ROOT = "${cudaRoot}";
CUDACXX = "${cudaRoot}/bin/nvcc";
VLLM_CUTLASS_SRC_DIR = "${lib.getDev cutlass}";
VLLM_TARGET_DEVICE = "cuda";
TORCH_CUDA_ARCH_LIST = "12.0;12.1";
TRITON_KERNELS_SRC_DIR = "${lib.getDev triton-kernels}/python/triton_kernels/triton_kernels";
FLASH_MLA_SRC_DIR = "${lib.getDev flashmla}";
QUTLASS_SRC_DIR = "${lib.getDev qutlass}";
VLLM_FLASH_ATTN_SRC_DIR = "${lib.getDev vllm-flash-attn}";
CAFFE2_USE_CUDNN = "ON";
CAFFE2_USE_CUFILE = "ON";
CUTLASS_ENABLE_CUBLAS = "ON";
CUTLASS_NVCC_ARCHS_ENABLED = "12.0;12.1";
cmakeFlags = [
(lib.cmakeBool "CMAKE_SKIP_INSTALL_RPATH" true)
(lib.cmakeBool "CMAKE_BUILD_WITH_INSTALL_RPATH" true)
(lib.cmakeFeature "CUDA_HOME" "${cudaRoot}")
(lib.cmakeFeature "CUDAToolkit_ROOT" "${cudaRoot}")
(lib.cmakeFeature "CMAKE_CUDA_COMPILER" "${cudaRoot}/bin/nvcc")
(lib.cmakeFeature "CMAKE_PREFIX_PATH" "${cudaRoot}")
(lib.cmakeFeature "FETCHCONTENT_SOURCE_DIR_CUTLASS" "${lib.getDev cutlass}")
(lib.cmakeFeature "FLASH_MLA_SRC_DIR" "${lib.getDev flashmla}")
(lib.cmakeFeature "VLLM_FLASH_ATTN_SRC_DIR" "${lib.getDev vllm-flash-attn}")
(lib.cmakeFeature "QUTLASS_SRC_DIR" "${lib.getDev qutlass}")
(lib.cmakeFeature "TORCH_CUDA_ARCH_LIST" "12.0;12.1")
(lib.cmakeFeature "CUTLASS_NVCC_ARCHS_ENABLED" "${cudaPackages.flags.cmakeCudaArchitecturesString}")
(lib.cmakeFeature "CUDA_TOOLKIT_ROOT_DIR" "${cudaRoot}")
(lib.cmakeFeature "CAFFE2_USE_CUDNN" "ON")
(lib.cmakeFeature "CAFFE2_USE_CUFILE" "ON")
(lib.cmakeFeature "CUTLASS_ENABLE_CUBLAS" "ON")
];
});
} // lib.optionalAttrs (cudaSupport && isx86_64) {
numba = prev.numba.overrideAttrs (old: {
buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.tbb ];
});
};
pyprojectOverlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
@@ -342,28 +164,24 @@ let
buildSystemsOverlay
]
);
# mlx and mlx-cuda ship clashing cmake files - we dont need them at runtime anyway
venv = name: (pythonSet.mkVirtualEnv "${name}-venv" members).overrideAttrs (_: { venvSkip = [ "lib/python${python.pythonVersion}/site-packages/mlx/share/cmake/*" "lib/python${python.pythonVersion}/site-packages/build_backend.py" ]; });
mkApp = text: name: 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;
text = "exec " + lib.optionalString cudaSupport "nixglhost " + text;
runtimeEnv = {
EXO_DASHBOARD_DIR = self'.packages.dashboard;
EXO_RESOURCES_DIR = inputs.self + /resources;
};
runtimeInputs = [
# mlx and mlx-cuda ship clashing cmake files - we dont need them at runtime anyway
(venv name)
pkgs.nix-gl-host
]
++ lib.optionals isDarwin [ pkgs.macmon ];
passthru = {
venv = venv name;
evenv = ((pythonSet.overrideScope editableOverlay).mkVirtualEnv "${name}-evenv" (members // { exo = (members.exo or [ ]) ++ [ "dev" ]; })).overrideAttrs (_: { venvSkip = [ "lib/python${python.pythonVersion}/site-packages/mlx/share/cmake/*" "lib/python${python.pythonVersion}/site-packages/build_backend.py" ]; });
};
text = "exec " + lib.optionalString cudaSupport "${lib.getExe pkgs.nix-gl-host} " + cmd;
};
in
{
inherit venv;
editablePythonSet = pythonSet.overrideScope editableOverlay;
mkPythonScript = path: mkApp ''python ${path} "$@"'';
mkExo = mkApp ''exo "$@"'';
};
@@ -373,18 +191,18 @@ in
{ self', pkgs, unfreePkgs, lib, ... }:
let
inherit (pkgs.stdenv.hostPlatform) isLinux;
inherit (mkPythonSet { inherit self' pkgs lib; members = { exo = [ "mlx-cpu" "vllm-none" ]; }; }) mkExo;
inherit (mkPythonSet { inherit self' pkgs lib; members = { exo = [ "cpu" ]; }; }) editablePythonSet mkExo;
# Virtual environment with dev dependencies for testing
testVenv = (mkPythonSet {
inherit self' pkgs lib; members = {
exo = [ "dev" "mlx-cpu" "vllm-none" ]; # Include pytest, pytest-asyncio, pytest-env
exo = [ "dev" "cpu" ]; # Include pytest, pytest-asyncio, pytest-env
};
}).venv "exo-test";
mkBenchScript = (mkPythonSet {
inherit self' pkgs lib; members = {
exo = [ "mlx-cpu" "vllm-none" ];
exo = [ "cpu" ];
exo-bench = [ ]; # Include pytest, pytest-asyncio, pytest-env
};
}).mkPythonScript;
@@ -394,12 +212,12 @@ in
runtimeInputs = [ pkgs.python313 ];
text = ''exec python ${path} "$@"'';
};
cuda12Set = mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_12) pkgs; members = { exo = [ "mlx-cuda12" "vllm-none" ]; }; };
cuda13Set = mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_13) pkgs; members = { exo = [ "mlx-cpu" "vllm-cuda13" ]; }; };
in
{
packages = {
exo = mkExo "exo";
editableVenv = editablePythonSet.mkVirtualEnv "exo-dev-env" { exo = [ "dev" ]; };
# for running tests in ci
exo-test-env = testVenv;
exo-bench = mkBenchScript "exo-bench" (inputs.self + /bench/exo_bench.py);
@@ -408,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 = cuda12Set.mkExo "exo-cuda-12";
exo-cuda-13 = cuda13Set.mkExo "exo-cuda-13";
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 = {
@@ -1,21 +0,0 @@
model_id = "2imi9/gpt-oss-20B-NVFP4A16-BF16"
n_layers = 24
hidden_size = 2880
num_key_value_heads = 8
supports_tensor = false
tasks = ["TextGeneration"]
family = "gpt-oss"
quantization = "nvfp4"
base_model = "GPT-OSS 20B"
capabilities = ["text", "thinking"]
reasoning_dialect = "channel"
context_length = 131072
requires_vllm = true
[storage_size]
in_bytes = 41829514752
[sampling_defaults]
temperature = 1.0
top_p = 1.0
top_k = 0
@@ -8,7 +8,6 @@ family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
@@ -8,7 +8,6 @@ family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
@@ -8,7 +8,6 @@ family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 131072
@@ -8,7 +8,6 @@ family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 131072
@@ -1,21 +0,0 @@
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
@@ -1,21 +0,0 @@
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]
@@ -1,21 +0,0 @@
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
@@ -1,21 +0,0 @@
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
@@ -1,21 +0,0 @@
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]
@@ -1,33 +0,0 @@
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]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "bf16"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 2B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more