mirror of
https://github.com/exo-explore/exo.git
synced 2026-09-09 12:02:25 -04:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0ec0ef95 | ||
|
|
1848f596bf | ||
|
|
df10f1503b | ||
|
|
cd9dcf5320 | ||
|
|
2d945bc7fb | ||
|
|
f4d3a30eef | ||
|
|
84fdccf4df | ||
|
|
32f3c99e0f | ||
|
|
8d34c355db | ||
|
|
8e9de4a26a | ||
|
|
8923c09145 | ||
|
|
1d34d3c660 | ||
|
|
3562e6184b | ||
|
|
8f75712e86 | ||
|
|
fe03666e32 | ||
|
|
7dfcfbdd48 | ||
|
|
46b8581927 | ||
|
|
e6c4de1e16 | ||
|
|
138b775dcb | ||
|
|
c1cbacc0d2 | ||
|
|
30a822637f | ||
|
|
39a275f1a2 | ||
|
|
c6285c95d4 | ||
|
|
3eafe13c51 | ||
|
|
2f7462f5cb | ||
|
|
6f0885990e | ||
|
|
9ce217e5d4 | ||
|
|
3bd510b079 | ||
|
|
07d8cb0cfd | ||
|
|
9f84d3e264 | ||
|
|
21182f9f8e | ||
|
|
8844483ce5 | ||
|
|
eca0c90e24 | ||
|
|
75a2fc7c6f | ||
|
|
ca2361d103 | ||
|
|
e91bc98906 | ||
|
|
4e1625fe86 | ||
|
|
fd0b58be17 | ||
|
|
6dd31c209c | ||
|
|
601ab485f9 | ||
|
|
b1c1debc27 | ||
|
|
376fd9bd26 | ||
|
|
4ab2b7efda | ||
|
|
9ea9bf0aca | ||
|
|
eca2ca0b7e | ||
|
|
fef281c666 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ...
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
)
|
||||
@@ -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 = "Ryuichi’s MacBook Pro"
|
||||
instance_meta = "ring"
|
||||
sharding = "pipeline"
|
||||
min_nodes = 1
|
||||
max_nodes = 1
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
''}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "exo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
+40
-60
@@ -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
@@ -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]
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+1
-1
@@ -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
Reference in new issue
Block a user