Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 339f3f10a3 | |||
| 5d22805a77 | |||
| 973e4db085 | |||
| 016de1803b | |||
| 60a6ac1125 | |||
| 5422e831ce | |||
| 03ea3cf6cd | |||
| 6fa2cc1265 | |||
| 04197fe27b | |||
| d1490444a1 | |||
| ba472da84f | |||
| f208586092 | |||
| be731d3a85 | |||
| 655185cfe7 | |||
| 1dd9c28842 | |||
| cacd26e63c | |||
| 6a3eb2f37d | |||
| e1df77bc4c | |||
| e78e53df6e | |||
| c70d9006e8 | |||
| 72cd8552ae | |||
| 8cd1308336 | |||
| 04dcdbd127 | |||
| ec5d62f935 | |||
| e96f084051 | |||
| dc68ddbac0 | |||
| 073f8c1690 | |||
| 3c29d0dd4c | |||
| 594ed99734 | |||
| e9e23e556e | |||
| 169ea2a5e8 | |||
| 4a7901c548 | |||
| 7bb5cb4fc7 | |||
| 493e342f83 | |||
| 283b1809c9 | |||
| 35030119e3 | |||
| 585dfe3549 | |||
| 5d7a005a13 | |||
| 87b7c5ef8b | |||
| 957ebbd21f | |||
| 4b6dd7588f | |||
| 3f4f7c9ba6 | |||
| 1331465ba0 | |||
| 8f94727f14 | |||
| 9ee23ee0d3 | |||
| f75d36cbe0 | |||
| 2683ac7b61 | |||
| 404b9769ac | |||
| 3e097f7243 | |||
| 0c8615f25c | |||
| ba35a4ba13 | |||
| 9a83fa6cdf | |||
| 659c1bc737 | |||
| 34df811b92 | |||
| ca5870a2e8 | |||
| 6be6ea5fd2 | |||
| 5e9d27b753 | |||
| cfc8f09004 |
@@ -0,0 +1,20 @@
|
||||
from enum import Enum
|
||||
|
||||
class HarmonyEncodingName(Enum):
|
||||
HARMONY_GPT_OSS = ...
|
||||
|
||||
class HarmonyEncoding: ...
|
||||
class HarmonyError(Exception): ...
|
||||
|
||||
class Role(Enum):
|
||||
ASSISTANT = ...
|
||||
|
||||
class StreamableParser:
|
||||
last_content_delta: str
|
||||
current_channel: str | None
|
||||
current_recipient: str | None
|
||||
|
||||
def __init__(self, encoding: HarmonyEncoding, role: Role = ...) -> None: ...
|
||||
def process(self, token_id: int) -> None: ...
|
||||
|
||||
def load_harmony_encoding(name: HarmonyEncodingName) -> HarmonyEncoding: ...
|
||||
@@ -0,0 +1,17 @@
|
||||
class NvmlMemoryInfo:
|
||||
used: int
|
||||
total: int
|
||||
free: int
|
||||
|
||||
class NvmlUtilizationRates:
|
||||
gpu: int
|
||||
memory: int
|
||||
|
||||
def nvmlInit() -> None: ...
|
||||
def nvmlShutdown() -> None: ...
|
||||
def nvmlDeviceGetCount() -> int: ...
|
||||
def nvmlDeviceGetHandleByIndex(index: int) -> object: ...
|
||||
def nvmlDeviceGetUtilizationRates(handle: object) -> NvmlUtilizationRates: ...
|
||||
def nvmlDeviceGetTemperature(handle: object, sensor_type: int) -> int: ...
|
||||
def nvmlDeviceGetPowerUsage(handle: object) -> int: ...
|
||||
def nvmlDeviceGetMemoryInfo(handle: object) -> NvmlMemoryInfo: ...
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Any, Sequence
|
||||
|
||||
from torch import backends as backends
|
||||
from torch import cuda as cuda
|
||||
from torch import distributed as distributed
|
||||
|
||||
__version__: str
|
||||
|
||||
class version:
|
||||
cuda: str
|
||||
|
||||
class dtype: ...
|
||||
|
||||
bfloat16: dtype
|
||||
float16: dtype
|
||||
float32: dtype
|
||||
int8: dtype
|
||||
int32: dtype
|
||||
int64: dtype
|
||||
long: dtype
|
||||
float8_e4m3fn: dtype
|
||||
|
||||
class Tensor:
|
||||
shape: Sequence[int]
|
||||
dtype: dtype
|
||||
def __getitem__(self, key: Any) -> Tensor: ...
|
||||
def __setitem__(self, key: Any, value: Any) -> None: ...
|
||||
def to(self, *args: Any, **kwargs: Any) -> Tensor: ...
|
||||
def cpu(self) -> Tensor: ...
|
||||
def detach(self) -> Tensor: ...
|
||||
def clone(self) -> Tensor: ...
|
||||
def flatten(self, start_dim: int = 0, end_dim: int = -1) -> Tensor: ...
|
||||
def view(self, *shape: Any) -> Tensor: ...
|
||||
def squeeze(self, dim: int = ...) -> Tensor: ...
|
||||
def unsqueeze(self, dim: int) -> Tensor: ...
|
||||
def permute(self, *dims: int) -> Tensor: ...
|
||||
def float(self) -> Tensor: ...
|
||||
def numpy(self) -> Any: ...
|
||||
def numel(self) -> int: ...
|
||||
def nelement(self) -> int: ...
|
||||
@property
|
||||
def is_cuda(self) -> bool: ...
|
||||
@property
|
||||
def device(self) -> device: ...
|
||||
def __len__(self) -> int: ...
|
||||
def data_ptr(self) -> int: ...
|
||||
def tolist(self) -> Any: ...
|
||||
def abs(self) -> Tensor: ...
|
||||
def max(self) -> Tensor: ...
|
||||
def mean(self) -> Tensor: ...
|
||||
def sum(self, dim: int = ...) -> Tensor: ...
|
||||
def item(self) -> float: ...
|
||||
|
||||
def tensor(data: Any, dtype: dtype | None = None, device: Any = None) -> Tensor: ...
|
||||
def zeros(*size: Any, dtype: dtype | None = None, device: Any = None) -> Tensor: ...
|
||||
def empty(*size: Any, dtype: dtype | None = None, device: Any = None) -> Tensor: ...
|
||||
def from_numpy(ndarray: Any) -> Tensor: ...
|
||||
def inference_mode() -> Any: ...
|
||||
|
||||
class device:
|
||||
def __init__(self, type: str, index: int = ...) -> None: ...
|
||||
@@ -0,0 +1 @@
|
||||
from torch.backends import cuda as cuda
|
||||
@@ -0,0 +1 @@
|
||||
def is_built() -> bool: ...
|
||||
@@ -0,0 +1,10 @@
|
||||
class _DeviceProperties:
|
||||
total_memory: int
|
||||
|
||||
def is_available() -> bool: ...
|
||||
def get_device_name(device: int) -> str: ...
|
||||
def get_device_properties(device: int) -> _DeviceProperties: ...
|
||||
def empty_cache() -> None: ...
|
||||
def mem_get_info() -> tuple[int, int]: ...
|
||||
def synchronize() -> None: ...
|
||||
def max_memory_allocated() -> int: ...
|
||||
@@ -0,0 +1,2 @@
|
||||
def is_initialized() -> bool: ...
|
||||
def destroy_process_group() -> None: ...
|
||||
@@ -0,0 +1 @@
|
||||
__version__: str
|
||||
@@ -0,0 +1,2 @@
|
||||
class ModelConfig:
|
||||
max_model_len: int
|
||||
@@ -0,0 +1,18 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class EngineArgs:
|
||||
model: str = ...
|
||||
served_model_name: str | list[str] | None = ...
|
||||
tokenizer: str | None = ...
|
||||
trust_remote_code: bool = ...
|
||||
dtype: str = ...
|
||||
seed: int = ...
|
||||
max_model_len: int | None = ...
|
||||
gpu_memory_utilization: float = ...
|
||||
enforce_eager: bool = ...
|
||||
tensor_parallel_size: int = ...
|
||||
pipeline_parallel_size: int = ...
|
||||
quantization: str | None = ...
|
||||
load_format: str = ...
|
||||
enable_sleep_mode: bool = ...
|
||||
@@ -0,0 +1,17 @@
|
||||
class CompletionOutput:
|
||||
index: int
|
||||
text: str
|
||||
token_ids: list[int]
|
||||
cumulative_logprob: float | None
|
||||
logprobs: object | None
|
||||
finish_reason: str | None
|
||||
stop_reason: int | str | None
|
||||
|
||||
def finished(self) -> bool: ...
|
||||
|
||||
class RequestOutput:
|
||||
request_id: str
|
||||
prompt: str | None
|
||||
prompt_token_ids: list[int] | None
|
||||
outputs: list[CompletionOutput]
|
||||
finished: bool
|
||||
@@ -0,0 +1,11 @@
|
||||
class SamplingParams:
|
||||
n: int
|
||||
temperature: float
|
||||
top_p: float
|
||||
top_k: int
|
||||
min_p: float
|
||||
seed: int | None
|
||||
stop: str | list[str] | None
|
||||
max_tokens: int | None
|
||||
logprobs: int | None
|
||||
repetition_penalty: float
|
||||
@@ -0,0 +1,3 @@
|
||||
from vllm.tokenizers.protocol import TokenizerLike
|
||||
|
||||
__all__ = ["TokenizerLike"]
|
||||
@@ -0,0 +1,15 @@
|
||||
from typing import Protocol
|
||||
|
||||
class TokenizerLike(Protocol):
|
||||
@property
|
||||
def eos_token_id(self) -> int: ...
|
||||
@property
|
||||
def vocab_size(self) -> int: ...
|
||||
def encode(self, text: str, add_special_tokens: bool = ...) -> list[int]: ...
|
||||
def decode(self, ids: list[int] | int, skip_special_tokens: bool = ...) -> str: ...
|
||||
def apply_chat_template(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, object]] | None = ...,
|
||||
**kwargs: object,
|
||||
) -> str | list[int]: ...
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from vllm.v1.core.kv_cache_utils import BlockPool, KVCacheBlock
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig
|
||||
|
||||
class KVCacheBlocks:
|
||||
blocks: tuple[Sequence[KVCacheBlock], ...]
|
||||
def __init__(self, blocks: tuple[Sequence[KVCacheBlock], ...]) -> None: ...
|
||||
def get_block_ids(self) -> tuple[list[int], ...]: ...
|
||||
|
||||
class KVCacheManager:
|
||||
block_pool: BlockPool
|
||||
kv_cache_config: KVCacheConfig
|
||||
enable_caching: bool
|
||||
num_kv_cache_groups: int
|
||||
coordinator: object
|
||||
def __init__(self, *args: object, **kwargs: object) -> None: ...
|
||||
def allocate_slots(
|
||||
self, request: object, num_new_tokens: int, *args: object, **kwargs: object
|
||||
) -> KVCacheBlocks | None: ...
|
||||
def get_computed_blocks(self, request: object) -> tuple[KVCacheBlocks, int]: ...
|
||||
def create_kv_cache_blocks(
|
||||
self, blocks: tuple[list[KVCacheBlock], ...]
|
||||
) -> KVCacheBlocks: ...
|
||||
@@ -0,0 +1,16 @@
|
||||
class KVCacheBlock:
|
||||
block_id: int
|
||||
ref_cnt: int
|
||||
def __init__(self, block_id: int) -> None: ...
|
||||
|
||||
class FreeKVCacheBlockQueue:
|
||||
def append_n(self, blocks: list[KVCacheBlock]) -> None: ...
|
||||
def popleft_n(self, n: int) -> list[KVCacheBlock]: ...
|
||||
|
||||
class BlockPool:
|
||||
blocks: list[KVCacheBlock]
|
||||
free_block_queue: FreeKVCacheBlockQueue
|
||||
num_gpu_blocks: int
|
||||
enable_caching: bool
|
||||
def get_num_free_blocks(self) -> int: ...
|
||||
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]: ...
|
||||
@@ -0,0 +1,22 @@
|
||||
from vllm.config import ModelConfig
|
||||
from vllm.engine.arg_utils import EngineArgs
|
||||
from vllm.outputs import RequestOutput
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
|
||||
class LLMEngine:
|
||||
tokenizer: TokenizerLike | None
|
||||
model_config: ModelConfig
|
||||
|
||||
@classmethod
|
||||
def from_engine_args(cls, engine_args: EngineArgs) -> LLMEngine: ...
|
||||
def add_request(
|
||||
self,
|
||||
request_id: str,
|
||||
prompt: str,
|
||||
params: SamplingParams,
|
||||
arrival_time: float | None = ...,
|
||||
) -> None: ...
|
||||
def step(self) -> list[RequestOutput]: ...
|
||||
def has_unfinished_requests(self) -> bool: ...
|
||||
def get_tokenizer(self) -> TokenizerLike: ...
|
||||
@@ -0,0 +1,23 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class KVCacheSpec:
|
||||
block_size: int
|
||||
num_kv_heads: int
|
||||
head_size: int
|
||||
|
||||
@dataclass
|
||||
class KVCacheGroupSpec:
|
||||
layer_names: list[str]
|
||||
kv_cache_spec: KVCacheSpec
|
||||
|
||||
@dataclass
|
||||
class KVCacheTensorSpec:
|
||||
shared_by: list[str]
|
||||
size: int
|
||||
|
||||
@dataclass
|
||||
class KVCacheConfig:
|
||||
num_blocks: int
|
||||
kv_cache_groups: list[KVCacheGroupSpec]
|
||||
kv_cache_tensors: list[KVCacheTensorSpec]
|
||||
@@ -0,0 +1,6 @@
|
||||
class Request:
|
||||
request_id: str
|
||||
prompt_token_ids: list[int] | None
|
||||
num_prompt_tokens: int
|
||||
num_computed_tokens: int
|
||||
num_tokens: int
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import torch
|
||||
|
||||
class _CompilationConfig:
|
||||
static_forward_context: dict[str, object]
|
||||
|
||||
class _ModelConfig:
|
||||
hf_config: object
|
||||
|
||||
class GPUModelRunner:
|
||||
kv_caches: list[torch.Tensor]
|
||||
compilation_config: _CompilationConfig
|
||||
model_config: _ModelConfig | None
|
||||
def _allocate_kv_cache_tensors(
|
||||
self, kv_cache_config: object
|
||||
) -> dict[str, torch.Tensor]: ...
|
||||
def initialize_kv_cache_tensors(
|
||||
self, kv_cache_config: object, kernel_block_sizes: list[int]
|
||||
) -> dict[str, torch.Tensor]: ...
|
||||
def _reshape_kv_cache_tensors(
|
||||
self,
|
||||
kv_cache_config: object,
|
||||
raw_tensors: dict[str, torch.Tensor],
|
||||
kernel_block_sizes: list[int],
|
||||
) -> dict[str, torch.Tensor]: ...
|
||||
@@ -0,0 +1,6 @@
|
||||
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
|
||||
|
||||
class Worker:
|
||||
model_runner: GPUModelRunner
|
||||
def determine_available_memory(self) -> int: ...
|
||||
def initialize_from_config(self, kv_cache_config: object) -> None: ...
|
||||
@@ -0,0 +1 @@
|
||||
def extract_layer_index(layer_name: str, num_attn_module: int) -> int: ...
|
||||
@@ -2396,7 +2396,7 @@ def degrees(a: array, /, *, stream: Stream | Device | None = ...) -> array:
|
||||
array: The angles in degrees.
|
||||
"""
|
||||
|
||||
def depends[T](inputs: T, dependencies: array | Sequence[array]) -> T:
|
||||
def depends(inputs: array | Sequence[array], dependencies: array | Sequence[array]):
|
||||
"""
|
||||
Insert dependencies between arrays in the graph. The outputs are
|
||||
identical to ``inputs`` but with dependencies on ``dependencies``.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from .layers import *
|
||||
from .utils import *
|
||||
"""
|
||||
This type stub file was generated by pyright.
|
||||
"""
|
||||
|
||||
from layers import *
|
||||
from utils import *
|
||||
|
||||
from . import init as init
|
||||
from . import losses as losses
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
from .activations import *
|
||||
from .base import *
|
||||
from .containers import *
|
||||
from .convolution import *
|
||||
from .convolution_transpose import *
|
||||
from .distributed import *
|
||||
from .dropout import *
|
||||
from .embedding import *
|
||||
from .linear import *
|
||||
from .normalization import *
|
||||
from .pooling import *
|
||||
from .positional_encoding import *
|
||||
from .quantized import *
|
||||
from .recurrent import *
|
||||
from .transformer import *
|
||||
from .upsample import *
|
||||
"""
|
||||
This type stub file was generated by pyright.
|
||||
"""
|
||||
|
||||
from activations import *
|
||||
from base import *
|
||||
from containers import *
|
||||
from convolution import *
|
||||
from convolution_transpose import *
|
||||
from distributed import *
|
||||
from dropout import *
|
||||
from embedding import *
|
||||
from linear import *
|
||||
from normalization import *
|
||||
from pooling import *
|
||||
from positional_encoding import *
|
||||
from quantized import *
|
||||
from recurrent import *
|
||||
from transformer import *
|
||||
from upsample import *
|
||||
|
||||
@@ -53,7 +53,7 @@ class Module(dict):
|
||||
mx.eval(model.parameters())
|
||||
"""
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> mx.array: ...
|
||||
__call__: Callable
|
||||
def __init__(self) -> None:
|
||||
"""Should be called by the subclasses of ``Module``."""
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def str2bool(string): # -> bool:
|
||||
def setup_arg_parser(): # -> ArgumentParser:
|
||||
"""Set up and return the argument parser."""
|
||||
|
||||
generation_stream: mx.Stream
|
||||
generation_stream = ...
|
||||
|
||||
@contextlib.contextmanager
|
||||
def wired_limit(
|
||||
@@ -266,12 +266,12 @@ def _merge_caches(caches: Any) -> List[Any]: ...
|
||||
class Batch:
|
||||
uids: List[int]
|
||||
y: mx.array
|
||||
logprobs: List[mx.array] | mx.array
|
||||
logprobs: mx.array
|
||||
max_tokens: List[int]
|
||||
num_tokens: List[int]
|
||||
cache: List[Any]
|
||||
samplers: List[Callable[[mx.array], mx.array] | None]
|
||||
logits_processors: List[List[Callable[[mx.array, mx.array], mx.array]]]
|
||||
samplers: List[Any]
|
||||
logits_processors: List[Any]
|
||||
tokens: List[mx.array]
|
||||
def __len__(self) -> int: ...
|
||||
def filter(self, keep_idx: List[int]) -> None: ...
|
||||
@@ -279,18 +279,13 @@ class Batch:
|
||||
def extract_cache(self, idx: int) -> List[Any]: ...
|
||||
|
||||
class BatchGenerator:
|
||||
model: nn.Module
|
||||
sampler: Callable[[mx.array], mx.array]
|
||||
stop_tokens: set[int]
|
||||
model: Any
|
||||
max_kv_size: Optional[int]
|
||||
prefill_step_size: int
|
||||
completion_batch_size: int
|
||||
prefill_batch_size: int
|
||||
unprocessed_prompts: List[Any]
|
||||
active_batch: Optional[Batch]
|
||||
prompt_progress_callback: Callable[[List[Tuple[int, int, int]]], None]
|
||||
_stats: BatchStats
|
||||
_next_count: int
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
|
||||
@@ -88,8 +88,8 @@ def create_attention_mask(
|
||||
) -> array | Literal["causal"] | None: ...
|
||||
|
||||
class _BaseCache(Cache):
|
||||
keys: mx.array | None
|
||||
values: mx.array | None
|
||||
keys: mx.array
|
||||
values: mx.array
|
||||
offset: int
|
||||
@property
|
||||
def state(self) -> tuple[mx.array | None, mx.array | None]: ...
|
||||
@@ -268,14 +268,29 @@ class CacheList(_BaseCache):
|
||||
"""
|
||||
|
||||
class BatchKVCache(_BaseCache):
|
||||
step: int
|
||||
keys: array | None
|
||||
values: array | None
|
||||
offset: array
|
||||
left_padding: array
|
||||
_idx: int
|
||||
def __init__(self, left_padding: List[int]) -> None: ...
|
||||
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
step = ...
|
||||
def __init__(self, left_padding: List[int]) -> None:
|
||||
"""
|
||||
The BatchKV cache expects inputs to be left-padded.
|
||||
|
||||
E.g. the following prompts:
|
||||
|
||||
[1, 3, 5]
|
||||
[7]
|
||||
[2, 6, 8, 9]
|
||||
|
||||
Should be padded like so:
|
||||
|
||||
[0, 1, 3, 5]
|
||||
[0, 0, 0, 7]
|
||||
[2, 6, 8, 9]
|
||||
|
||||
And ``left_padding`` specifies the amount of padding for each.
|
||||
In this case, ``left_padding = [1, 3, 0]``.
|
||||
"""
|
||||
|
||||
def update_and_fetch(self, keys, values): # -> tuple[array | Any, array | Any]:
|
||||
...
|
||||
@property
|
||||
def state(
|
||||
self,
|
||||
@@ -301,21 +316,12 @@ class BatchKVCache(_BaseCache):
|
||||
"""
|
||||
|
||||
class BatchRotatingKVCache(_BaseCache):
|
||||
step: int
|
||||
keys: array | None
|
||||
values: array | None
|
||||
offset: array
|
||||
left_padding: array
|
||||
max_size: int
|
||||
_idx: int
|
||||
_offset: int
|
||||
rotated: bool
|
||||
_lengths: array | None
|
||||
def __init__(self, max_size: int, left_padding: List[int]) -> None: ...
|
||||
def _trim(self, trim_size: int, v: array, append: array | None = ...) -> array: ...
|
||||
def _update_in_place(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
def _update_concat(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
step = ...
|
||||
def __init__(self, max_size, left_padding: List[int]) -> None: ...
|
||||
def update_and_fetch(
|
||||
self, keys, values
|
||||
): # -> tuple[array | Any, array | Any] | tuple[array | Any, array | Any | None]:
|
||||
...
|
||||
@property
|
||||
def state(
|
||||
self,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
import mlx.core as mx
|
||||
|
||||
def compute_g(A_log: mx.array, a: mx.array, dt_bias: mx.array) -> mx.array: ...
|
||||
def gated_delta_update(
|
||||
q: mx.array,
|
||||
k: mx.array,
|
||||
v: mx.array,
|
||||
a: mx.array,
|
||||
b: mx.array,
|
||||
A_log: mx.array,
|
||||
dt_bias: mx.array,
|
||||
state: Optional[mx.array] = ...,
|
||||
mask: Optional[mx.array] = ...,
|
||||
use_kernel: bool = ...,
|
||||
) -> tuple[mx.array, mx.array]: ...
|
||||
def gated_delta_ops(
|
||||
q: mx.array,
|
||||
k: mx.array,
|
||||
v: mx.array,
|
||||
g: mx.array,
|
||||
beta: mx.array,
|
||||
state: Optional[mx.array] = ...,
|
||||
mask: Optional[mx.array] = ...,
|
||||
) -> tuple[mx.array, mx.array]: ...
|
||||
def gated_delta_kernel(
|
||||
q: mx.array,
|
||||
k: mx.array,
|
||||
v: mx.array,
|
||||
g: mx.array,
|
||||
beta: mx.array,
|
||||
state: mx.array,
|
||||
mask: Optional[mx.array] = ...,
|
||||
) -> tuple[mx.array, mx.array]: ...
|
||||
@@ -1,51 +0,0 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
import mlx.nn as nn
|
||||
|
||||
class YarnRoPE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dims: int,
|
||||
traditional: bool = ...,
|
||||
max_position_embeddings: int = ...,
|
||||
base: float = ...,
|
||||
scaling_factor: float = ...,
|
||||
original_max_position_embeddings: int = ...,
|
||||
beta_fast: float = ...,
|
||||
beta_slow: float = ...,
|
||||
mscale: float = ...,
|
||||
mscale_all_dim: float = ...,
|
||||
) -> None: ...
|
||||
|
||||
class Llama3RoPE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dims: int,
|
||||
traditional: bool = ...,
|
||||
max_position_embeddings: int = ...,
|
||||
base: float = ...,
|
||||
scaling_factor: float = ...,
|
||||
original_max_position_embeddings: int = ...,
|
||||
low_freq_factor: float = ...,
|
||||
high_freq_factor: float = ...,
|
||||
) -> None: ...
|
||||
|
||||
class SuScaledRoPE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dims: int,
|
||||
traditional: bool = ...,
|
||||
max_position_embeddings: int = ...,
|
||||
base: float = ...,
|
||||
short_factor: Any = ...,
|
||||
long_factor: Any = ...,
|
||||
original_max_position_embeddings: int = ...,
|
||||
) -> None: ...
|
||||
|
||||
def initialize_rope(
|
||||
dims: int,
|
||||
base: float = ...,
|
||||
traditional: bool = ...,
|
||||
scaling_config: Optional[dict[str, Any]] = ...,
|
||||
max_position_embeddings: Optional[int] = ...,
|
||||
) -> nn.Module: ...
|
||||
+1
-10
@@ -11,18 +11,9 @@ To run EXO from source:
|
||||
```bash
|
||||
brew install uv
|
||||
```
|
||||
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
|
||||
Use the pinned fork revision used by this repo instead of Homebrew `macmon`.
|
||||
```bash
|
||||
cargo install --git https://github.com/swiftraccoon/macmon \
|
||||
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
|
||||
macmon \
|
||||
--force
|
||||
brew install macmon
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -95,10 +95,11 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
- [uv](https://github.com/astral-sh/uv) (for Python dependency management)
|
||||
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
|
||||
- [node](https://github.com/nodejs/node) (for building the dashboard)
|
||||
|
||||
```bash
|
||||
brew install uv node
|
||||
brew install uv macmon node
|
||||
```
|
||||
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
|
||||
|
||||
@@ -106,17 +107,6 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
|
||||
|
||||
Install the pinned fork revision used by this repo instead of Homebrew `macmon`.
|
||||
Homebrew `macmon 0.6.1` still crashes on Apple M5.
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/swiftraccoon/macmon \
|
||||
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
|
||||
macmon \
|
||||
--force
|
||||
```
|
||||
|
||||
Clone the repo, build the dashboard, and run exo:
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import Foundation
|
||||
|
||||
private let customNamespaceKey = "EXOCustomNamespace"
|
||||
private let hfTokenKey = "EXOHFToken"
|
||||
private let hfEndpointKey = "EXOHFEndpoint"
|
||||
private let enableImageModelsKey = "EXOEnableImageModels"
|
||||
private let offlineModeKey = "EXOOfflineMode"
|
||||
private let onboardingCompletedKey = "EXOOnboardingCompleted"
|
||||
@@ -54,14 +53,6 @@ final class ExoProcessController: ObservableObject {
|
||||
UserDefaults.standard.set(hfToken, forKey: hfTokenKey)
|
||||
}
|
||||
}
|
||||
@Published var hfEndpoint: String = {
|
||||
return UserDefaults.standard.string(forKey: hfEndpointKey) ?? ""
|
||||
}()
|
||||
{
|
||||
didSet {
|
||||
UserDefaults.standard.set(hfEndpoint, forKey: hfEndpointKey)
|
||||
}
|
||||
}
|
||||
@Published var enableImageModels: Bool = {
|
||||
return UserDefaults.standard.bool(forKey: enableImageModelsKey)
|
||||
}()
|
||||
@@ -282,9 +273,6 @@ final class ExoProcessController: ObservableObject {
|
||||
if !hfToken.isEmpty {
|
||||
environment["HF_TOKEN"] = hfToken
|
||||
}
|
||||
if !hfEndpoint.isEmpty {
|
||||
environment["HF_ENDPOINT"] = hfEndpoint
|
||||
}
|
||||
if enableImageModels {
|
||||
environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ struct SettingsView: View {
|
||||
|
||||
@State private var pendingNamespace: String = ""
|
||||
@State private var pendingHFToken: String = ""
|
||||
@State private var pendingHFEndpoint: String = ""
|
||||
@State private var pendingEnableImageModels = false
|
||||
@State private var pendingOfflineMode = false
|
||||
@State private var needsRestart = false
|
||||
@@ -43,7 +42,6 @@ struct SettingsView: View {
|
||||
.onAppear {
|
||||
pendingNamespace = controller.customNamespace
|
||||
pendingHFToken = controller.hfToken
|
||||
pendingHFEndpoint = controller.hfEndpoint
|
||||
pendingEnableImageModels = controller.enableImageModels
|
||||
pendingOfflineMode = controller.offlineMode
|
||||
needsRestart = false
|
||||
@@ -76,17 +74,6 @@ struct SettingsView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("HuggingFace Endpoint") {
|
||||
TextField("default", text: $pendingHFEndpoint)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 200)
|
||||
}
|
||||
Text("Defaults to huggingface.co. Use a mirror (e.g. hf-mirror.com) for China.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Offline Mode", isOn: $pendingOfflineMode)
|
||||
Text("Skip internet checks and use only locally available models.")
|
||||
@@ -467,7 +454,6 @@ struct SettingsView: View {
|
||||
|
||||
private var hasGeneralChanges: Bool {
|
||||
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
|
||||
|| pendingHFEndpoint != controller.hfEndpoint
|
||||
|| pendingOfflineMode != controller.offlineMode
|
||||
}
|
||||
|
||||
@@ -478,7 +464,6 @@ struct SettingsView: View {
|
||||
private func applyGeneralSettings() {
|
||||
controller.customNamespace = pendingNamespace
|
||||
controller.hfToken = pendingHFToken
|
||||
controller.hfEndpoint = pendingHFEndpoint
|
||||
controller.offlineMode = pendingOfflineMode
|
||||
restartIfRunning()
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
add_common_instance_args,
|
||||
ensure_cuda_available,
|
||||
instance_id_from_instance,
|
||||
nodes_used_in_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
settle_and_fetch_placements,
|
||||
validate_vllm_args,
|
||||
wait_for_instance_gone,
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
@@ -933,6 +935,7 @@ Examples:
|
||||
help="Write JSON results to stdout instead of file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
validate_vllm_args(args)
|
||||
|
||||
all_scenarios = load_scenarios(SCENARIOS_PATH)
|
||||
if args.scenarios:
|
||||
@@ -952,6 +955,8 @@ Examples:
|
||||
|
||||
log = sys.stderr if args.stdout else sys.stdout
|
||||
exo = ExoClient(args.host, args.port, timeout_s=args.timeout)
|
||||
if args.ensure_cuda:
|
||||
ensure_cuda_available(exo)
|
||||
_short_id, full_model_id = resolve_model_short_id(exo, args.model)
|
||||
|
||||
selected = settle_and_fetch_placements(
|
||||
|
||||
+12
-5
@@ -33,11 +33,13 @@ from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
add_common_instance_args,
|
||||
ensure_cuda_available,
|
||||
instance_id_from_instance,
|
||||
nodes_used_in_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
settle_and_fetch_placements,
|
||||
validate_vllm_args,
|
||||
wait_for_instance_gone,
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
@@ -280,6 +282,7 @@ def main() -> int:
|
||||
help="Force all pp×tg combinations (cartesian product) even when lists have equal length.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
validate_vllm_args(args)
|
||||
|
||||
pp_list = parse_int_list(args.pp)
|
||||
tg_list = parse_int_list(args.tg)
|
||||
@@ -304,6 +307,8 @@ def main() -> int:
|
||||
logger.info(f"pp/tg mode: tandem (zip) - {len(pp_list)} pairs")
|
||||
|
||||
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
|
||||
if args.ensure_cuda:
|
||||
ensure_cuda_available(client)
|
||||
short_id, full_model_id = resolve_model_short_id(
|
||||
client, args.model, force_download=args.force_download
|
||||
)
|
||||
@@ -501,21 +506,23 @@ def main() -> int:
|
||||
for x, _ in batch_results
|
||||
if x["stats"]["generation_tps"] > 0
|
||||
]
|
||||
per_req_tps = (
|
||||
agg_gen_tps = (
|
||||
mean(valid_gen_tps) if valid_gen_tps else 0.0
|
||||
)
|
||||
agg_gen_tps = per_req_tps * concurrency
|
||||
gen_tps = agg_gen_tps / concurrency
|
||||
logger.info(
|
||||
f"[concurrent {concurrency}x] "
|
||||
f"agg_gen_tps={agg_gen_tps:.2f} "
|
||||
f"per_req_tps={per_req_tps:.2f} "
|
||||
f"gen_tps={gen_tps:.2f} "
|
||||
f"errors={batch_errors}"
|
||||
)
|
||||
|
||||
if runs:
|
||||
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
|
||||
per_req_tps = mean(x["stats"]["generation_tps"] for x in runs)
|
||||
gen_tps = per_req_tps * concurrency
|
||||
gen_tps = mean(
|
||||
x["stats"]["generation_tps"] / x["concurrency"]
|
||||
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(
|
||||
|
||||
@@ -46,11 +46,13 @@ from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
add_common_instance_args,
|
||||
ensure_cuda_available,
|
||||
instance_id_from_instance,
|
||||
nodes_used_in_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
settle_and_fetch_placements,
|
||||
validate_vllm_args,
|
||||
wait_for_instance_gone,
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
@@ -1157,6 +1159,7 @@ def main() -> int:
|
||||
)
|
||||
|
||||
args, _ = ap.parse_known_args()
|
||||
validate_vllm_args(args)
|
||||
|
||||
# Resolve tasks
|
||||
if args.tasks:
|
||||
@@ -1173,6 +1176,8 @@ def main() -> int:
|
||||
|
||||
# Instance management
|
||||
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
|
||||
if args.ensure_cuda:
|
||||
ensure_cuda_available(client)
|
||||
instance_id: str | None = None
|
||||
|
||||
if not args.skip_instance_setup:
|
||||
|
||||
+35
-1
@@ -69,6 +69,10 @@ 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 post_bench_disaggregated(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
payload["disaggregated"] = True
|
||||
return self.request_json("POST", "/bench/chat/completions", body=payload)
|
||||
|
||||
|
||||
def unwrap_instance(instance: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(instance) != 1:
|
||||
@@ -196,6 +200,31 @@ def resolve_model_short_id(
|
||||
raise ValueError(f"Model not found in /models: {model_arg}")
|
||||
|
||||
|
||||
def validate_vllm_args(args: argparse.Namespace) -> None:
|
||||
if args.instance_meta != "vllm":
|
||||
return
|
||||
if args.sharding == "tensor":
|
||||
raise SystemExit(
|
||||
"--instance-meta vllm is incompatible with --sharding tensor (vllm is pipeline-only)"
|
||||
)
|
||||
if args.min_nodes > 1:
|
||||
raise SystemExit(
|
||||
"--instance-meta vllm is incompatible with --min-nodes > 1 (vllm is single-node)"
|
||||
)
|
||||
if args.max_nodes > 1:
|
||||
raise SystemExit(
|
||||
"--instance-meta vllm is incompatible with --max-nodes > 1 (vllm is single-node)"
|
||||
)
|
||||
|
||||
|
||||
def ensure_cuda_available(client: ExoClient) -> None:
|
||||
capabilities = client.request_json("GET", "/capabilities")
|
||||
if not capabilities or not capabilities.get("vllm_available"):
|
||||
raise SystemExit(
|
||||
"--ensure-cuda: vllm is not available on the exo cluster (no CUDA capability)"
|
||||
)
|
||||
|
||||
|
||||
def placement_filter(instance_meta: str, wanted: str) -> bool:
|
||||
s = (instance_meta or "").lower()
|
||||
if wanted == "both":
|
||||
@@ -475,7 +504,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", "both"], default="both"
|
||||
"--instance-meta", choices=["ring", "jaccl", "vllm", "both"], default="both"
|
||||
)
|
||||
ap.add_argument(
|
||||
"--sharding", choices=["pipeline", "tensor", "both"], default="both"
|
||||
@@ -504,3 +533,8 @@ 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(
|
||||
"--ensure-cuda",
|
||||
action="store_true",
|
||||
help="Verify the exo cluster has CUDA/vllm capability; error if not.",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
# type: ignore
|
||||
#!/usr/bin/env python3
|
||||
"""Disaggregated prefill-decode benchmark for exo.
|
||||
|
||||
Measures throughput when a vLLM node handles prefill (KV generation + transfer)
|
||||
and an MLX node handles decode (token generation).
|
||||
|
||||
Requires a cluster with at least one CUDA/vLLM node and one Apple Silicon/MLX node.
|
||||
|
||||
Usage:
|
||||
uv run python prefill_decode_bench.py --model <decode-model> --pp 2048,8192 --tg 128
|
||||
uv run python prefill_decode_bench.py --model <decode-model> --prefill-model <vllm-model> --pp 4096 --tg 128
|
||||
uv run python prefill_decode_bench.py --model <model-id> --pp 2048 --tg 128 --no-overlapping
|
||||
uv run python prefill_decode_bench.py --model <model-id> --pp 2048 --tg 128 --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import itertools
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from statistics import mean
|
||||
from typing import Any
|
||||
|
||||
from exo_bench import (
|
||||
PromptSizer,
|
||||
format_peak_memory,
|
||||
load_tokenizer_for_bench,
|
||||
parse_int_list,
|
||||
)
|
||||
from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
add_common_instance_args,
|
||||
instance_id_from_instance,
|
||||
nodes_used_in_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
settle_and_fetch_placements,
|
||||
wait_for_instance_gone,
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def find_vllm_placement(placements: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
for p in placements:
|
||||
if "vllm" in str(p.get("instance_meta", "")).lower():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def find_mlx_placement(
|
||||
placements: list[dict[str, Any]], decode_meta: str
|
||||
) -> dict[str, Any] | None:
|
||||
for p in placements:
|
||||
meta = str(p.get("instance_meta", "")).lower()
|
||||
if decode_meta == "both":
|
||||
if "ring" in meta or "jaccl" in meta:
|
||||
return p
|
||||
elif decode_meta in meta:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def run_one_disaggregated(
|
||||
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_disaggregated(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 main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="prefill-decode-bench",
|
||||
description="Benchmark disaggregated prefill-decode (vLLM prefill → MLX decode).",
|
||||
)
|
||||
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 per placement (uses first pp/tg).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--prefill-model",
|
||||
default=None,
|
||||
help="Model for the vLLM prefill node (defaults to --model if not set).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--no-overlapping",
|
||||
action="store_true",
|
||||
help="Use batch KV transfer instead of streaming (overlapping).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--decode-instance-meta",
|
||||
choices=["ring", "jaccl", "both"],
|
||||
default="ring",
|
||||
help="Instance meta for the decode (MLX) node.",
|
||||
)
|
||||
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 (cartesian product) even when lists have equal length.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
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 requires >1000 uncached tokens)")
|
||||
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")
|
||||
|
||||
overlapping = not args.no_overlapping
|
||||
logger.info(f"KV transfer mode: {'overlapping (streaming)' if overlapping else 'batch'}")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
prefill_model_arg = args.prefill_model or args.model
|
||||
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] tokenizer load failed")
|
||||
|
||||
try:
|
||||
prompt_sizer = PromptSizer(tokenizer)
|
||||
except Exception:
|
||||
logger.error("[prefill-decode-bench] tokenizer usable but prompt sizing failed")
|
||||
raise
|
||||
|
||||
original_instance_meta = args.instance_meta
|
||||
original_max_nodes = args.max_nodes
|
||||
|
||||
args.instance_meta = "vllm"
|
||||
args.min_nodes = 1
|
||||
args.max_nodes = 1
|
||||
vllm_placements = settle_and_fetch_placements(
|
||||
client, prefill_full_id, args, settle_timeout=args.settle_timeout
|
||||
)
|
||||
|
||||
args.instance_meta = args.decode_instance_meta
|
||||
args.min_nodes = 1
|
||||
args.max_nodes = original_max_nodes
|
||||
mlx_placements = settle_and_fetch_placements(
|
||||
client, decode_full_id, args, settle_timeout=args.settle_timeout
|
||||
)
|
||||
|
||||
args.instance_meta = original_instance_meta
|
||||
|
||||
vllm_placement = find_vllm_placement(vllm_placements)
|
||||
mlx_placement = find_mlx_placement(mlx_placements, args.decode_instance_meta)
|
||||
|
||||
if not vllm_placement:
|
||||
logger.error("No vLLM placement found. Need a CUDA node for prefill.")
|
||||
return 1
|
||||
if not mlx_placement:
|
||||
logger.error(f"No MLX ({args.decode_instance_meta}) placement found for decode.")
|
||||
return 1
|
||||
|
||||
vllm_instance = vllm_placement["instance"]
|
||||
mlx_instance = mlx_placement["instance"]
|
||||
vllm_instance_id = instance_id_from_instance(vllm_instance)
|
||||
mlx_instance_id = instance_id_from_instance(mlx_instance)
|
||||
vllm_meta = str(vllm_placement.get("instance_meta", ""))
|
||||
mlx_meta = str(mlx_placement.get("instance_meta", ""))
|
||||
mlx_sharding = str(mlx_placement.get("sharding", ""))
|
||||
mlx_nodes = nodes_used_in_instance(mlx_instance)
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"PREFILL (vLLM): {vllm_meta} / {prefill_short_id} ({prefill_full_id}) / instance_id={vllm_instance_id}")
|
||||
logger.info(f"DECODE (MLX): {mlx_meta} / {mlx_sharding} / nodes={mlx_nodes} / {decode_short_id} ({decode_full_id}) / instance_id={mlx_instance_id}")
|
||||
logger.info(f"Overlapping: {overlapping}")
|
||||
|
||||
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,
|
||||
prefill_full_id,
|
||||
vllm_placement,
|
||||
args.danger_delete_downloads,
|
||||
args.timeout,
|
||||
settle_deadline,
|
||||
)
|
||||
download_duration_mlx = run_planning_phase(
|
||||
client,
|
||||
decode_full_id,
|
||||
mlx_placement,
|
||||
args.danger_delete_downloads,
|
||||
args.timeout,
|
||||
settle_deadline,
|
||||
)
|
||||
if download_duration_s is not None:
|
||||
logger.info(f"Download (vLLM): {download_duration_s:.1f}s")
|
||||
if download_duration_mlx is not None:
|
||||
logger.info(f"Download (MLX): {download_duration_mlx:.1f}s")
|
||||
|
||||
# Create vLLM instance first (starts prefill server)
|
||||
logger.info("Creating vLLM (prefill) instance...")
|
||||
client.request_json("POST", "/instance", body={"instance": vllm_instance})
|
||||
try:
|
||||
wait_for_instance_ready(client, vllm_instance_id)
|
||||
except (RuntimeError, TimeoutError) as e:
|
||||
logger.error(f"Failed to initialize vLLM instance: {e}")
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{vllm_instance_id}")
|
||||
return 1
|
||||
logger.info("vLLM (prefill) instance ready")
|
||||
|
||||
# Create MLX instance (decode)
|
||||
logger.info("Creating MLX (decode) instance...")
|
||||
client.request_json("POST", "/instance", body={"instance": mlx_instance})
|
||||
try:
|
||||
wait_for_instance_ready(client, mlx_instance_id)
|
||||
except (RuntimeError, TimeoutError) as e:
|
||||
logger.error(f"Failed to initialize MLX instance: {e}")
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{mlx_instance_id}")
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{vllm_instance_id}")
|
||||
return 1
|
||||
logger.info("MLX (decode) instance ready")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
all_rows: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
for i in range(args.warmup):
|
||||
run_one_disaggregated(
|
||||
client, decode_full_id, pp_list[0], tg_list[0], prompt_sizer
|
||||
)
|
||||
logger.debug(f" warmup {i + 1}/{args.warmup} done")
|
||||
|
||||
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))
|
||||
|
||||
for pp, tg in pp_tg_pairs:
|
||||
logger.info(f"--- pp={pp} tg={tg} ---")
|
||||
runs: list[dict[str, Any]] = []
|
||||
for r in range(args.repeat):
|
||||
time.sleep(3)
|
||||
|
||||
try:
|
||||
row, actual_pp_tokens = run_one_disaggregated(
|
||||
client, decode_full_id, pp, tg, prompt_sizer
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
continue
|
||||
|
||||
row.update(
|
||||
{
|
||||
"prefill_model_short_id": prefill_short_id,
|
||||
"prefill_model_id": prefill_full_id,
|
||||
"prefill_instance_meta": vllm_meta,
|
||||
"prefill_instance_id": vllm_instance_id,
|
||||
"decode_model_short_id": decode_short_id,
|
||||
"decode_model_id": decode_full_id,
|
||||
"decode_instance_meta": mlx_meta,
|
||||
"decode_sharding": mlx_sharding,
|
||||
"decode_nodes": mlx_nodes,
|
||||
"decode_instance_id": mlx_instance_id,
|
||||
"overlapping": overlapping,
|
||||
"pp_tokens": actual_pp_tokens,
|
||||
"tg": tg,
|
||||
"repeat_index": r,
|
||||
}
|
||||
)
|
||||
runs.append(row)
|
||||
all_rows.append(row)
|
||||
|
||||
if runs:
|
||||
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
|
||||
gen_tps = mean(x["stats"]["generation_tps"] for x in runs)
|
||||
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
|
||||
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
|
||||
peak = mean(
|
||||
x["stats"]["peak_memory_usage"]["inBytes"] for x in runs
|
||||
)
|
||||
avg_elapsed = mean(x["elapsed_s"] for x in runs)
|
||||
|
||||
logger.info(
|
||||
f"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\n"
|
||||
)
|
||||
time.sleep(2)
|
||||
finally:
|
||||
try:
|
||||
client.request_json("DELETE", f"/instance/{mlx_instance_id}")
|
||||
except ExoHttpError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
try:
|
||||
client.request_json("DELETE", f"/instance/{vllm_instance_id}")
|
||||
except ExoHttpError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
wait_for_instance_gone(client, mlx_instance_id)
|
||||
wait_for_instance_gone(client, vllm_instance_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())
|
||||
@@ -0,0 +1,377 @@
|
||||
# type: ignore
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import mlx.core as mx
|
||||
|
||||
DTYPE_MAP = {
|
||||
"float32": (mx.float32, 4),
|
||||
"float16": (mx.float16, 2),
|
||||
"bfloat16": (mx.bfloat16, 2),
|
||||
}
|
||||
|
||||
SIZES = [
|
||||
1 * 1024,
|
||||
4 * 1024,
|
||||
16 * 1024,
|
||||
64 * 1024,
|
||||
256 * 1024,
|
||||
1 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
16 * 1024 * 1024,
|
||||
64 * 1024 * 1024,
|
||||
256 * 1024 * 1024,
|
||||
1 * 1024 * 1024 * 1024,
|
||||
2 * 1024 * 1024 * 1024,
|
||||
4 * 1024 * 1024 * 1024,
|
||||
8 * 1024 * 1024 * 1024,
|
||||
]
|
||||
|
||||
|
||||
def format_bytes(n: int) -> str:
|
||||
if n >= 1024 * 1024 * 1024:
|
||||
return f"{n / (1024 * 1024 * 1024):.0f} GB"
|
||||
if n >= 1024 * 1024:
|
||||
return f"{n / (1024 * 1024):.0f} MB"
|
||||
if n >= 1024:
|
||||
return f"{n / 1024:.0f} KB"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def format_time(seconds: float) -> str:
|
||||
if seconds >= 1.0:
|
||||
return f"{seconds:.3f} s"
|
||||
if seconds >= 0.001:
|
||||
return f"{seconds * 1000:.2f} ms"
|
||||
return f"{seconds * 1_000_000:.1f} us"
|
||||
|
||||
|
||||
def format_bandwidth(bytes_per_sec: float) -> str:
|
||||
if bytes_per_sec >= 1024 * 1024 * 1024:
|
||||
return f"{bytes_per_sec / (1024 * 1024 * 1024):.2f} GB/s"
|
||||
if bytes_per_sec >= 1024 * 1024:
|
||||
return f"{bytes_per_sec / (1024 * 1024):.1f} MB/s"
|
||||
return f"{bytes_per_sec / 1024:.1f} KB/s"
|
||||
|
||||
|
||||
def barrier(group: mx.distributed.Group) -> None:
|
||||
mx.eval(mx.distributed.all_sum(mx.array(1.0), group=group))
|
||||
|
||||
|
||||
def init_ring(
|
||||
rank: int, self_ip: str, peer_ip: str, port: int, tmpdir: str
|
||||
) -> mx.distributed.Group:
|
||||
if rank == 0:
|
||||
hosts = [f"{self_ip}:{port}", f"{peer_ip}:{port}"]
|
||||
else:
|
||||
hosts = [f"{peer_ip}:{port}", f"{self_ip}:{port}"]
|
||||
|
||||
hostfile = os.path.join(tmpdir, "hosts.json")
|
||||
with open(hostfile, "w") as f:
|
||||
json.dump(hosts, f)
|
||||
|
||||
for var in ("MLX_HOSTFILE", "MLX_RANK", "MLX_IBV_DEVICES", "MLX_JACCL_COORDINATOR"):
|
||||
os.environ.pop(var, None)
|
||||
|
||||
os.environ["MLX_HOSTFILE"] = hostfile
|
||||
os.environ["MLX_RANK"] = str(rank)
|
||||
return mx.distributed.init(backend="ring", strict=True)
|
||||
|
||||
|
||||
def init_jaccl(
|
||||
rank: int, interface: str, coordinator: str, port: int, tmpdir: str
|
||||
) -> mx.distributed.Group:
|
||||
devices = [[None, interface], [interface, None]]
|
||||
devfile = os.path.join(tmpdir, "devices.json")
|
||||
with open(devfile, "w") as f:
|
||||
json.dump(devices, f)
|
||||
|
||||
for var in ("MLX_HOSTFILE", "MLX_RANK", "MLX_IBV_DEVICES", "MLX_JACCL_COORDINATOR"):
|
||||
os.environ.pop(var, None)
|
||||
|
||||
os.environ["MLX_IBV_DEVICES"] = devfile
|
||||
os.environ["MLX_RANK"] = str(rank)
|
||||
if rank == 0:
|
||||
os.environ["MLX_JACCL_COORDINATOR"] = f"0.0.0.0:{port}"
|
||||
else:
|
||||
os.environ["MLX_JACCL_COORDINATOR"] = coordinator
|
||||
|
||||
return mx.distributed.init(backend="jaccl", strict=True)
|
||||
|
||||
|
||||
def bench_unidirectional(
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
size_bytes: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
) -> list[float]:
|
||||
n_elements = size_bytes // element_size
|
||||
tensor = mx.random.normal(shape=(n_elements,)).astype(dtype)
|
||||
mx.eval(tensor)
|
||||
|
||||
for _ in range(warmup):
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
barrier(group)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(iterations):
|
||||
barrier(group)
|
||||
t0 = time.perf_counter()
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
barrier(group)
|
||||
t1 = time.perf_counter()
|
||||
times.append(t1 - t0)
|
||||
|
||||
return times
|
||||
|
||||
|
||||
def bench_rtt(
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
size_bytes: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
) -> list[float]:
|
||||
n_elements = size_bytes // element_size
|
||||
tensor = mx.random.normal(shape=(n_elements,)).astype(dtype)
|
||||
mx.eval(tensor)
|
||||
|
||||
for _ in range(warmup):
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
received = mx.distributed.recv_like(tensor, src=1, group=group)
|
||||
mx.eval(received)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
sent = mx.distributed.send(received, dst=0, group=group)
|
||||
mx.eval(sent)
|
||||
barrier(group)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(iterations):
|
||||
barrier(group)
|
||||
t0 = time.perf_counter()
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
received = mx.distributed.recv_like(tensor, src=1, group=group)
|
||||
mx.eval(received)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
sent = mx.distributed.send(received, dst=0, group=group)
|
||||
mx.eval(sent)
|
||||
barrier(group)
|
||||
t1 = time.perf_counter()
|
||||
times.append(t1 - t0)
|
||||
|
||||
return times
|
||||
|
||||
|
||||
def bench_all_gather(
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
size_bytes: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
) -> list[float]:
|
||||
n_elements = (size_bytes // 2) // element_size
|
||||
tensor = mx.random.normal(shape=(n_elements,)).astype(dtype)
|
||||
mx.eval(tensor)
|
||||
|
||||
for _ in range(warmup):
|
||||
gathered = mx.distributed.all_gather(tensor, group=group)
|
||||
mx.eval(gathered)
|
||||
barrier(group)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(iterations):
|
||||
barrier(group)
|
||||
t0 = time.perf_counter()
|
||||
gathered = mx.distributed.all_gather(tensor, group=group)
|
||||
mx.eval(gathered)
|
||||
t1 = time.perf_counter()
|
||||
times.append(t1 - t0)
|
||||
|
||||
return times
|
||||
|
||||
|
||||
def print_table(title: str, rows: list[dict[str, str]]) -> None:
|
||||
print(f"\n=== {title} ===")
|
||||
headers = ["Size", "Median", "Min", "Max", "Bandwidth"]
|
||||
widths = [
|
||||
max(len(h), max((len(r[h]) for r in rows), default=0)) + 2 for h in headers
|
||||
]
|
||||
header_line = "".join(h.ljust(w) for h, w in zip(headers, widths, strict=True))
|
||||
print(header_line)
|
||||
print("-" * len(header_line))
|
||||
for row in rows:
|
||||
print("".join(row[h].ljust(w) for h, w in zip(headers, widths, strict=True)))
|
||||
|
||||
|
||||
def run_bench(
|
||||
name: str,
|
||||
bench_fn,
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
bw_multiplier: int = 1,
|
||||
) -> None:
|
||||
rows: list[dict[str, str]] = []
|
||||
for size in SIZES:
|
||||
if rank == 0:
|
||||
print(f" {name}: {format_bytes(size)}...", end="", flush=True)
|
||||
times = bench_fn(group, rank, size, dtype, element_size, warmup, iterations)
|
||||
if rank == 0:
|
||||
med = statistics.median(times)
|
||||
mn = min(times)
|
||||
mx_ = max(times)
|
||||
bw = (size * bw_multiplier) / med
|
||||
rows.append(
|
||||
{
|
||||
"Size": format_bytes(size),
|
||||
"Median": format_time(med),
|
||||
"Min": format_time(mn),
|
||||
"Max": format_time(mx_),
|
||||
"Bandwidth": format_bandwidth(bw),
|
||||
}
|
||||
)
|
||||
print(f" {format_bandwidth(bw)}")
|
||||
if rank == 0:
|
||||
print_table(name, rows)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MLX Distributed Communication Benchmark"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="backend", required=True)
|
||||
|
||||
ring_parser = subparsers.add_parser("ring")
|
||||
ring_parser.add_argument("--rank", type=int, required=True, choices=[0, 1])
|
||||
ring_parser.add_argument("--self-ip", required=True)
|
||||
ring_parser.add_argument("--peer-ip", required=True)
|
||||
ring_parser.add_argument("--port", type=int, default=5555)
|
||||
|
||||
jaccl_parser = subparsers.add_parser("jaccl")
|
||||
jaccl_parser.add_argument("--rank", type=int, required=True, choices=[0, 1])
|
||||
jaccl_parser.add_argument("--interface", required=True)
|
||||
jaccl_parser.add_argument(
|
||||
"--coordinator",
|
||||
type=str,
|
||||
default=None,
|
||||
help="IP:PORT of rank 0 (required for rank 1)",
|
||||
)
|
||||
jaccl_parser.add_argument(
|
||||
"--port", type=int, default=9999, help="Coordinator port (rank 0 only)"
|
||||
)
|
||||
|
||||
for p in [ring_parser, jaccl_parser]:
|
||||
p.add_argument("--warmup", type=int, default=3)
|
||||
p.add_argument("--iterations", type=int, default=10)
|
||||
p.add_argument("--dtype", choices=list(DTYPE_MAP.keys()), default="float32")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.backend == "jaccl" and args.rank == 1 and args.coordinator is None:
|
||||
jaccl_parser.error("--coordinator is required for rank 1")
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
dtype, element_size = DTYPE_MAP[args.dtype]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if args.backend == "ring":
|
||||
print(f"Initializing ring backend (rank {args.rank})...")
|
||||
group = init_ring(args.rank, args.self_ip, args.peer_ip, args.port, tmpdir)
|
||||
else:
|
||||
print(f"Initializing jaccl backend (rank {args.rank})...")
|
||||
group = init_jaccl(
|
||||
args.rank, args.interface, args.coordinator or "", args.port, tmpdir
|
||||
)
|
||||
|
||||
print(f"Rank {group.rank()} of {group.size()} initialized")
|
||||
barrier(group)
|
||||
|
||||
if args.rank == 0:
|
||||
print("\nMLX Distributed Communication Benchmark")
|
||||
print(
|
||||
f"Backend: {args.backend} | Dtype: {args.dtype} | Warmup: {args.warmup} | Iterations: {args.iterations}"
|
||||
)
|
||||
|
||||
run_bench(
|
||||
"Unidirectional (rank 0 -> rank 1)",
|
||||
bench_unidirectional,
|
||||
group,
|
||||
args.rank,
|
||||
dtype,
|
||||
element_size,
|
||||
args.warmup,
|
||||
args.iterations,
|
||||
)
|
||||
run_bench(
|
||||
"Round-Trip (ping-pong)",
|
||||
bench_rtt,
|
||||
group,
|
||||
args.rank,
|
||||
dtype,
|
||||
element_size,
|
||||
args.warmup,
|
||||
args.iterations,
|
||||
bw_multiplier=2,
|
||||
)
|
||||
run_bench(
|
||||
"All-Gather",
|
||||
bench_all_gather,
|
||||
group,
|
||||
args.rank,
|
||||
dtype,
|
||||
element_size,
|
||||
args.warmup,
|
||||
args.iterations,
|
||||
)
|
||||
|
||||
if args.rank == 0:
|
||||
print("\nDone.")
|
||||
else:
|
||||
print("Rank 1 complete.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
sys.exit(1)
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
echo "=== Starting overnight bench runs at $(date) ==="
|
||||
|
||||
echo "--- [4/8] Qwen3.5-122B-A10B-GPTQ-Int4 ---"
|
||||
echo "Skipping because Int 4"
|
||||
#uv run bench/exo_bench.py --force-download --model "Qwen/Qwen3.5-122B-A10B-GPTQ-Int4" --pp 700 --tg 36000 --repeat 1
|
||||
|
||||
echo "--- [5/8] Qwen3.5-27B-FP8 ---"
|
||||
#uv run bench/exo_bench.py --force-download --model "Qwen/Qwen3.5-27B-FP8" --pp 700 --tg 35133 --repeat 1
|
||||
|
||||
echo "--- [6/8] GLM-4.7-Flash-bf16 ---"
|
||||
uv run bench/exo_bench.py --force-download --model "mlx-community/GLM-4.7-Flash-bf16" --pp 700 --tg 29000 --repeat 1
|
||||
|
||||
echo "--- [7/8] NVIDIA-Nemotron-3-Nano-30B-A3B (23000,1200) ---"
|
||||
uv run bench/exo_bench.py --force-download --model "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-BF16" --pp 700 --tg 23000,1200 --repeat 1
|
||||
|
||||
echo "--- [8/8] Qwen3.5-27B-bf16 ---"
|
||||
uv run bench/exo_bench.py --force-download --model "mlx-community/Qwen3.5-27B-bf16" --pp 700 --tg 35400 --repeat 1
|
||||
|
||||
echo "=== All bench runs complete at $(date) ==="
|
||||
@@ -9,6 +9,10 @@
|
||||
quantization: string;
|
||||
}
|
||||
|
||||
function normalizeBaseModel(s: string): string {
|
||||
return s.toLowerCase().replace(/[-_]/g, " ").trim();
|
||||
}
|
||||
|
||||
// Auto mode tier list (for when user just starts typing)
|
||||
export const AUTO_TIERS: string[][] = [
|
||||
// Tier 1 (frontier)
|
||||
@@ -43,8 +47,9 @@
|
||||
|
||||
/** Return the tier index (0 = best) for a base_model name. */
|
||||
export function getAutoTierIndex(baseModel: string): number {
|
||||
const norm = normalizeBaseModel(baseModel);
|
||||
for (let i = 0; i < AUTO_TIERS.length; i++) {
|
||||
if (AUTO_TIERS[i].includes(baseModel)) return i;
|
||||
if (AUTO_TIERS[i].some((t) => normalizeBaseModel(t) === norm)) return i;
|
||||
}
|
||||
return AUTO_TIERS.length; // not in any tier → lowest priority
|
||||
}
|
||||
@@ -60,7 +65,7 @@
|
||||
const variants = modelList
|
||||
.filter(
|
||||
(m) =>
|
||||
m.base_model === baseModel &&
|
||||
normalizeBaseModel(m.base_model) === normalizeBaseModel(baseModel) &&
|
||||
(m.storage_size_megabytes || 0) / 1024 <= memoryGB &&
|
||||
(m.storage_size_megabytes || 0) > 0,
|
||||
)
|
||||
@@ -162,7 +167,7 @@
|
||||
/** For a given base_model name, find the biggest quant variant that fits in memory. */
|
||||
function pickBestVariant(baseModel: string): ChatModelInfo | null {
|
||||
const variants = models
|
||||
.filter((m) => m.base_model === baseModel && fitsInMemory(m))
|
||||
.filter((m) => normalizeBaseModel(m.base_model) === normalizeBaseModel(baseModel) && fitsInMemory(m))
|
||||
.sort((a, b) => getModelSizeGB(b) - getModelSizeGB(a));
|
||||
return variants[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** "macbook pro" | "mac studio" | "mac mini" etc. */
|
||||
/** "macbook pro" | "mac studio" | "mac mini" | "dgx spark" | "linux" etc. */
|
||||
deviceType: string;
|
||||
/** Center X coordinate in SVG space */
|
||||
cx: number;
|
||||
@@ -38,10 +38,43 @@
|
||||
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);
|
||||
@@ -114,7 +147,264 @@
|
||||
const studioClipId = $derived(`di-studio-${uid}`);
|
||||
</script>
|
||||
|
||||
{#if modelLower === "mac studio" || modelLower === "mac mini"}
|
||||
{#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"}
|
||||
<!-- Mac Studio / Mac Mini -->
|
||||
<defs>
|
||||
<clipPath id={studioClipId}>
|
||||
|
||||
@@ -88,12 +88,6 @@
|
||||
d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if family === "nemotron"}
|
||||
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
kimi: "Kimi",
|
||||
flux: "FLUX",
|
||||
"qwen-image": "Qwen Img",
|
||||
nemotron: "NVIDIA",
|
||||
};
|
||||
|
||||
function getFamilyName(family: string): string {
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
perNode?: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
status: "completed" | "partial" | "pending" | "downloading";
|
||||
percentage: number;
|
||||
progress: DownloadProgress | null;
|
||||
progress: DownloadProgress;
|
||||
}>;
|
||||
} | null;
|
||||
nodes?: Record<string, NodeInfo>;
|
||||
@@ -147,7 +145,10 @@
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
const perNode = $derived(downloadStatus?.perNode ?? []);
|
||||
const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
|
||||
const progress = $derived(downloadStatus?.progress);
|
||||
const percentage = $derived(progress?.percentage ?? 0);
|
||||
let expandedNodes = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleNodeDetails(nodeId: string): void {
|
||||
const next = new Set(expandedNodes);
|
||||
@@ -168,8 +169,10 @@
|
||||
|
||||
function getDeviceType(
|
||||
name: string,
|
||||
): "macbook" | "studio" | "mini" | "unknown" {
|
||||
): "macbook" | "studio" | "mini" | "dgx" | "linux" | "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";
|
||||
@@ -277,7 +280,7 @@
|
||||
let placementNodes: Array<{
|
||||
id: string;
|
||||
deviceName: string;
|
||||
deviceType: "macbook" | "studio" | "mini" | "unknown";
|
||||
deviceType: "macbook" | "studio" | "mini" | "dgx" | "linux" | "unknown";
|
||||
totalGB: number;
|
||||
currentUsedGB: number;
|
||||
modelUsageGB: number;
|
||||
@@ -586,49 +589,23 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Download Status (per-node) -->
|
||||
{#if perNode.length > 0}
|
||||
<!-- Download Status -->
|
||||
{#if isDownloading && progress}
|
||||
<div class="mb-2 space-y-1">
|
||||
<div
|
||||
class="text-[10px] font-mono text-white/20 tracking-widest uppercase"
|
||||
>
|
||||
Download progress
|
||||
<div class="flex items-center justify-between text-xs font-mono">
|
||||
<span class="text-blue-400 tracking-wider uppercase">Downloading</span
|
||||
>
|
||||
<span class="text-white/60"
|
||||
>{percentage.toFixed(1)}% · {formatSpeed(progress.speed)}
|
||||
· {formatEta(progress.etaMs)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="h-1 bg-exo-medium-gray/30 rounded overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-500/70 transition-all duration-300"
|
||||
style="width: {percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
{#each perNode as node}
|
||||
<div class="flex items-center gap-2 text-xs font-mono">
|
||||
<span class="text-white/40 w-20 truncate" title={node.nodeId}
|
||||
>{node.nodeName}</span
|
||||
>
|
||||
<div
|
||||
class="flex-1 h-1 bg-exo-medium-gray/30 rounded overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full transition-all duration-300 {node.status ===
|
||||
'downloading'
|
||||
? 'bg-blue-500/70'
|
||||
: node.status === 'completed'
|
||||
? 'bg-exo-yellow/40'
|
||||
: 'bg-white/20'}"
|
||||
style="width: {node.percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
<span
|
||||
class="text-right {node.status === 'completed'
|
||||
? 'text-exo-yellow/60'
|
||||
: node.status === 'downloading'
|
||||
? 'text-blue-400/60'
|
||||
: 'text-white/30'}"
|
||||
>
|
||||
{#if node.status === "downloading" && node.progress}
|
||||
{Math.round(node.percentage)}% {formatSpeed(
|
||||
node.progress.speed,
|
||||
)}
|
||||
{:else}
|
||||
{node.percentage > 0 ? `${Math.round(node.percentage)}%` : "0%"}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -687,7 +664,15 @@
|
||||
{@const allConnections =
|
||||
isDebugMode && usedNodes.length > 1
|
||||
? (() => {
|
||||
const conns: Array = [];
|
||||
const conns: Array<{
|
||||
ip: string;
|
||||
iface: string | null;
|
||||
from: string;
|
||||
to: string;
|
||||
midX: number;
|
||||
midY: number;
|
||||
arrow: string;
|
||||
}> = [];
|
||||
for (let i = 0; i < usedNodes.length; i++) {
|
||||
for (let j = i + 1; j < usedNodes.length; j++) {
|
||||
const n1 = usedNodes[i];
|
||||
@@ -699,12 +684,7 @@
|
||||
const toPos = nodePositions[c.to];
|
||||
const arrow =
|
||||
fromPos && toPos ? getArrow(fromPos, toPos) : "→";
|
||||
conns.push({
|
||||
...c,
|
||||
midX,
|
||||
midY,
|
||||
arrow,
|
||||
});
|
||||
conns.push({ ...c, midX, midY, arrow });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -990,6 +970,137 @@
|
||||
/>
|
||||
{/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 if node.deviceType === "linux"}
|
||||
<!-- Linux Tux penguin icon -->
|
||||
{@const sz = node.iconSize}
|
||||
{@const sc = sz / 100}
|
||||
<g transform="translate({-sz / 2}, {-sz / 2})">
|
||||
<!-- Body -->
|
||||
<path
|
||||
d="M50 8c-8 0-14 6-14 13 0 4 2 8 5 10-8 4-16 14-16 28v12c0 4 2 7 5 9l-6 4c-2 1-3 3-3 5v3c0 2 2 4 4 4h10l4-4h22l4 4h10c2 0 4-2 4-4v-3c0-2-1-4-3-5l-6-4c3-2 5-5 5-9V59c0-14-8-24-16-28 3-2 5-6 5-10 0-7-6-13-14-13z"
|
||||
transform="scale({sc})"
|
||||
fill="#1a1a1a"
|
||||
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
|
||||
stroke-width={1.5 / sc}
|
||||
/>
|
||||
<!-- Belly -->
|
||||
<path
|
||||
d="M38 52c0-8 5-15 12-15s12 7 12 15v14c0 4-5 7-12 7s-12-3-12-7V52z"
|
||||
transform="scale({sc})"
|
||||
fill="rgba(220,220,220,0.85)"
|
||||
/>
|
||||
<!-- Eyes -->
|
||||
<circle cx={44 * sc} cy={16 * sc} r={2.5 * sc} fill="white" />
|
||||
<circle cx={56 * sc} cy={16 * sc} r={2.5 * sc} fill="white" />
|
||||
<circle
|
||||
cx={44 * sc}
|
||||
cy={16 * sc}
|
||||
r={1.2 * sc}
|
||||
fill="#1a1a1a"
|
||||
/>
|
||||
<circle
|
||||
cx={56 * sc}
|
||||
cy={16 * sc}
|
||||
r={1.2 * sc}
|
||||
fill="#1a1a1a"
|
||||
/>
|
||||
<!-- Beak -->
|
||||
<path
|
||||
d="M{46 * sc} {22 * sc} L{50 * sc} {27 * sc} L{54 *
|
||||
sc} {22 * sc} Z"
|
||||
fill="#E8A317"
|
||||
/>
|
||||
<!-- Feet -->
|
||||
<ellipse
|
||||
cx={42 * sc}
|
||||
cy={94 * sc}
|
||||
rx={6 * sc}
|
||||
ry={2.5 * sc}
|
||||
fill="#E8A317"
|
||||
/>
|
||||
<ellipse
|
||||
cx={58 * sc}
|
||||
cy={94 * sc}
|
||||
rx={6 * sc}
|
||||
ry={2.5 * sc}
|
||||
fill="#E8A317"
|
||||
/>
|
||||
</g>
|
||||
{:else}
|
||||
<!-- Unknown device - hexagon -->
|
||||
<g
|
||||
|
||||
@@ -379,12 +379,16 @@
|
||||
return hfTrendingModels;
|
||||
});
|
||||
|
||||
function normalizeBaseModel(s: string): string {
|
||||
return s.toLowerCase().replace(/[-_]/g, " ").trim();
|
||||
}
|
||||
|
||||
// Group models by base_model
|
||||
const groupedModels = $derived.by((): ModelGroup[] => {
|
||||
const groups = new Map<string, ModelGroup>();
|
||||
|
||||
for (const model of models) {
|
||||
const groupId = model.base_model || model.id;
|
||||
const groupId = normalizeBaseModel(model.base_model || model.id);
|
||||
const groupName = model.base_model || model.name || model.id;
|
||||
|
||||
if (!groups.has(groupId)) {
|
||||
@@ -459,7 +463,6 @@
|
||||
"llama",
|
||||
"flux",
|
||||
"qwen-image",
|
||||
"nemotron",
|
||||
];
|
||||
return Array.from(families).sort((a, b) => {
|
||||
const aIdx = familyOrder.indexOf(a);
|
||||
@@ -579,7 +582,7 @@
|
||||
const model = models.find((m) => m.id === id);
|
||||
if (model) {
|
||||
result.push({
|
||||
id: model.base_model || model.id,
|
||||
id: normalizeBaseModel(model.base_model || model.id),
|
||||
name: model.name || model.id,
|
||||
capabilities: model.capabilities || ["text"],
|
||||
family: model.family || "",
|
||||
|
||||
@@ -117,6 +117,10 @@
|
||||
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;
|
||||
@@ -554,6 +558,13 @@
|
||||
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);
|
||||
@@ -623,7 +634,382 @@
|
||||
`${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
|
||||
);
|
||||
|
||||
if (modelLower === "mac studio") {
|
||||
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") {
|
||||
// Mac Studio - classic cube with memory fill
|
||||
iconBaseWidth = nodeRadius * 1.25;
|
||||
iconBaseHeight = nodeRadius * 0.85;
|
||||
@@ -1182,8 +1568,12 @@
|
||||
debugLabelY += debugLineHeight;
|
||||
}
|
||||
|
||||
const identity = identitiesData[nodeInfo.id];
|
||||
if (identity?.osVersion) {
|
||||
const dbgIdentity = identitiesData[nodeInfo.id];
|
||||
if (dbgIdentity?.osVersion) {
|
||||
const osLabel =
|
||||
dbgIdentity.osVersion === "Linux"
|
||||
? "Linux"
|
||||
: `macOS ${dbgIdentity.osVersion}${dbgIdentity.osBuildVersion ? ` (${dbgIdentity.osBuildVersion})` : ""}`;
|
||||
nodeG
|
||||
.append("text")
|
||||
.attr("x", nodeInfo.x)
|
||||
@@ -1192,9 +1582,7 @@
|
||||
.attr("fill", "rgba(179,179,179,0.7)")
|
||||
.attr("font-size", debugFontSize)
|
||||
.attr("font-family", "SF Mono, Monaco, monospace")
|
||||
.text(
|
||||
`macOS ${identity.osVersion}${identity.osBuildVersion ? ` (${identity.osBuildVersion})` : ""}`,
|
||||
);
|
||||
.text(osLabel);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ export interface ModelDownloadStatus {
|
||||
export interface PlacementPreview {
|
||||
model_id: string;
|
||||
sharding: "Pipeline" | "Tensor";
|
||||
instance_meta: "MlxRing" | "MlxJaccl";
|
||||
instance_meta: "MlxRing" | "MlxJaccl" | "Vllm";
|
||||
instance: unknown | null;
|
||||
memory_delta_by_node: Record<string, number> | null;
|
||||
error: string | null;
|
||||
@@ -547,6 +547,7 @@ class AppStore {
|
||||
{ total: { inBytes: number }; available: { inBytes: number } }
|
||||
>
|
||||
>({});
|
||||
vllmAvailable = $state(false);
|
||||
placementPreviews = $state<PlacementPreview[]>([]);
|
||||
selectedPreviewModelId = $state<string | null>(null);
|
||||
isLoadingPreviews = $state(false);
|
||||
@@ -1331,6 +1332,15 @@ class AppStore {
|
||||
this.isConnected = true;
|
||||
}
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
fetch("/capabilities")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { vllm_available?: boolean } | null) => {
|
||||
this.vllmAvailable = data?.vllm_available ?? false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.vllmAvailable = false;
|
||||
});
|
||||
} catch (error) {
|
||||
this.consecutiveFailures++;
|
||||
if (
|
||||
@@ -1793,14 +1803,6 @@ class AppStore {
|
||||
this.persistConversation(targetConversationId);
|
||||
}
|
||||
},
|
||||
{
|
||||
generation_stats: (data) => {
|
||||
const stats = data as { generation_tps: number };
|
||||
if (stats.generation_tps > 0) {
|
||||
this.tps = stats.generation_tps;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Final update
|
||||
@@ -1998,14 +2000,6 @@ class AppStore {
|
||||
this.persistConversation(targetConversationId);
|
||||
}
|
||||
},
|
||||
{
|
||||
generation_stats: (data) => {
|
||||
const stats = data as { generation_tps: number };
|
||||
if (stats.generation_tps > 0) {
|
||||
this.tps = stats.generation_tps;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Final cleanup of the message (if conversation still exists)
|
||||
@@ -2413,7 +2407,7 @@ class AppStore {
|
||||
|
||||
let streamedContent = "";
|
||||
let streamedThinking = "";
|
||||
let serverTpsReceived = false;
|
||||
|
||||
interface ChatCompletionChunk {
|
||||
choices?: Array<{
|
||||
delta?: { content?: string; reasoning_content?: string };
|
||||
@@ -2478,6 +2472,7 @@ class AppStore {
|
||||
tokenCount += 1;
|
||||
this.totalTokens = tokenCount;
|
||||
|
||||
// Update real-time TPS during streaming
|
||||
if (firstTokenTime !== null && tokenCount > 1) {
|
||||
const elapsed = performance.now() - firstTokenTime;
|
||||
this.tps = (tokenCount / elapsed) * 1000;
|
||||
@@ -2528,24 +2523,16 @@ class AppStore {
|
||||
startedAt: this.prefillProgress?.startedAt ?? performance.now(),
|
||||
};
|
||||
},
|
||||
generation_stats: (data) => {
|
||||
const stats = data as { generation_tps: number };
|
||||
|
||||
if (stats.generation_tps > 0) {
|
||||
this.tps = stats.generation_tps;
|
||||
serverTpsReceived = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Clear prefill progress after stream ends
|
||||
this.prefillProgress = null;
|
||||
|
||||
// Use server-side TPS if available, otherwise fall back to client-side
|
||||
if (!serverTpsReceived && firstTokenTime !== null && tokenCount > 1) {
|
||||
// Calculate final TPS
|
||||
if (firstTokenTime !== null && tokenCount > 1) {
|
||||
const totalGenerationTime = performance.now() - firstTokenTime;
|
||||
this.tps = (tokenCount / totalGenerationTime) * 1000;
|
||||
this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
|
||||
}
|
||||
|
||||
// Final cleanup of the message (if conversation still exists)
|
||||
@@ -3357,6 +3344,7 @@ export const nodeThunderbolt = () => appStore.nodeThunderbolt;
|
||||
export const nodeRdmaCtl = () => appStore.nodeRdmaCtl;
|
||||
export const thunderboltBridgeCycles = () => appStore.thunderboltBridgeCycles;
|
||||
export const nodeThunderboltBridge = () => appStore.nodeThunderboltBridge;
|
||||
export const vllmAvailable = () => appStore.vllmAvailable;
|
||||
|
||||
// Image generation params
|
||||
export const imageGenerationParams = () => appStore.getImageGenerationParams();
|
||||
|
||||
+278
-196
@@ -42,7 +42,6 @@
|
||||
setSelectedChatModel,
|
||||
selectedChatModel,
|
||||
sendMessage,
|
||||
thinkingEnabled,
|
||||
generateImage,
|
||||
editImage,
|
||||
editingImage,
|
||||
@@ -65,6 +64,7 @@
|
||||
nodeThunderboltBridge,
|
||||
nodeIdentities,
|
||||
isConnected,
|
||||
vllmAvailable,
|
||||
type DownloadProgress,
|
||||
type PlacementPreview,
|
||||
} from "$lib/stores/app.svelte";
|
||||
@@ -295,7 +295,7 @@
|
||||
const seen = new Set<string>();
|
||||
const deduped: typeof candidates = [];
|
||||
for (const m of candidates) {
|
||||
const key = m.base_model || m.family || m.id;
|
||||
const key = (m.base_model || m.family || m.id).toLowerCase().replace(/[-_]/g, " ");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(m);
|
||||
@@ -701,7 +701,10 @@
|
||||
? Object.keys(topologyData()!.nodes).length
|
||||
: 1;
|
||||
const sharding = nodeCount <= 1 ? "Pipeline" : selectedSharding;
|
||||
const instanceType = nodeCount <= 1 ? "MlxRing" : selectedInstanceType;
|
||||
const instanceType =
|
||||
nodeCount <= 1 && selectedInstanceType !== "Vllm"
|
||||
? "MlxRing"
|
||||
: selectedInstanceType;
|
||||
try {
|
||||
const placementResponse = await fetch(
|
||||
`/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${sharding}&instance_meta=${instanceType}&min_nodes=1`,
|
||||
@@ -853,7 +856,7 @@
|
||||
) {
|
||||
const model = selectedChatModel();
|
||||
if (!model) {
|
||||
sendMessage(content, files, thinkingEnabled());
|
||||
sendMessage(content, files, null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -881,11 +884,11 @@
|
||||
}
|
||||
|
||||
// Default: text chat
|
||||
sendMessage(content, files, thinkingEnabled());
|
||||
sendMessage(content, files, null);
|
||||
}
|
||||
|
||||
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
|
||||
type InstanceMeta = "MlxRing" | "MlxJaccl";
|
||||
type InstanceMeta = "MlxRing" | "MlxJaccl" | "Vllm";
|
||||
|
||||
// Launch defaults persistence
|
||||
const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults-v2";
|
||||
@@ -931,7 +934,11 @@
|
||||
// Apply sharding and instance type unconditionally
|
||||
selectedSharding = defaults.sharding;
|
||||
selectedInstanceType =
|
||||
defaults.instanceType === "MlxRing" ? "MlxRing" : "MlxJaccl";
|
||||
defaults.instanceType === "Vllm"
|
||||
? "Vllm"
|
||||
: defaults.instanceType === "MlxRing"
|
||||
? "MlxRing"
|
||||
: "MlxJaccl";
|
||||
|
||||
// Apply minNodes if valid (between 1 and maxNodes)
|
||||
if (
|
||||
@@ -1145,9 +1152,7 @@
|
||||
}
|
||||
|
||||
const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
|
||||
selectedInstanceType === "MlxRing"
|
||||
? runtime === "MlxRing"
|
||||
: runtime === "MlxJaccl";
|
||||
runtime === selectedInstanceType;
|
||||
|
||||
// Helper to check if a model can be launched (has valid placement with >= minNodes)
|
||||
function canModelFit(modelId: string): boolean {
|
||||
@@ -1536,44 +1541,34 @@
|
||||
}
|
||||
|
||||
// Helper to get download status for a model (checks all downloads for matching model ID)
|
||||
type NodeDownloadStatus = {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
status: "completed" | "partial" | "pending" | "downloading";
|
||||
percentage: number;
|
||||
progress: DownloadProgress | null;
|
||||
};
|
||||
|
||||
// Shared helper: collect per-node download status for a model across a set of nodes.
|
||||
// Handles deduplication, entry parsing, and aggregation in one place.
|
||||
function collectDownloadStatus(
|
||||
modelId: string,
|
||||
nodeIds?: string[],
|
||||
): {
|
||||
function getModelDownloadStatus(modelId: string): {
|
||||
isDownloading: boolean;
|
||||
progress: DownloadProgress | null;
|
||||
perNode: NodeDownloadStatus[];
|
||||
failedError: string | null;
|
||||
perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}>;
|
||||
} {
|
||||
const empty = {
|
||||
isDownloading: false,
|
||||
progress: null,
|
||||
perNode: [] as NodeDownloadStatus[],
|
||||
failedError: null,
|
||||
};
|
||||
|
||||
if (!downloadsData || Object.keys(downloadsData).length === 0) {
|
||||
return empty;
|
||||
return { isDownloading: false, progress: null, perNode: [] };
|
||||
}
|
||||
|
||||
// Deduplicate by nodeId — a node can have multiple entries for the same model
|
||||
// (e.g. PipelineShardMetadata + TensorShardMetadata). Keep the last entry,
|
||||
// which is the most recently applied event.
|
||||
const perNodeMap = new Map<string, NodeDownloadStatus>();
|
||||
let totalBytes = 0;
|
||||
let downloadedBytes = 0;
|
||||
let totalSpeed = 0;
|
||||
let completedFiles = 0;
|
||||
let totalFiles = 0;
|
||||
let isDownloading = false;
|
||||
const allFiles: DownloadProgress["files"] = [];
|
||||
const perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}> = [];
|
||||
|
||||
const nodeIdSet = nodeIds ? new Set(nodeIds) : null;
|
||||
// Check all nodes for downloads matching this model
|
||||
for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
|
||||
if (nodeIdSet && !nodeIdSet.has(nodeId)) continue;
|
||||
if (!Array.isArray(nodeDownloads)) continue;
|
||||
|
||||
for (const downloadWrapped of nodeDownloads) {
|
||||
@@ -1586,45 +1581,29 @@
|
||||
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
|
||||
downloadKind
|
||||
] as Record<string, unknown>;
|
||||
if (!downloadPayload) continue;
|
||||
|
||||
const downloadModelId = extractModelIdFromDownload(downloadPayload);
|
||||
if (!downloadModelId || downloadModelId !== modelId) continue;
|
||||
|
||||
// DownloadFailed — return with any data collected so far
|
||||
if (downloadKind === "DownloadFailed") {
|
||||
return {
|
||||
isDownloading: false,
|
||||
progress: null,
|
||||
perNode: Array.from(perNodeMap.values()),
|
||||
failedError:
|
||||
(downloadPayload.errorMessage as string) ||
|
||||
(downloadPayload.error_message as string) ||
|
||||
"Download failed",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
downloadKind !== "DownloadOngoing" &&
|
||||
downloadKind !== "DownloadPending" &&
|
||||
downloadKind !== "DownloadCompleted"
|
||||
downloadKind !== "DownloadPending"
|
||||
)
|
||||
continue;
|
||||
if (!downloadPayload) continue;
|
||||
|
||||
const nodeName =
|
||||
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
|
||||
const downloadModelId = extractModelIdFromDownload(downloadPayload);
|
||||
|
||||
if (downloadKind === "DownloadCompleted") {
|
||||
perNodeMap.set(nodeId, {
|
||||
nodeId,
|
||||
nodeName,
|
||||
status: "completed",
|
||||
percentage: 100,
|
||||
progress: null,
|
||||
});
|
||||
continue;
|
||||
// Match if the model ID contains or equals the requested model
|
||||
// (handles cases like "mlx-community/Meta-Llama..." matching)
|
||||
if (
|
||||
!downloadModelId ||
|
||||
!downloadModelId.includes(modelId.split("/").pop() || modelId)
|
||||
) {
|
||||
// Try exact match or partial match
|
||||
if (downloadModelId !== modelId) continue;
|
||||
}
|
||||
|
||||
// For DownloadPending with partial bytes (paused/resumed downloads),
|
||||
// synthesize a progress object from the top-level downloaded/total fields
|
||||
let progress: DownloadProgress | null;
|
||||
if (downloadKind === "DownloadPending") {
|
||||
const pendingDownloaded = getBytes(
|
||||
downloadPayload.downloaded ??
|
||||
@@ -1637,67 +1616,44 @@
|
||||
downloadPayload.totalBytes,
|
||||
);
|
||||
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
|
||||
const pct =
|
||||
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0;
|
||||
perNodeMap.set(nodeId, {
|
||||
nodeId,
|
||||
nodeName,
|
||||
status: pendingDownloaded > 0 ? "partial" : "pending",
|
||||
percentage: pct,
|
||||
progress: null,
|
||||
});
|
||||
continue;
|
||||
isDownloading = true;
|
||||
progress = {
|
||||
totalBytes: pendingTotal,
|
||||
downloadedBytes: pendingDownloaded,
|
||||
speed: 0,
|
||||
etaMs: 0,
|
||||
percentage:
|
||||
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
files: [],
|
||||
};
|
||||
} else {
|
||||
isDownloading = true;
|
||||
progress = parseDownloadProgress(downloadPayload);
|
||||
}
|
||||
|
||||
// DownloadOngoing
|
||||
const progress = parseDownloadProgress(downloadPayload);
|
||||
if (
|
||||
!progress ||
|
||||
(progress.downloadedBytes <= 0 && progress.totalBytes <= 0)
|
||||
)
|
||||
continue;
|
||||
if (progress) {
|
||||
// Sum all values across nodes - each node downloads independently
|
||||
totalBytes += progress.totalBytes;
|
||||
downloadedBytes += progress.downloadedBytes;
|
||||
totalSpeed += progress.speed;
|
||||
completedFiles += progress.completedFiles;
|
||||
totalFiles += progress.totalFiles;
|
||||
allFiles.push(...progress.files);
|
||||
|
||||
perNodeMap.set(nodeId, {
|
||||
nodeId,
|
||||
nodeName,
|
||||
status: "downloading",
|
||||
percentage: progress.percentage,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate from deduplicated per-node entries
|
||||
const perNode = Array.from(perNodeMap.values());
|
||||
let totalBytes = 0;
|
||||
let downloadedBytes = 0;
|
||||
let totalSpeed = 0;
|
||||
let completedFiles = 0;
|
||||
let totalFiles = 0;
|
||||
let isDownloading = false;
|
||||
const allFiles: DownloadProgress["files"] = [];
|
||||
|
||||
for (const node of perNode) {
|
||||
if (node.status === "downloading" && node.progress) {
|
||||
isDownloading = true;
|
||||
totalBytes += node.progress.totalBytes;
|
||||
downloadedBytes += node.progress.downloadedBytes;
|
||||
totalSpeed += node.progress.speed;
|
||||
completedFiles += node.progress.completedFiles;
|
||||
totalFiles += node.progress.totalFiles;
|
||||
allFiles.push(...node.progress.files);
|
||||
const nodeName =
|
||||
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
|
||||
perNode.push({ nodeId, nodeName, progress });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDownloading) {
|
||||
return {
|
||||
isDownloading: false,
|
||||
progress: null,
|
||||
perNode,
|
||||
failedError: null,
|
||||
};
|
||||
return { isDownloading: false, progress: null, perNode: [] };
|
||||
}
|
||||
|
||||
// ETA = total remaining bytes / total speed across all nodes
|
||||
const remainingBytes = totalBytes - downloadedBytes;
|
||||
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
|
||||
|
||||
@@ -1714,21 +1670,9 @@
|
||||
files: allFiles,
|
||||
},
|
||||
perNode,
|
||||
failedError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getModelDownloadStatus(
|
||||
modelId: string,
|
||||
nodeIds?: string[],
|
||||
): {
|
||||
isDownloading: boolean;
|
||||
progress: DownloadProgress | null;
|
||||
perNode: NodeDownloadStatus[];
|
||||
} {
|
||||
return collectDownloadStatus(modelId, nodeIds);
|
||||
}
|
||||
|
||||
// Helper to get download status for an instance
|
||||
function getInstanceDownloadStatus(
|
||||
instanceId: string,
|
||||
@@ -1739,9 +1683,26 @@
|
||||
errorMessage: string | null;
|
||||
progress: DownloadProgress | null;
|
||||
statusText: string;
|
||||
perNode: NodeDownloadStatus[];
|
||||
perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}>;
|
||||
} {
|
||||
// Unwrap the instance to get shard assignments
|
||||
if (!downloadsData || Object.keys(downloadsData).length === 0) {
|
||||
// No download data yet — defer to runner status instead of assuming RUNNING
|
||||
const statusInfo = deriveInstanceStatus(instanceWrapped);
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: false,
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
statusText: statusInfo.statusText,
|
||||
perNode: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Unwrap the instance
|
||||
const [instanceTag, instance] = getTagged(instanceWrapped);
|
||||
if (!instance || typeof instance !== "object") {
|
||||
return {
|
||||
@@ -1761,45 +1722,132 @@
|
||||
modelId?: string;
|
||||
};
|
||||
};
|
||||
const instanceModelId = inst.shardAssignments?.modelId;
|
||||
|
||||
if (!instanceModelId) {
|
||||
const statusInfo = deriveInstanceStatus(instanceWrapped);
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: statusInfo.statusText === "FAILED",
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
statusText: statusInfo.statusText,
|
||||
perNode: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get node IDs assigned to this instance
|
||||
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
|
||||
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
|
||||
const instanceModelId = inst.shardAssignments?.modelId;
|
||||
|
||||
// Build reverse mapping: runnerId -> nodeId
|
||||
const runnerToNode: Record<string, string> = {};
|
||||
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
|
||||
runnerToNode[runnerId] = nodeId;
|
||||
}
|
||||
const instanceNodeIds = Object.keys(runnerToShard)
|
||||
.map((runnerId) => runnerToNode[runnerId])
|
||||
.filter(Boolean);
|
||||
|
||||
const result = collectDownloadStatus(instanceModelId, instanceNodeIds);
|
||||
let totalBytes = 0;
|
||||
let downloadedBytes = 0;
|
||||
let totalSpeed = 0;
|
||||
let completedFiles = 0;
|
||||
let totalFiles = 0;
|
||||
let isDownloading = false;
|
||||
const allFiles: DownloadProgress["files"] = [];
|
||||
const perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}> = [];
|
||||
|
||||
if (result.failedError) {
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: true,
|
||||
errorMessage: result.failedError,
|
||||
progress: null,
|
||||
statusText: "FAILED",
|
||||
perNode: [],
|
||||
};
|
||||
// Check downloads for nodes that are part of this instance
|
||||
for (const runnerId of Object.keys(runnerToShard)) {
|
||||
const nodeId = runnerToNode[runnerId];
|
||||
if (!nodeId) continue;
|
||||
|
||||
const nodeDownloads = downloadsData[nodeId];
|
||||
if (!Array.isArray(nodeDownloads)) continue;
|
||||
|
||||
for (const downloadWrapped of nodeDownloads) {
|
||||
if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
|
||||
|
||||
const keys = Object.keys(downloadWrapped as Record<string, unknown>);
|
||||
if (keys.length !== 1) continue;
|
||||
|
||||
const downloadKind = keys[0];
|
||||
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
|
||||
downloadKind
|
||||
] as Record<string, unknown>;
|
||||
|
||||
// Handle DownloadFailed - return immediately with error info
|
||||
if (downloadKind === "DownloadFailed") {
|
||||
const downloadModelId = extractModelIdFromDownload(downloadPayload);
|
||||
if (
|
||||
instanceModelId &&
|
||||
downloadModelId &&
|
||||
downloadModelId === instanceModelId
|
||||
) {
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: true,
|
||||
errorMessage:
|
||||
(downloadPayload.errorMessage as string) || "Download failed",
|
||||
progress: null,
|
||||
statusText: "FAILED",
|
||||
perNode: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
downloadKind !== "DownloadOngoing" &&
|
||||
downloadKind !== "DownloadPending"
|
||||
)
|
||||
continue;
|
||||
if (!downloadPayload) continue;
|
||||
|
||||
// Check if this download is for this instance's model
|
||||
const downloadModelId = extractModelIdFromDownload(downloadPayload);
|
||||
if (
|
||||
instanceModelId &&
|
||||
downloadModelId &&
|
||||
downloadModelId === instanceModelId
|
||||
) {
|
||||
// For DownloadPending with partial bytes, synthesize progress
|
||||
let progress: DownloadProgress | null;
|
||||
if (downloadKind === "DownloadPending") {
|
||||
const pendingDownloaded = getBytes(
|
||||
downloadPayload.downloaded ??
|
||||
downloadPayload.downloaded_bytes ??
|
||||
downloadPayload.downloadedBytes,
|
||||
);
|
||||
const pendingTotal = getBytes(
|
||||
downloadPayload.total ??
|
||||
downloadPayload.total_bytes ??
|
||||
downloadPayload.totalBytes,
|
||||
);
|
||||
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
|
||||
isDownloading = true;
|
||||
progress = {
|
||||
totalBytes: pendingTotal,
|
||||
downloadedBytes: pendingDownloaded,
|
||||
speed: 0,
|
||||
etaMs: 0,
|
||||
percentage:
|
||||
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
files: [],
|
||||
};
|
||||
} else {
|
||||
isDownloading = true;
|
||||
progress = parseDownloadProgress(downloadPayload);
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
// Sum all values across nodes - each node downloads independently
|
||||
totalBytes += progress.totalBytes;
|
||||
downloadedBytes += progress.downloadedBytes;
|
||||
totalSpeed += progress.speed;
|
||||
completedFiles += progress.completedFiles;
|
||||
totalFiles += progress.totalFiles;
|
||||
allFiles.push(...progress.files);
|
||||
|
||||
const nodeName =
|
||||
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
|
||||
perNode.push({ nodeId, nodeName, progress });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.isDownloading) {
|
||||
if (!isDownloading) {
|
||||
// Check runner status for other states
|
||||
const statusInfo = deriveInstanceStatus(instanceWrapped);
|
||||
return {
|
||||
isDownloading: false,
|
||||
@@ -1807,17 +1855,30 @@
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
statusText: statusInfo.statusText,
|
||||
perNode: result.perNode,
|
||||
perNode: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ETA = total remaining bytes / total speed across all nodes
|
||||
const remainingBytes = totalBytes - downloadedBytes;
|
||||
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
|
||||
|
||||
return {
|
||||
isDownloading: true,
|
||||
isFailed: false,
|
||||
errorMessage: null,
|
||||
progress: result.progress,
|
||||
progress: {
|
||||
totalBytes,
|
||||
downloadedBytes,
|
||||
speed: totalSpeed,
|
||||
etaMs,
|
||||
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
|
||||
completedFiles,
|
||||
totalFiles,
|
||||
files: allFiles,
|
||||
},
|
||||
statusText: "DOWNLOADING",
|
||||
perNode: result.perNode,
|
||||
perNode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2036,6 +2097,7 @@
|
||||
let instanceType = "Unknown";
|
||||
if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
|
||||
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
|
||||
else if (instanceTag === "VllmInstance") instanceType = "vLLM (CUDA)";
|
||||
|
||||
const inst = instance as {
|
||||
shardAssignments?: {
|
||||
@@ -4575,7 +4637,7 @@
|
||||
type="button"
|
||||
onclick={() => {
|
||||
completeOnboarding();
|
||||
sendMessage(chip, undefined, thinkingEnabled());
|
||||
sendMessage(chip);
|
||||
}}
|
||||
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
@@ -5314,10 +5376,10 @@
|
||||
<div
|
||||
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
|
||||
>
|
||||
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
|
||||
{#each downloadInfo.perNode as nodeProg}
|
||||
{@const nodePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, nodeProg.percentage),
|
||||
Math.max(0, nodeProg.progress.percentage),
|
||||
)}
|
||||
{@const isExpanded =
|
||||
instanceDownloadExpandedNodes.has(
|
||||
@@ -5373,17 +5435,15 @@
|
||||
>
|
||||
<span
|
||||
>{formatBytes(
|
||||
nodeProg.progress?.downloadedBytes ??
|
||||
0,
|
||||
nodeProg.progress.downloadedBytes,
|
||||
)} / {formatBytes(
|
||||
nodeProg.progress?.totalBytes ?? 0,
|
||||
nodeProg.progress.totalBytes,
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
>{formatSpeed(
|
||||
nodeProg.progress?.speed ?? 0,
|
||||
)} • ETA {formatEta(
|
||||
nodeProg.progress?.etaMs ?? 0,
|
||||
>{formatSpeed(nodeProg.progress.speed)} •
|
||||
ETA {formatEta(
|
||||
nodeProg.progress.etaMs,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
@@ -5391,14 +5451,14 @@
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="mt-2 space-y-1.5">
|
||||
{#if nodeProg.progress?.files ?? [].length === 0}
|
||||
{#if nodeProg.progress.files.length === 0}
|
||||
<div
|
||||
class="text-[11px] font-mono text-exo-light-gray/70"
|
||||
>
|
||||
No file details reported.
|
||||
</div>
|
||||
{:else}
|
||||
{#each nodeProg.progress?.files ?? [] as f}
|
||||
{#each nodeProg.progress.files as f}
|
||||
{@const filePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, f.percentage ?? 0),
|
||||
@@ -5787,6 +5847,32 @@
|
||||
</span>
|
||||
RDMA (Fast)
|
||||
</button>
|
||||
{#if vllmAvailable()}
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedInstanceType = "Vllm";
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -5874,15 +5960,12 @@
|
||||
)}
|
||||
{@const allPreviews = filteredPreviews()}
|
||||
{#if selectedModel && allPreviews.length > 0}
|
||||
{@const downloadStatus = getModelDownloadStatus(
|
||||
selectedModel.id,
|
||||
)}
|
||||
{@const tags = modelTags()[selectedModel.id] || []}
|
||||
<div class="space-y-3">
|
||||
{#each allPreviews as apiPreview, i}
|
||||
{@const downloadStatus = getModelDownloadStatus(
|
||||
selectedModel.id,
|
||||
apiPreview.memory_delta_by_node
|
||||
? Object.keys(apiPreview.memory_delta_by_node)
|
||||
: undefined,
|
||||
)}
|
||||
<div
|
||||
role="group"
|
||||
onmouseenter={() => {
|
||||
@@ -6070,7 +6153,7 @@
|
||||
onclick={() => {
|
||||
chatLaunchState = "idle";
|
||||
selectedChatCategory = null;
|
||||
sendMessage(prompt, undefined, thinkingEnabled());
|
||||
sendMessage(prompt);
|
||||
}}
|
||||
class="text-left px-3 py-2.5 text-xs text-exo-light-gray hover:text-white font-mono rounded-lg border border-exo-medium-gray/30 hover:border-exo-yellow/30 bg-exo-dark-gray/30 hover:bg-exo-dark-gray/60 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
@@ -6453,10 +6536,10 @@
|
||||
<div
|
||||
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
|
||||
>
|
||||
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
|
||||
{#each downloadInfo.perNode as nodeProg}
|
||||
{@const nodePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, nodeProg.percentage),
|
||||
Math.max(0, nodeProg.progress.percentage),
|
||||
)}
|
||||
{@const isExpanded =
|
||||
instanceDownloadExpandedNodes.has(
|
||||
@@ -6515,17 +6598,16 @@
|
||||
>
|
||||
<span
|
||||
>{formatBytes(
|
||||
nodeProg.progress
|
||||
?.downloadedBytes ?? 0,
|
||||
nodeProg.progress.downloadedBytes,
|
||||
)} / {formatBytes(
|
||||
nodeProg.progress?.totalBytes ?? 0,
|
||||
nodeProg.progress.totalBytes,
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
>{formatSpeed(
|
||||
nodeProg.progress?.speed ?? 0,
|
||||
nodeProg.progress.speed,
|
||||
)} • ETA {formatEta(
|
||||
nodeProg.progress?.etaMs ?? 0,
|
||||
nodeProg.progress.etaMs,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
@@ -6533,14 +6615,14 @@
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="mt-2 space-y-1.5">
|
||||
{#if nodeProg.progress?.files ?? [].length === 0}
|
||||
{#if nodeProg.progress.files.length === 0}
|
||||
<div
|
||||
class="text-[11px] font-mono text-exo-light-gray/70"
|
||||
>
|
||||
No file details reported.
|
||||
</div>
|
||||
{:else}
|
||||
{#each nodeProg.progress?.files ?? [] as f}
|
||||
{#each nodeProg.progress.files as f}
|
||||
{@const filePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, f.percentage ?? 0),
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
};
|
||||
|
||||
nixConfig = {
|
||||
extra-trusted-public-keys = "exo.cachix.org-1:okq7hl624TBeAR3kV+g39dUFSiaZgLRkLsFBCuJ2NZI=";
|
||||
extra-substituters = "https://exo.cachix.org";
|
||||
extra-trusted-public-keys = "exo.cachix.org-1:okq7hl624TBeAR3kV+g39dUFSiaZgLRkLsFBCuJ2NZI= cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M=";
|
||||
extra-substituters = "https://exo.cachix.org https://cache.nixos-cuda.org";
|
||||
};
|
||||
|
||||
outputs =
|
||||
@@ -72,10 +72,12 @@
|
||||
];
|
||||
|
||||
perSystem =
|
||||
{ config, self', pkgs, lib, system, ... }:
|
||||
{ config, self', inputs', pkgs, lib, system, ... }:
|
||||
let
|
||||
# Use pinned nixpkgs for swift-format (swift is broken on x86_64-linux in newer nixpkgs)
|
||||
pkgsSwift = import inputs.nixpkgs-swift { inherit system; };
|
||||
|
||||
pkgsCuda = import ./nix/cuda-pkgs.nix { nixpkgs = inputs.nixpkgs; inherit system; };
|
||||
in
|
||||
{
|
||||
# Allow unfree for metal-toolchain (needed for Darwin Metal packages)
|
||||
@@ -84,17 +86,6 @@
|
||||
config.allowUnfreePredicate = pkg: (pkg.pname or "") == "metal-toolchain";
|
||||
overlays = [
|
||||
(import ./nix/apple-sdk-overlay.nix)
|
||||
(final: prev: {
|
||||
macmon = prev.macmon.overrideAttrs (_: {
|
||||
version = "git";
|
||||
src = final.fetchFromGitHub {
|
||||
owner = "swiftraccoon";
|
||||
repo = "macmon";
|
||||
rev = "9154d234f763fbeffdcb4135d0bbbaf80609699b";
|
||||
hash = "sha256-CwhilKNbs5XL9/tF5DMwyPBlE/hpmjGNTuxQ36sM50M=";
|
||||
};
|
||||
});
|
||||
})
|
||||
];
|
||||
};
|
||||
treefmt = {
|
||||
@@ -123,66 +114,137 @@
|
||||
};
|
||||
};
|
||||
|
||||
packages = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin (
|
||||
let
|
||||
uvLock = builtins.fromTOML (builtins.readFile ./uv.lock);
|
||||
mlxPackage = builtins.head (builtins.filter (p: p.name == "mlx" && p.source ? git) uvLock.package);
|
||||
uvLockMlxVersion = mlxPackage.version;
|
||||
uvLockMlxRev = builtins.elemAt (builtins.split "#" mlxPackage.source.git) 2;
|
||||
in
|
||||
{
|
||||
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
|
||||
mlx = pkgs.callPackage ./nix/mlx.nix {
|
||||
inherit (self'.packages) metal-toolchain;
|
||||
inherit uvLockMlxVersion uvLockMlxRev;
|
||||
};
|
||||
default = self'.packages.exo;
|
||||
}
|
||||
);
|
||||
packages = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin
|
||||
(
|
||||
let
|
||||
uvLock = builtins.fromTOML (builtins.readFile ./uv.lock);
|
||||
mlxPackage = builtins.head (builtins.filter (p: p.name == "mlx" && p.source ? git) uvLock.package);
|
||||
uvLockMlxVersion = mlxPackage.version;
|
||||
uvLockMlxRev = builtins.elemAt (builtins.split "#" mlxPackage.source.git) 2;
|
||||
in
|
||||
{
|
||||
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
|
||||
mlx = pkgs.callPackage ./nix/mlx.nix {
|
||||
inherit (self'.packages) metal-toolchain;
|
||||
inherit uvLockMlxVersion uvLockMlxRev;
|
||||
};
|
||||
default = self'.packages.exo;
|
||||
}
|
||||
) // lib.optionalAttrs (pkgsCuda != null) {
|
||||
torch-cuda = pkgsCuda.python313Packages.torch;
|
||||
vllm-cuda = pkgsCuda.python313Packages.vllm;
|
||||
|
||||
devShells.default = with pkgs; pkgs.mkShell {
|
||||
inputsFrom = [ self'.checks.cargo-build ];
|
||||
|
||||
packages =
|
||||
[
|
||||
# FORMATTING
|
||||
config.treefmt.build.wrapper
|
||||
|
||||
# PYTHON
|
||||
python313
|
||||
uv
|
||||
ruff
|
||||
basedpyright
|
||||
|
||||
# RUST
|
||||
config.rust.toolchain
|
||||
maturin
|
||||
|
||||
# NIX
|
||||
nixpkgs-fmt
|
||||
|
||||
# SVELTE
|
||||
nodejs
|
||||
|
||||
# MISC
|
||||
just
|
||||
jq
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
unixtools.ifconfig
|
||||
]
|
||||
++ lib.optionals stdenv.isDarwin [
|
||||
macmon
|
||||
# Smoke test script for verifying vLLM + CUDA GPU setup
|
||||
vllm-check = pkgs.writeShellApplication {
|
||||
name = "vllm-check";
|
||||
runtimeInputs = [
|
||||
(pkgsCuda.python313.withPackages (ps: [ ps.torch ps.vllm ]))
|
||||
];
|
||||
# On non-NixOS hosts, NVIDIA driver libraries live in /usr/lib and must be
|
||||
# LD_PRELOAD'd individually (adding the whole dir causes SIGILL from conflicts).
|
||||
# These are: CUDA driver, NVML, and the PTX JIT compiler (for flash attention).
|
||||
# libnvJitLink comes from the nix CUDA toolkit via LD_LIBRARY_PATH.
|
||||
text = ''
|
||||
for dir in /usr/lib/aarch64-linux-gnu /usr/lib/x86_64-linux-gnu /usr/lib; do
|
||||
if [ -e "$dir/libcuda.so.1" ]; then
|
||||
NVIDIA_LIBS="$dir/libcuda.so.1"
|
||||
for lib in libnvidia-ml.so.1 libnvidia-ptxjitcompiler.so.1; do
|
||||
[ -e "$dir/$lib" ] && NVIDIA_LIBS="$NVIDIA_LIBS:$dir/$lib"
|
||||
done
|
||||
export LD_PRELOAD="$NVIDIA_LIBS''${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
export LD_LIBRARY_PATH="${pkgsCuda.cudaPackages.libnvjitlink}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
exec python ${inputs.self + /tests/test_vllm_smoke.py}
|
||||
'';
|
||||
};
|
||||
|
||||
OPENSSL_NO_VENDOR = "1";
|
||||
# exo with CUDA torch + vLLM — wraps the uv2nix-built package with host driver libs
|
||||
exo-cuda = pkgs.writeShellApplication {
|
||||
name = "exo-cuda";
|
||||
runtimeInputs = [ self'.packages.exo-cuda-unwrapped ];
|
||||
text = ''
|
||||
for dir in /usr/lib/aarch64-linux-gnu /usr/lib/x86_64-linux-gnu /usr/lib; do
|
||||
if [ -e "$dir/libcuda.so.1" ]; then
|
||||
NVIDIA_LIBS="$dir/libcuda.so.1"
|
||||
for lib in libnvidia-ml.so.1 libnvidia-ptxjitcompiler.so.1; do
|
||||
[ -e "$dir/$lib" ] && NVIDIA_LIBS="$NVIDIA_LIBS:$dir/$lib"
|
||||
done
|
||||
export LD_PRELOAD="$NVIDIA_LIBS''${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
export LD_LIBRARY_PATH="${pkgsCuda.stdenv.cc.cc.lib}/lib:${pkgsCuda.cudaPackages.libnvjitlink}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
exec exo-cuda "$@"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${python313}/lib"
|
||||
${lib.optionalString stdenv.isLinux ''
|
||||
export LD_LIBRARY_PATH="${openssl.out}/lib:$LD_LIBRARY_PATH"
|
||||
''}
|
||||
'';
|
||||
# CUDA development shell with torch + vLLM (aarch64-linux only)
|
||||
devShells = lib.optionalAttrs (pkgsCuda != null)
|
||||
{
|
||||
cuda = pkgs.mkShell {
|
||||
packages = [
|
||||
(pkgsCuda.python313.withPackages (ps: [
|
||||
ps.torch
|
||||
ps.vllm
|
||||
]))
|
||||
pkgs.uv
|
||||
pkgs.just
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "CUDA dev shell with torch + vLLM"
|
||||
python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}')" 2>/dev/null || true
|
||||
'';
|
||||
};
|
||||
} // {
|
||||
|
||||
default = with pkgs; pkgs.mkShell {
|
||||
inputsFrom = [ self'.checks.cargo-build ];
|
||||
|
||||
packages =
|
||||
[
|
||||
# FORMATTING
|
||||
config.treefmt.build.wrapper
|
||||
|
||||
# PYTHON
|
||||
python313
|
||||
uv
|
||||
ruff
|
||||
basedpyright
|
||||
|
||||
# RUST
|
||||
config.rust.toolchain
|
||||
maturin
|
||||
|
||||
# NIX
|
||||
nixpkgs-fmt
|
||||
|
||||
# SVELTE
|
||||
nodejs
|
||||
|
||||
# MISC
|
||||
just
|
||||
jq
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
unixtools.ifconfig
|
||||
]
|
||||
++ lib.optionals stdenv.isDarwin [
|
||||
macmon
|
||||
];
|
||||
|
||||
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"
|
||||
''}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export NIX_CONFIG := "extra-experimental-features = nix-command flakes"
|
||||
|
||||
default: lint fmt
|
||||
all: lint fmt check
|
||||
|
||||
fmt:
|
||||
treefmt || nix fmt
|
||||
|
||||
@@ -18,6 +15,39 @@ check:
|
||||
sync:
|
||||
uv sync --all-packages
|
||||
|
||||
sync-cuda:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if command -v nvidia-smi &>/dev/null; then
|
||||
arch=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader | head -1 | tr -d ' ')
|
||||
export TORCH_CUDA_ARCH_LIST="$arch"
|
||||
export FLASHINFER_CUDA_ARCH_LIST="${arch}a"
|
||||
export VLLM_TARGET_DEVICE=cuda
|
||||
fi
|
||||
uv pip install "cmake>=3.26.1" ninja "packaging>=24.2" "setuptools>=77.0.3,<81.0.0" "setuptools-scm>=8.0" wheel jinja2
|
||||
find ~/.cache/uv/git-v0 -name CMakeCache.txt -delete 2>/dev/null || true
|
||||
uv sync --extra cuda
|
||||
|
||||
build-flashinfer-sm121:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
FLASHINFER_VERSION="v0.6.6"
|
||||
BUILD_DIR="/tmp/flashinfer-build"
|
||||
EXO_VENV="$(pwd)/.venv"
|
||||
rm -rf "$BUILD_DIR"
|
||||
git clone --depth 1 --branch "$FLASHINFER_VERSION" --recurse-submodules --shallow-submodules https://github.com/flashinfer-ai/flashinfer.git "$BUILD_DIR"
|
||||
cd "$BUILD_DIR"
|
||||
export FLASHINFER_CUDA_ARCH_LIST="12.1a"
|
||||
export TORCH_CUDA_ARCH_LIST="12.1"
|
||||
export FLASHINFER_ENABLE_AOT=1
|
||||
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
||||
VIRTUAL_ENV="$EXO_VENV" "$UV" pip install . --no-build-isolation
|
||||
echo "Building AOT cubin wheel..."
|
||||
cd flashinfer-cubin && VIRTUAL_ENV="$EXO_VENV" "$UV" pip install . --no-build-isolation && cd ..
|
||||
echo "Building JIT cache wheel..."
|
||||
cd flashinfer-jit-cache && VIRTUAL_ENV="$EXO_VENV" "$UV" pip install . --no-build-isolation && cd ..
|
||||
echo "FlashInfer built from source with SM121 AOT kernels"
|
||||
|
||||
sync-clean:
|
||||
uv sync --all-packages --force-reinstall --no-cache
|
||||
|
||||
@@ -34,10 +64,6 @@ build-dashboard:
|
||||
package:
|
||||
uv run pyinstaller packaging/pyinstaller/exo.spec
|
||||
|
||||
build-app: package
|
||||
xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
|
||||
@echo "\nBuild complete. Run with:\n open {{justfile_directory()}}/app/EXO/build/Build/Products/Debug/EXO.app"
|
||||
|
||||
clean:
|
||||
rm -rf **/__pycache__
|
||||
rm -rf target/
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
{ nixpkgs, system }:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
if system == "aarch64-linux" then
|
||||
import nixpkgs
|
||||
{
|
||||
inherit system;
|
||||
config = {
|
||||
allowUnfree = true;
|
||||
allowBroken = true;
|
||||
allowUnsupportedSystem = true;
|
||||
cudaSupport = true;
|
||||
cudaCapabilities = [ "12.1" ];
|
||||
};
|
||||
overlays = [
|
||||
(final: prev:
|
||||
let
|
||||
cudaCompatStub = cfinal: cprev: {
|
||||
cuda_compat = prev.runCommand "cuda13.0-cuda_compat-stub" { } "mkdir -p $out";
|
||||
};
|
||||
in
|
||||
{
|
||||
cudaPackages = prev.cudaPackages_13.overrideScope cudaCompatStub // {
|
||||
override = args:
|
||||
(prev.cudaPackages_13.override args).overrideScope cudaCompatStub;
|
||||
};
|
||||
|
||||
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
|
||||
(pyFinal: pyPrev: {
|
||||
fastsafetensors = pyFinal.buildPythonPackage {
|
||||
pname = "fastsafetensors";
|
||||
version = "0.2.2";
|
||||
src = prev.fetchFromGitHub {
|
||||
owner = "foundation-model-stack";
|
||||
repo = "fastsafetensors";
|
||||
rev = "v0.2.2";
|
||||
hash = "";
|
||||
};
|
||||
pyproject = true;
|
||||
build-system = [
|
||||
pyFinal.setuptools
|
||||
pyFinal.pybind11
|
||||
];
|
||||
buildInputs = [
|
||||
final.cudaPackages.cuda_cudart
|
||||
final.cudaPackages.cuda_nvml_dev
|
||||
];
|
||||
nativeBuildInputs = [
|
||||
final.cudaPackages.cuda_nvcc
|
||||
];
|
||||
dependencies = [
|
||||
pyFinal.typer
|
||||
];
|
||||
env.CUDA_HOME = "${final.cudaPackages.cuda_nvcc}";
|
||||
pythonImportsCheck = [ "fastsafetensors" ];
|
||||
};
|
||||
cupy = pyPrev.cupy.override {
|
||||
cudaPackages = final.cudaPackages;
|
||||
};
|
||||
|
||||
bitsandbytes = pyPrev.bitsandbytes.overrideAttrs (old: {
|
||||
preConfigure = (old.preConfigure or "") + ''
|
||||
export CXXFLAGS="''${CXXFLAGS:-} -I${final.cudaPackages.cuda_crt}/include"
|
||||
export CUDAFLAGS="''${CUDAFLAGS:-} -I${final.cudaPackages.cuda_crt}/include"
|
||||
export CMAKE_CUDA_FLAGS="''${CMAKE_CUDA_FLAGS:-} -I${final.cudaPackages.cuda_crt}/include"
|
||||
'';
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [ final.cudaPackages.cuda_crt ];
|
||||
});
|
||||
|
||||
vllm = pyPrev.vllm.overrideAttrs (old: {
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [
|
||||
final.cuda_cccl_with_prefix
|
||||
final.cudaPackages.cuda_crt
|
||||
];
|
||||
preConfigure = (old.preConfigure or "") + ''
|
||||
export CXXFLAGS="''${CXXFLAGS:-} -I${final.cuda_cccl_with_prefix}/include -I${final.cudaPackages.cuda_crt}/include"
|
||||
export CUDAFLAGS="''${CUDAFLAGS:-} -I${final.cuda_cccl_with_prefix}/include -I${final.cudaPackages.cuda_crt}/include"
|
||||
'';
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
magma-cuda-static = prev.magma-cuda-static.overrideAttrs (old: {
|
||||
postPatch = (old.postPatch or "") + ''
|
||||
sed -i '/err = cudaGetDeviceProperties( &prop, dev );/a\ int clock_khz = 0; cudaDeviceGetAttribute(\&clock_khz, cudaDevAttrClockRate, dev);' interface_cuda/interface.cpp
|
||||
sed -i 's/prop\.clockRate/clock_khz/g' interface_cuda/interface.cpp
|
||||
'';
|
||||
});
|
||||
|
||||
cuda_cccl_with_prefix = prev.runCommand "cuda13.0-cuda_cccl-with-cccl-prefix" { } ''
|
||||
mkdir -p $out/include
|
||||
ln -s ${final.cudaPackages.cuda_cccl}/include $out/include/cccl
|
||||
'';
|
||||
|
||||
opencv = prev.opencv.override { enableCuda = false; };
|
||||
opencv4 = prev.opencv4.override { enableCuda = false; };
|
||||
})
|
||||
];
|
||||
}
|
||||
else
|
||||
null
|
||||
@@ -71,9 +71,7 @@ MACMON_PATH = shutil.which("macmon")
|
||||
if MACMON_PATH is None:
|
||||
raise SystemExit(
|
||||
"macmon binary not found in PATH. "
|
||||
"Install the pinned fork used by exo via: "
|
||||
"cargo install --git https://github.com/swiftraccoon/macmon "
|
||||
"--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b macmon --force"
|
||||
"Install it via: brew install macmon"
|
||||
)
|
||||
|
||||
BINARIES: list[tuple[str, str]] = [
|
||||
@@ -122,3 +120,4 @@ coll = COLLECT(
|
||||
upx_exclude=[],
|
||||
name="exo",
|
||||
)
|
||||
|
||||
|
||||
+43
-10
@@ -15,17 +15,17 @@ dependencies = [
|
||||
"huggingface-hub>=0.33.4",
|
||||
"psutil>=7.0.0",
|
||||
"loguru>=0.7.3",
|
||||
"exo_pyo3_bindings", # rust bindings
|
||||
"exo_pyo3_bindings", # rust bindings
|
||||
"anyio==4.11.0",
|
||||
"mlx; sys_platform == 'darwin'",
|
||||
"mlx[cpu]==0.30.6; sys_platform == 'linux'",
|
||||
"mlx==0.30.6; sys_platform == 'linux'",
|
||||
"mlx-lm",
|
||||
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
|
||||
"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",
|
||||
"mflux==0.16.9; sys_platform == 'darwin'",
|
||||
"python-multipart>=0.0.21",
|
||||
"msgspec>=0.19.0",
|
||||
"zstandard>=0.23.0",
|
||||
@@ -45,11 +45,13 @@ dev = [
|
||||
"ruff>=0.11.13",
|
||||
]
|
||||
|
||||
# mlx[cuda] requires a newer version of mlx. the ideal on linux is: default to mlx[cpu] unless[cuda] specified.
|
||||
[project.optional-dependencies]
|
||||
# cuda = [
|
||||
# "mlx[cuda]==0.26.3",
|
||||
# ]
|
||||
cuda = [
|
||||
"torch>=2.10.0; sys_platform == 'linux'",
|
||||
"vllm>=0.13.0; sys_platform == 'linux'",
|
||||
"mlx-cuda-13==0.30.6; sys_platform == 'linux'",
|
||||
"fastsafetensors>=0.1.10; sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
###
|
||||
# workspace configuration
|
||||
@@ -61,11 +63,18 @@ 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/fix-deepseek-v32-indexer" }
|
||||
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/eval-left-padding-in-batched-rotation" }
|
||||
torch = [{ index = "pytorch-cu130", marker = "sys_platform == 'linux'" }]
|
||||
vllm = { git = "https://github.com/hmellor/vllm.git", branch = "transformers-v5" }
|
||||
# Uncomment to use local mlx/mlx-lm development versions:
|
||||
# mlx = { path = "/Users/Shared/mlx", editable=true }
|
||||
# mlx-lm = { path = "/Users/Shared/mlx-lm", editable=true }
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cu130"
|
||||
url = "https://download.pytorch.org/whl/cu130"
|
||||
explicit = true
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.9,<0.9.0"]
|
||||
build-backend = "uv_build"
|
||||
@@ -99,8 +108,16 @@ exclude = [
|
||||
"**/.direnv",
|
||||
"**/rust",
|
||||
"**/.github",
|
||||
"**/vllm_patches",
|
||||
"**/engines/vllm",
|
||||
]
|
||||
stubPath = ".mlx_typings"
|
||||
extraPaths = [".cuda_typings"]
|
||||
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "src/exo/worker/runner/vllm_inference"
|
||||
extraPaths = ["src", ".cuda_typings"]
|
||||
reportMissingModuleSource = false
|
||||
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "src"
|
||||
@@ -113,7 +130,22 @@ root = "src"
|
||||
[tool.uv]
|
||||
required-version = ">=0.8.6"
|
||||
prerelease = "allow"
|
||||
environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux' and platform_machine == 'aarch64'",
|
||||
]
|
||||
no-binary-package = ["vllm", "flashinfer-python"]
|
||||
no-build-isolation-package = ["vllm", "flashinfer-python"]
|
||||
extra-build-dependencies = { vllm = [
|
||||
"cmake>=3.26.1",
|
||||
"ninja",
|
||||
"packaging>=24.2",
|
||||
"setuptools>=77.0.3,<81.0.0",
|
||||
"setuptools-scm>=8.0",
|
||||
"wheel",
|
||||
"jinja2",
|
||||
] }
|
||||
conflicts = [[{ package = "exo", extra = "cuda" }, { package = "exo-bench" }]]
|
||||
|
||||
###
|
||||
# ruff configuration
|
||||
@@ -123,6 +155,7 @@ environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
|
||||
extend-exclude = [
|
||||
"shared/protobufs/**",
|
||||
"*mlx_typings/**",
|
||||
"*cuda_typings/**",
|
||||
"rust/exo_pyo3_bindings/**",
|
||||
"bench/vendor/**",
|
||||
]
|
||||
|
||||
+46
-8
@@ -3,6 +3,7 @@
|
||||
perSystem =
|
||||
{ config, self', pkgs, lib, system, ... }:
|
||||
let
|
||||
pkgsCuda = import ../nix/cuda-pkgs.nix { nixpkgs = inputs.nixpkgs; inherit system; };
|
||||
# Load workspace from uv.lock
|
||||
workspace = inputs.uv2nix.lib.workspace.loadWorkspace {
|
||||
workspaceRoot = inputs.self;
|
||||
@@ -99,16 +100,18 @@
|
||||
}
|
||||
);
|
||||
|
||||
baseOverlays = [
|
||||
inputs.pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
exoOverlay
|
||||
buildSystemsOverlay
|
||||
linuxOverlay
|
||||
];
|
||||
|
||||
pythonSet = (pkgs.callPackage inputs.pyproject-nix.build.packages {
|
||||
inherit python;
|
||||
}).overrideScope (
|
||||
lib.composeManyExtensions [
|
||||
inputs.pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
exoOverlay
|
||||
buildSystemsOverlay
|
||||
linuxOverlay
|
||||
]
|
||||
lib.composeManyExtensions baseOverlays
|
||||
);
|
||||
# mlx-cpu and mlx-cuda-13 both ship mlx/ site-packages files; keep first.
|
||||
# mlx-cpu/mlx-cuda-13 and nvidia-cudnn-cu12/cu13 ship overlapping files.
|
||||
@@ -172,6 +175,39 @@
|
||||
--set EXO_RESOURCES_DIR ${inputs.self + /resources} \
|
||||
${lib.optionalString pkgs.stdenv.hostPlatform.isDarwin "--prefix PATH : ${pkgs.macmon}/bin"}
|
||||
'';
|
||||
|
||||
vllmEnv = pkgsCuda.python313.withPackages (ps: [ ps.vllm ps.fastsafetensors ]);
|
||||
|
||||
vllmSite = pkgs.runCommand "vllm-site-filtered" { } ''
|
||||
mkdir -p $out
|
||||
for pkg in ${vllmEnv}/${python.sitePackages}/*; do
|
||||
name=$(basename "$pkg")
|
||||
case "$name" in
|
||||
anyio*|pydantic*) ;;
|
||||
*) ln -s "$pkg" "$out/$name" ;;
|
||||
esac
|
||||
done
|
||||
'';
|
||||
|
||||
exoCudaDeps = exoDeps // {
|
||||
mlx-cuda-13 = [ ];
|
||||
};
|
||||
|
||||
exoCudaVenv = (pythonSet.mkVirtualEnv "exo-cuda-env" exoCudaDeps).overrideAttrs {
|
||||
venvIgnoreCollisions = venvCollisionPaths;
|
||||
};
|
||||
|
||||
exoCudaPackage = pkgs.runCommand "exo-cuda"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
}
|
||||
''
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${exoCudaVenv}/bin/exo $out/bin/exo-cuda \
|
||||
--set EXO_DASHBOARD_DIR ${self'.packages.dashboard} \
|
||||
--set EXO_RESOURCES_DIR ${inputs.self + /resources} \
|
||||
--prefix PYTHONPATH : "${vllmSite}"
|
||||
'';
|
||||
in
|
||||
{
|
||||
# Python package only available on macOS (requires MLX/Metal)
|
||||
@@ -180,7 +216,9 @@
|
||||
exo = exoPackage;
|
||||
# Test environment for running pytest outside of Nix sandbox (needs GPU access)
|
||||
exo-test-env = testVenv;
|
||||
} // {
|
||||
} // lib.optionalAttrs (pkgsCuda != null) {
|
||||
exo-cuda-unwrapped = exoCudaPackage;
|
||||
} // {
|
||||
exo-bench = mkBenchScript "exo-bench" (inputs.self + /bench/exo_bench.py);
|
||||
exo-eval = mkBenchScript "exo-eval" (inputs.self + /bench/exo_eval.py);
|
||||
exo-eval-tool-calls = mkBenchScript "exo-eval-tool-calls" (inputs.self + /bench/eval_tool_calls.py);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
model_id = "mlx-community/DeepSeek-V3.2-4bit"
|
||||
n_layers = 61
|
||||
hidden_size = 7168
|
||||
num_key_value_heads = 128
|
||||
supports_tensor = true
|
||||
tasks = ["TextGeneration"]
|
||||
family = "deepseek"
|
||||
quantization = "4bit"
|
||||
base_model = "DeepSeek V3.2"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 378086226621
|
||||
@@ -1,13 +0,0 @@
|
||||
model_id = "mlx-community/DeepSeek-V3.2-8bit"
|
||||
n_layers = 61
|
||||
hidden_size = 7168
|
||||
num_key_value_heads = 128
|
||||
supports_tensor = true
|
||||
tasks = ["TextGeneration"]
|
||||
family = "deepseek"
|
||||
quantization = "8bit"
|
||||
base_model = "DeepSeek V3.2"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 755957120916
|
||||
@@ -42,7 +42,7 @@ class MessageTooLargeError(builtins.Exception):
|
||||
|
||||
@typing.final
|
||||
class NetworkingHandle:
|
||||
def __new__(cls, identity: Keypair, bootstrap_peers: typing.Sequence[builtins.str], listen_port: builtins.int) -> NetworkingHandle: ...
|
||||
def __new__(cls, identity: Keypair) -> NetworkingHandle: ...
|
||||
async def gossipsub_subscribe(self, topic: builtins.str) -> builtins.bool:
|
||||
r"""
|
||||
Subscribe to a `GossipSub` topic.
|
||||
|
||||
@@ -180,12 +180,7 @@ impl PyNetworkingHandle {
|
||||
// ---- Lifecycle management methods ----
|
||||
|
||||
#[new]
|
||||
#[pyo3(signature = (identity, bootstrap_peers, listen_port))]
|
||||
fn py_new(
|
||||
identity: Bound<'_, PyKeypair>,
|
||||
bootstrap_peers: Vec<String>,
|
||||
listen_port: u16,
|
||||
) -> PyResult<Self> {
|
||||
fn py_new(identity: Bound<'_, PyKeypair>) -> PyResult<Self> {
|
||||
// create communication channels
|
||||
let (to_swarm, from_client) = mpsc::channel(MPSC_CHANNEL_SIZE);
|
||||
|
||||
@@ -194,9 +189,7 @@ impl PyNetworkingHandle {
|
||||
|
||||
// create networking swarm (within tokio context!! or it crashes)
|
||||
let _guard = pyo3_async_runtimes::tokio::get_runtime().enter();
|
||||
let swarm = create_swarm(identity, from_client, bootstrap_peers, listen_port)
|
||||
.pyerr()?
|
||||
.into_stream();
|
||||
let swarm = create_swarm(identity, from_client).pyerr()?.into_stream();
|
||||
|
||||
Ok(Self {
|
||||
swarm: Arc::new(Mutex::new(swarm)),
|
||||
|
||||
@@ -16,14 +16,9 @@ async fn main() {
|
||||
let (to_swarm, from_client) = mpsc::channel(20);
|
||||
|
||||
// Configure swarm
|
||||
let mut swarm = swarm::create_swarm(
|
||||
identity::Keypair::generate_ed25519(),
|
||||
from_client,
|
||||
vec![],
|
||||
0,
|
||||
)
|
||||
.expect("Swarm creation failed")
|
||||
.into_stream();
|
||||
let mut swarm = swarm::create_swarm(identity::Keypair::generate_ed25519(), from_client)
|
||||
.expect("Swarm creation failed")
|
||||
.into_stream();
|
||||
|
||||
// Create a Gossipsub topic & subscribe
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
@@ -104,7 +104,6 @@ pub struct Behaviour {
|
||||
// state-tracking for managed behaviors & mDNS-discovered peers
|
||||
managed: managed::Behaviour,
|
||||
mdns_discovered: HashMap<PeerId, BTreeSet<Multiaddr>>,
|
||||
bootstrap_peers: Vec<Multiaddr>,
|
||||
|
||||
retry_delay: Delay, // retry interval
|
||||
|
||||
@@ -113,11 +112,10 @@ pub struct Behaviour {
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
pub fn new(keypair: &identity::Keypair, bootstrap_peers: Vec<Multiaddr>) -> io::Result<Self> {
|
||||
pub fn new(keypair: &identity::Keypair) -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
managed: managed::Behaviour::new(keypair)?,
|
||||
mdns_discovered: HashMap::new(),
|
||||
bootstrap_peers,
|
||||
retry_delay: Delay::new(RETRY_CONNECT_INTERVAL),
|
||||
pending_events: WakerDeque::new(),
|
||||
})
|
||||
@@ -370,12 +368,6 @@ impl NetworkBehaviour for Behaviour {
|
||||
self.dial(p, ma)
|
||||
}
|
||||
}
|
||||
// dial bootstrap peers (for environments where mDNS is unavailable)
|
||||
for addr in &self.bootstrap_peers {
|
||||
self.pending_events.push_back(ToSwarm::Dial {
|
||||
opts: DialOpts::unknown_peer_id().address(addr.clone()).build(),
|
||||
})
|
||||
}
|
||||
self.retry_delay.reset(RETRY_CONNECT_INTERVAL) // reset timeout
|
||||
}
|
||||
|
||||
|
||||
@@ -142,29 +142,19 @@ fn filter_swarm_event(event: SwarmEvent<BehaviourEvent>) -> Option<FromSwarm> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create and configure a swarm.
|
||||
///
|
||||
/// - `listen_port`: TCP port to listen on. `0` lets the OS assign one.
|
||||
/// - `bootstrap_peers`: multiaddrs to dial for environments without mDNS.
|
||||
/// Create and configure a swarm which listens to all ports on OS
|
||||
pub fn create_swarm(
|
||||
keypair: identity::Keypair,
|
||||
from_client: mpsc::Receiver<ToSwarm>,
|
||||
bootstrap_peers: Vec<String>,
|
||||
listen_port: u16,
|
||||
) -> alias::AnyResult<Swarm> {
|
||||
let parsed_bootstrap_peers: Vec<libp2p::Multiaddr> = bootstrap_peers
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
|
||||
let mut swarm = SwarmBuilder::with_existing_identity(keypair)
|
||||
.with_tokio()
|
||||
.with_other_transport(tcp_transport)?
|
||||
.with_behaviour(|keypair| Behaviour::new(keypair, parsed_bootstrap_peers))?
|
||||
.with_behaviour(Behaviour::new)?
|
||||
.build();
|
||||
|
||||
swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{listen_port}").parse()?)?;
|
||||
// Listen on all interfaces and whatever port the OS assigns
|
||||
swarm.listen_on("/ip4/0.0.0.0/tcp/0".parse()?)?;
|
||||
Ok(Swarm { swarm, from_client })
|
||||
}
|
||||
|
||||
@@ -256,12 +246,9 @@ mod behaviour {
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
pub fn new(
|
||||
keypair: &identity::Keypair,
|
||||
bootstrap_peers: Vec<libp2p::Multiaddr>,
|
||||
) -> alias::AnyResult<Self> {
|
||||
pub fn new(keypair: &identity::Keypair) -> alias::AnyResult<Self> {
|
||||
Ok(Self {
|
||||
discovery: discovery::Behaviour::new(keypair, bootstrap_peers)?,
|
||||
discovery: discovery::Behaviour::new(keypair)?,
|
||||
gossipsub: gossipsub_behaviour(keypair),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
use futures_lite::StreamExt;
|
||||
use networking::swarm::{FromSwarm, create_swarm};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Helper: find a free TCP port.
|
||||
fn free_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
/// Two nodes connect via bootstrap peers — no mDNS needed.
|
||||
///
|
||||
/// Node A listens on a fixed port. Node B bootstraps to A's address.
|
||||
/// We verify that B emits `FromSwarm::Discovered` for A's peer ID.
|
||||
#[tokio::test]
|
||||
async fn two_nodes_connect_via_bootstrap_peers() {
|
||||
let port_a = free_port();
|
||||
|
||||
// Node A: listens on a known port, no bootstrap peers
|
||||
let keypair_a = libp2p::identity::Keypair::generate_ed25519();
|
||||
let peer_id_a = keypair_a.public().to_peer_id();
|
||||
let (_tx_a, rx_a) = mpsc::channel(16);
|
||||
let swarm_a = create_swarm(keypair_a, rx_a, vec![], port_a).expect("create swarm A");
|
||||
let mut stream_a = swarm_a.into_stream();
|
||||
|
||||
// Node B: bootstraps to A's address
|
||||
let keypair_b = libp2p::identity::Keypair::generate_ed25519();
|
||||
let (_tx_b, rx_b) = mpsc::channel(16);
|
||||
let swarm_b = create_swarm(
|
||||
keypair_b,
|
||||
rx_b,
|
||||
vec![format!("/ip4/127.0.0.1/tcp/{port_a}")],
|
||||
0,
|
||||
)
|
||||
.expect("create swarm B");
|
||||
let mut stream_b = swarm_b.into_stream();
|
||||
|
||||
// Wait for B to discover A (connection established)
|
||||
let connected = timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(event) = stream_a.next() => {
|
||||
// A will also see B connect, but we check from B's perspective
|
||||
let _ = event;
|
||||
}
|
||||
Some(event) = stream_b.next() => {
|
||||
if let FromSwarm::Discovered { peer_id } = event {
|
||||
if peer_id == peer_id_a {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
connected.is_ok() && connected.unwrap(),
|
||||
"Node B should discover Node A via bootstrap peer"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty bootstrap peers should work (backward compatible).
|
||||
#[tokio::test]
|
||||
async fn create_swarm_with_empty_bootstrap_peers() {
|
||||
let keypair = libp2p::identity::Keypair::generate_ed25519();
|
||||
let (_tx, rx) = mpsc::channel(16);
|
||||
let swarm = create_swarm(keypair, rx, vec![], 0);
|
||||
assert!(
|
||||
swarm.is_ok(),
|
||||
"create_swarm with no bootstrap peers should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Invalid multiaddr strings are silently filtered out.
|
||||
#[tokio::test]
|
||||
async fn create_swarm_ignores_invalid_bootstrap_addrs() {
|
||||
let keypair = libp2p::identity::Keypair::generate_ed25519();
|
||||
let (_tx, rx) = mpsc::channel(16);
|
||||
let swarm = create_swarm(
|
||||
keypair,
|
||||
rx,
|
||||
vec![
|
||||
"not-a-valid-multiaddr".to_string(),
|
||||
"".to_string(),
|
||||
"/ip4/10.0.0.1/tcp/30000".to_string(), // valid
|
||||
],
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
swarm.is_ok(),
|
||||
"create_swarm should succeed even with invalid bootstrap addrs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fixed listen port works correctly.
|
||||
#[tokio::test]
|
||||
async fn create_swarm_with_fixed_port() {
|
||||
let port = free_port();
|
||||
let keypair = libp2p::identity::Keypair::generate_ed25519();
|
||||
let (_tx, rx) = mpsc::channel(16);
|
||||
let swarm = create_swarm(keypair, rx, vec![], port);
|
||||
assert!(swarm.is_ok(), "create_swarm with fixed port should succeed");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import sys
|
||||
sys.path.insert(0, "src")
|
||||
import mlx.core as mx
|
||||
from mlx_lm import load
|
||||
from mlx_lm.models.cache import RotatingKVCache, KVCache
|
||||
|
||||
model, tok = load("mlx-community/gpt-oss-20b-MXFP4-Q8")
|
||||
|
||||
prompt = "Hello " * 2000
|
||||
tokens = tok.encode(prompt)
|
||||
print(f"Tokens: {len(tokens)}")
|
||||
|
||||
cache = model.make_cache()
|
||||
token_arr = mx.array([tokens])
|
||||
logits = model(token_arr, cache=cache)
|
||||
mx.eval(logits)
|
||||
|
||||
for i, c in enumerate(cache[:6]):
|
||||
if isinstance(c, KVCache) and not isinstance(c, RotatingKVCache) and c.keys is not None:
|
||||
k = c.keys.astype(mx.float32)
|
||||
print(f"Layer {i} KVCache: shape={c.keys.shape} offset={c.offset} first=[{float(k[0,0,0,0]):.6f}, {float(k[0,0,0,1]):.6f}] last=[{float(k[0,0,-1,-2]):.6f}, {float(k[0,0,-1,-1]):.6f}]")
|
||||
elif isinstance(c, RotatingKVCache) and c.keys is not None:
|
||||
k = c.keys.astype(mx.float32)
|
||||
print(f"Layer {i} RotatingKV: shape={c.keys.shape} _idx={c._idx} offset={c.offset} first=[{float(k[0,0,0,0]):.6f}, {float(k[0,0,0,1]):.6f}]")
|
||||
@@ -0,0 +1,12 @@
|
||||
import mlx.core as mx
|
||||
from mlx_lm import load
|
||||
from mlx_lm.models.cache import RotatingKVCache
|
||||
|
||||
model, tok = load("mlx-community/gpt-oss-20b-MXFP4-Q8")
|
||||
cache = model.make_cache()
|
||||
tokens = mx.ones((1, 5000), dtype=mx.int32)
|
||||
model(tokens, cache=cache)
|
||||
mx.eval([c.keys for c in cache if c.keys is not None])
|
||||
for i, c in enumerate(cache[:4]):
|
||||
if isinstance(c, RotatingKVCache):
|
||||
print(f"Layer {i}: _idx={c._idx} offset={c.offset} keep={c.keep} max_size={c.max_size} keys={c.keys.shape}")
|
||||
@@ -0,0 +1,186 @@
|
||||
import sys
|
||||
sys.path.insert(0, "src")
|
||||
from exo.worker.engines.mlx.gdn_softplus_patch import patch_gdn_softplus
|
||||
from exo.worker.engines.mlx.yarn_rope_patch import patch_yarn_rope
|
||||
patch_gdn_softplus()
|
||||
patch_yarn_rope()
|
||||
import mlx.core as mx
|
||||
import torch
|
||||
import socket
|
||||
from pathlib import Path
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from mlx_lm import load
|
||||
from mlx_lm.models.cache import ArraysCache, RotatingKVCache, KVCache
|
||||
from exo.disaggregated.protocol import read_header, read_message, ArraysState, KVChunk, Done
|
||||
from exo.disaggregated.prefill_client import _nhd_to_bhsd, _torch_to_mx
|
||||
|
||||
ENDPOINT = sys.argv[1] if len(sys.argv) > 1 else "10.43.0.1:62988"
|
||||
MODEL = sys.argv[2] if len(sys.argv) > 2 else "mlx-community/Llama-3.2-1B-Instruct-bf16"
|
||||
MODEL_PATH = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
model, tok = load(MODEL_PATH or str(Path.home() / ".exo/models" / MODEL.replace("/", "--")))
|
||||
prompt = "The quick brown fox jumps over the lazy dog. " * 3000
|
||||
tokens = tok.encode(prompt)
|
||||
print(f"Tokens: {len(tokens)}")
|
||||
|
||||
host, port = ENDPOINT.rsplit(":", 1)
|
||||
sock = socket.create_connection((host, int(port)), timeout=60)
|
||||
request = json.dumps({"model": MODEL, "token_ids": tokens, "start_pos": 0}).encode() + b"\n"
|
||||
sock.sendall(request)
|
||||
stream = sock.makefile("rb", buffering=65536)
|
||||
header = read_header(stream)
|
||||
|
||||
vllm_kv = defaultdict(list)
|
||||
vllm_arrays: dict[int, list[torch.Tensor]] = {}
|
||||
while True:
|
||||
msg = read_message(stream, header)
|
||||
if msg is None or isinstance(msg, Done):
|
||||
break
|
||||
if isinstance(msg, KVChunk):
|
||||
vllm_kv[msg.layer_idx].append((msg.keys, msg.values))
|
||||
elif isinstance(msg, ArraysState):
|
||||
vllm_arrays[msg.layer_idx] = msg.arrays
|
||||
sock.close()
|
||||
|
||||
print(f"Received {len(vllm_kv)} KV layers, {len(vllm_arrays)} arrays layers from vLLM")
|
||||
|
||||
if hasattr(model, "make_cache"):
|
||||
mlx_cache = model.make_cache()
|
||||
else:
|
||||
from mlx_lm.models.cache import make_prompt_cache
|
||||
mlx_cache = make_prompt_cache(model)
|
||||
token_arr = mx.array([tokens[:-2]])
|
||||
mlx_logits = model(token_arr, cache=mlx_cache)
|
||||
mx.eval(mlx_logits)
|
||||
|
||||
for i in range(min(6, len(mlx_cache))):
|
||||
c = mlx_cache[i]
|
||||
if isinstance(c, ArraysCache):
|
||||
if i in vllm_arrays:
|
||||
vllm_arrs = vllm_arrays[i]
|
||||
mlx_state = c.state
|
||||
print(f"Layer {i} (Arrays): mlx_state={len(mlx_state)} arrays, vllm={len(vllm_arrs)} arrays")
|
||||
for ai, (m_arr, v_arr) in enumerate(zip(mlx_state, vllm_arrs)):
|
||||
if m_arr is None:
|
||||
continue
|
||||
v_mx = _torch_to_mx(v_arr).astype(mx.float32)
|
||||
m_f = m_arr.astype(mx.float32)
|
||||
if m_f.shape != v_mx.shape:
|
||||
print(f" [{ai}] SHAPE MISMATCH mlx={m_f.shape} vllm={v_mx.shape}")
|
||||
else:
|
||||
d = mx.abs(m_f - v_mx)
|
||||
a = m_f.reshape(-1)
|
||||
b = v_mx.reshape(-1)
|
||||
cos = float(mx.sum(a * b).item()) / (float(mx.sqrt(mx.sum(a * a)).item()) * float(mx.sqrt(mx.sum(b * b)).item()) + 1e-8)
|
||||
print(f" [{ai}] cosine_sim={cos:.6f} max_diff={mx.max(d).item():.6f} mean_diff={mx.mean(d).item():.6f} shape={m_f.shape}")
|
||||
else:
|
||||
print(f"Layer {i} (Arrays): no vLLM data")
|
||||
continue
|
||||
if c.keys is None:
|
||||
continue
|
||||
mlx_k = c.keys.astype(mx.float32)
|
||||
|
||||
if i not in vllm_kv:
|
||||
print(f"Layer {i}: no vLLM data")
|
||||
continue
|
||||
chunks = vllm_kv[i]
|
||||
vk = torch.cat([k for k, v in chunks], dim=0) if len(chunks) > 1 else chunks[0][0]
|
||||
vk_mx = _torch_to_mx(vk.permute(1, 0, 2).unsqueeze(0)).astype(mx.float32)
|
||||
|
||||
n = min(mlx_k.shape[2], vk_mx.shape[2])
|
||||
diff = mx.abs(mlx_k[:, :, :n, :] - vk_mx[:, :, :n, :])
|
||||
max_diff = mx.max(diff).item()
|
||||
mean_diff = mx.mean(diff).item()
|
||||
cache_type = "RotatingKV" if isinstance(c, RotatingKVCache) else "KV"
|
||||
print(f"Layer {i} ({cache_type}): mlx={mlx_k.shape} vllm={vk_mx.shape} max_diff={max_diff:.6f} mean_diff={mean_diff:.6f}")
|
||||
|
||||
a = mlx_k[:, :, :n, :].reshape(-1)
|
||||
b_vec = vk_mx[:, :, :n, :].reshape(-1)
|
||||
cos_sim = float(mx.sum(a * b_vec).item()) / (float(mx.sqrt(mx.sum(a * a)).item()) * float(mx.sqrt(mx.sum(b_vec * b_vec)).item()) + 1e-8)
|
||||
diff_tensor = mx.abs(mlx_k[:, :, :n, :] - vk_mx[:, :, :n, :])
|
||||
max_idx = mx.argmax(diff_tensor.reshape(-1)).item()
|
||||
total_elems = diff_tensor.shape[1] * n * diff_tensor.shape[3]
|
||||
h_idx = (max_idx // (n * diff_tensor.shape[3])) % diff_tensor.shape[1]
|
||||
s_idx = (max_idx // diff_tensor.shape[3]) % n
|
||||
d_idx = max_idx % diff_tensor.shape[3]
|
||||
print(f" cosine_sim={cos_sim:.6f} max_diff={max_diff:.4f} at h={h_idx} pos={s_idx} dim={d_idx}: mlx={float(mlx_k[0,h_idx,s_idx,d_idx].item()):.6f} vllm={float(vk_mx[0,h_idx,s_idx,d_idx].item()):.6f}")
|
||||
|
||||
D = mlx_k.shape[3]
|
||||
for pos in [0, 100, n-1]:
|
||||
mlx_row = [float(mlx_k[0, 0, pos, d].item()) for d in range(D)]
|
||||
vllm_row = [float(vk_mx[0, 0, pos, d].item()) for d in range(D)]
|
||||
diffs = [abs(mlx_row[d] - vllm_row[d]) for d in range(D)]
|
||||
top5 = sorted(range(D), key=lambda d: -diffs[d])[:5]
|
||||
print(f" pos={pos} top5 diff dims: {[(d, f'{diffs[d]:.3f}', f'mlx={mlx_row[d]:.3f}', f'vllm={vllm_row[d]:.3f}') for d in top5]}")
|
||||
|
||||
print("\n--- Run 2: cached request ---")
|
||||
sock2 = socket.create_connection((host, int(port)), timeout=60)
|
||||
request2 = json.dumps({"model": MODEL, "token_ids": tokens, "start_pos": 0}).encode() + b"\n"
|
||||
sock2.sendall(request2)
|
||||
stream2 = sock2.makefile("rb", buffering=65536)
|
||||
|
||||
first_byte = stream2.peek(1)[:1]
|
||||
if first_byte == b"{":
|
||||
line2 = stream2.readline()
|
||||
print(f"Server error: {json.loads(line2.decode())}")
|
||||
sys.exit(1)
|
||||
|
||||
header2 = read_header(stream2)
|
||||
vllm_kv2 = defaultdict(list)
|
||||
vllm_arrays2: dict[int, list[torch.Tensor]] = {}
|
||||
total_tokens2 = 0
|
||||
while True:
|
||||
msg = read_message(stream2, header2)
|
||||
if msg is None:
|
||||
break
|
||||
if isinstance(msg, KVChunk):
|
||||
vllm_kv2[msg.layer_idx].append((msg.keys, msg.values))
|
||||
elif isinstance(msg, ArraysState):
|
||||
vllm_arrays2[msg.layer_idx] = msg.arrays
|
||||
elif isinstance(msg, Done):
|
||||
total_tokens2 = msg.total_tokens
|
||||
break
|
||||
sock2.close()
|
||||
|
||||
kv_tokens2 = 0
|
||||
if vllm_kv2:
|
||||
first_layer = next(iter(vllm_kv2.values()))
|
||||
kv_tokens2 = sum(k.shape[0] for k, v in first_layer)
|
||||
print(f"Received {len(vllm_kv2)} KV layers ({kv_tokens2} tokens), {len(vllm_arrays2)} arrays layers, total_tokens={total_tokens2}")
|
||||
|
||||
for i in range(min(6, len(mlx_cache))):
|
||||
c = mlx_cache[i]
|
||||
if isinstance(c, ArraysCache):
|
||||
if i in vllm_arrays2:
|
||||
vllm_arrs = vllm_arrays2[i]
|
||||
mlx_state = c.state
|
||||
for ai, (m_arr, v_arr) in enumerate(zip(mlx_state, vllm_arrs)):
|
||||
if m_arr is None:
|
||||
continue
|
||||
v_mx = _torch_to_mx(v_arr).astype(mx.float32)
|
||||
m_f = m_arr.astype(mx.float32)
|
||||
if m_f.shape != v_mx.shape:
|
||||
print(f"Layer {i} [{ai}] SHAPE MISMATCH mlx={m_f.shape} vllm={v_mx.shape}")
|
||||
else:
|
||||
a2 = m_f.reshape(-1)
|
||||
b2 = v_mx.reshape(-1)
|
||||
cos2 = float(mx.sum(a2 * b2).item()) / (float(mx.sqrt(mx.sum(a2 * a2)).item()) * float(mx.sqrt(mx.sum(b2 * b2)).item()) + 1e-8)
|
||||
print(f"Layer {i} (Arrays) [{ai}] cosine_sim={cos2:.6f} shape={m_f.shape}")
|
||||
continue
|
||||
if c.keys is None or i not in vllm_kv2:
|
||||
continue
|
||||
mlx_k = c.keys.astype(mx.float32)
|
||||
chunks = vllm_kv2[i]
|
||||
vk = torch.cat([k for k, v in chunks], dim=0) if len(chunks) > 1 else chunks[0][0]
|
||||
vk_mx = _torch_to_mx(vk.permute(1, 0, 2).unsqueeze(0)).astype(mx.float32)
|
||||
n = min(mlx_k.shape[2], vk_mx.shape[2])
|
||||
a2 = mlx_k[:, :, :n, :].reshape(-1)
|
||||
b2 = vk_mx[:, :, :n, :].reshape(-1)
|
||||
cos2 = float(mx.sum(a2 * b2).item()) / (float(mx.sqrt(mx.sum(a2 * a2)).item()) * float(mx.sqrt(mx.sum(b2 * b2)).item()) + 1e-8)
|
||||
print(f"Layer {i} (KV) cosine_sim={cos2:.6f} mlx={mlx_k.shape} vllm={vk_mx.shape}")
|
||||
|
||||
if len(vllm_kv2) > 0:
|
||||
print("PASS")
|
||||
else:
|
||||
print("FAIL")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Minimal KVConnector that captures per-layer cache data."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
KVConnectorBase_V1,
|
||||
KVConnectorMetadata,
|
||||
)
|
||||
|
||||
captured_layers: dict[str, Any] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureMetadata(KVConnectorMetadata):
|
||||
pass
|
||||
|
||||
|
||||
class CaptureConnector(KVConnectorBase_V1):
|
||||
def __init__(self, vllm_config, role, kv_cache_config=None):
|
||||
super().__init__(vllm_config, role, kv_cache_config)
|
||||
|
||||
def start_load_kv(self, forward_context, **kwargs):
|
||||
pass
|
||||
|
||||
def wait_for_layer_load(self, layer_name):
|
||||
pass
|
||||
|
||||
def save_kv_layer(self, layer_name, kv_layer, attn_metadata, **kwargs):
|
||||
import time
|
||||
slot_mapping = getattr(attn_metadata, 'slot_mapping', None)
|
||||
if slot_mapping is not None and slot_mapping.shape[0] <= 100:
|
||||
return
|
||||
t0 = time.perf_counter()
|
||||
torch.cuda.synchronize()
|
||||
t_sync = time.perf_counter() - t0
|
||||
if isinstance(kv_layer, (list, tuple)):
|
||||
captured_layers[layer_name] = [t.cpu().clone() for t in kv_layer]
|
||||
else:
|
||||
slot_mapping = getattr(attn_metadata, 'slot_mapping', None)
|
||||
if slot_mapping is not None:
|
||||
if kv_layer.shape[0] == 2:
|
||||
k_all = kv_layer[0]
|
||||
v_all = kv_layer[1]
|
||||
else:
|
||||
k_all = kv_layer[:, 0]
|
||||
v_all = kv_layer[:, 1]
|
||||
k_flat = k_all.reshape(-1, *k_all.shape[-2:])
|
||||
v_flat = v_all.reshape(-1, *v_all.shape[-2:])
|
||||
valid = slot_mapping >= 0
|
||||
safe_sm = slot_mapping.clamp(min=0)
|
||||
keys = k_flat[safe_sm]
|
||||
values = v_flat[safe_sm]
|
||||
keys[~valid] = 0
|
||||
values[~valid] = 0
|
||||
prev = captured_layers.get(layer_name)
|
||||
if isinstance(prev, dict) and "keys" in prev:
|
||||
t1 = time.perf_counter()
|
||||
captured_layers[layer_name] = {
|
||||
"keys": torch.cat([prev["keys"], keys.cpu()], dim=0),
|
||||
"values": torch.cat([prev["values"], values.cpu()], dim=0),
|
||||
}
|
||||
t_copy = time.perf_counter() - t1
|
||||
else:
|
||||
t1 = time.perf_counter()
|
||||
captured_layers[layer_name] = {
|
||||
"keys": keys.cpu(),
|
||||
"values": values.cpu(),
|
||||
}
|
||||
t_copy = time.perf_counter() - t1
|
||||
if "layers.3." in layer_name:
|
||||
print(f" [attn save] sync={t_sync*1000:.1f}ms copy={t_copy*1000:.1f}ms tokens={keys.shape[0]}")
|
||||
else:
|
||||
captured_layers[layer_name] = kv_layer.cpu().clone()
|
||||
|
||||
def wait_for_save(self):
|
||||
pass
|
||||
|
||||
def get_num_new_matched_tokens(self, request, num_computed_tokens):
|
||||
return 0, False
|
||||
|
||||
def update_state_after_alloc(self, request, blocks, num_external_tokens):
|
||||
pass
|
||||
|
||||
def build_connector_meta(self, scheduler_output):
|
||||
return CaptureMetadata()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Inspect vLLM KV cache structure per-layer after prefill.
|
||||
|
||||
Runs on DGX Spark. Prints per-layer shapes, dtypes, kv_cache_config,
|
||||
and layer_to_group mapping to understand what vLLM stores for each
|
||||
model architecture (standard attention, sliding window, GatedDeltaNet).
|
||||
|
||||
Usage:
|
||||
uv run python scripts/disaggregated/inspect_vllm_kv.py --model ~/.local/share/exo/models/openai--gpt-oss-20b
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
|
||||
|
||||
os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0"
|
||||
os.environ["VLLM_KV_CACHE_LAYOUT"] = "NHD"
|
||||
os.environ["VLLM_BATCH_INVARIANT"] = "1"
|
||||
|
||||
from exo.worker.runner.bootstrap import _ensure_cuda_libs
|
||||
|
||||
_ensure_cuda_libs()
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _build_layer_groups(kv_cache_config):
|
||||
group_lookup = {}
|
||||
for group_idx, group_spec in enumerate(kv_cache_config.kv_cache_groups):
|
||||
for layer_name in group_spec.layer_names:
|
||||
group_lookup[layer_name] = group_idx
|
||||
|
||||
layer_to_group = []
|
||||
for tensor_spec in kv_cache_config.kv_cache_tensors:
|
||||
for name in tensor_spec.shared_by:
|
||||
layer_to_group.append(group_lookup[name])
|
||||
return layer_to_group
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", required=True, help="Path to model")
|
||||
parser.add_argument("--prompt", default="Hello, world! How are you today?", help="Prompt to prefill")
|
||||
args = parser.parse_args()
|
||||
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
from exo.worker.engines.vllm.vllm_generator import load_vllm_engine
|
||||
|
||||
print(f"Loading vLLM engine from {args.model}...")
|
||||
engine, _, prefix_cache = load_vllm_engine(
|
||||
model_path=args.model,
|
||||
model_id=args.model,
|
||||
trust_remote_code=True,
|
||||
)
|
||||
print("Engine loaded.\n")
|
||||
|
||||
from vllm import SamplingParams
|
||||
|
||||
tokenizer = engine.get_tokenizer()
|
||||
token_ids = tokenizer.encode(args.prompt, add_special_tokens=False)
|
||||
print(f"Prompt: {args.prompt!r}")
|
||||
print(f"Token IDs: {len(token_ids)} tokens\n")
|
||||
|
||||
request_id = "inspect-test"
|
||||
params = SamplingParams(max_tokens=1, detokenize=False)
|
||||
engine.add_request(request_id, {"prompt_token_ids": token_ids}, params)
|
||||
|
||||
while engine.has_unfinished_requests():
|
||||
engine.step()
|
||||
|
||||
model_runner = get_model_runner()
|
||||
if model_runner is None:
|
||||
print("ERROR: model_runner is None")
|
||||
return
|
||||
|
||||
print("=" * 70)
|
||||
print("PER-LAYER KV CACHE TENSORS (model_runner.kv_caches)")
|
||||
print("=" * 70)
|
||||
kv_caches = model_runner.kv_caches
|
||||
for i, kv in enumerate(kv_caches):
|
||||
if isinstance(kv, list):
|
||||
shapes = [t.shape for t in kv]
|
||||
dtypes = [t.dtype for t in kv]
|
||||
print(f" Layer {i:3d}: list of {len(kv)} tensors — shapes={shapes}, dtypes={dtypes}")
|
||||
elif isinstance(kv, torch.Tensor):
|
||||
print(f" Layer {i:3d}: shape={tuple(kv.shape)}, dtype={kv.dtype}, device={kv.device}")
|
||||
else:
|
||||
print(f" Layer {i:3d}: type={type(kv).__name__}")
|
||||
print(f"\n Total layers with KV: {len(kv_caches)}\n")
|
||||
|
||||
engine_core = engine.engine_core.engine_core
|
||||
kv_cache_config = engine_core.scheduler.kv_cache_manager.kv_cache_config
|
||||
|
||||
print("=" * 70)
|
||||
print("KV CACHE CONFIG")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n Number of KV cache groups: {len(kv_cache_config.kv_cache_groups)}")
|
||||
for gi, group in enumerate(kv_cache_config.kv_cache_groups):
|
||||
print(f"\n Group {gi}:")
|
||||
print(f" Layer names ({len(group.layer_names)}):")
|
||||
for name in group.layer_names[:5]:
|
||||
print(f" {name}")
|
||||
if len(group.layer_names) > 5:
|
||||
print(f" ... and {len(group.layer_names) - 5} more")
|
||||
|
||||
print(f"\n Number of KV cache tensors: {len(kv_cache_config.kv_cache_tensors)}")
|
||||
for ti, tensor_spec in enumerate(kv_cache_config.kv_cache_tensors):
|
||||
shared = tensor_spec.shared_by[:3]
|
||||
extra = f" ... +{len(tensor_spec.shared_by)-3}" if len(tensor_spec.shared_by) > 3 else ""
|
||||
print(f" Tensor {ti}: shared_by={shared}{extra}")
|
||||
|
||||
layer_to_group = _build_layer_groups(kv_cache_config)
|
||||
print(f"\n layer_to_group ({len(layer_to_group)} entries): {layer_to_group[:10]}{'...' if len(layer_to_group) > 10 else ''}")
|
||||
|
||||
coordinator = engine_core.scheduler.kv_cache_manager.coordinator
|
||||
null_block = coordinator.block_pool.null_block
|
||||
|
||||
internal_id = None
|
||||
for mgr in coordinator.single_type_managers:
|
||||
for key in mgr.req_to_blocks:
|
||||
if str(key).startswith(request_id):
|
||||
internal_id = str(key)
|
||||
break
|
||||
if internal_id:
|
||||
break
|
||||
|
||||
if internal_id:
|
||||
print(f"\n Request internal_id: {internal_id}")
|
||||
for gi, mgr in enumerate(coordinator.single_type_managers):
|
||||
blocks = mgr.req_to_blocks.get(internal_id)
|
||||
if blocks:
|
||||
real_blocks = [b for b in blocks if b is not null_block and not b.is_null]
|
||||
null_count = len(blocks) - len(real_blocks)
|
||||
print(f" Group {gi}: {len(real_blocks)} real blocks, {null_count} null blocks, block_size={mgr.block_size}")
|
||||
else:
|
||||
print(f" Group {gi}: no blocks")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
print(f" Model: {args.model}")
|
||||
print(f" KV cache layers: {len(kv_caches)}")
|
||||
print(f" KV cache groups: {len(kv_cache_config.kv_cache_groups)}")
|
||||
print(f" Layer-to-group mapping entries: {len(layer_to_group)}")
|
||||
unique_shapes = set()
|
||||
for kv in kv_caches:
|
||||
if isinstance(kv, torch.Tensor):
|
||||
unique_shapes.add(tuple(kv.shape))
|
||||
print(f" Unique tensor shapes: {unique_shapes}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Extract KV cache per-layer from vLLM using a real KVConnector.
|
||||
|
||||
Patches vLLM to allow KVConnector on hybrid models (attention + GDN).
|
||||
|
||||
Usage:
|
||||
uv run python scripts/disaggregated/test_kv_extract.py --model ~/.local/share/exo/models/Qwen--Qwen3.5-2B --output /tmp/kv_cache_qwen35/
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0"
|
||||
os.environ["VLLM_KV_CACHE_LAYOUT"] = "NHD"
|
||||
os.environ["VLLM_BATCH_INVARIANT"] = "1"
|
||||
|
||||
from exo.worker.runner.bootstrap import _ensure_cuda_libs
|
||||
|
||||
_ensure_cuda_libs()
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _patch_vllm_for_connector():
|
||||
"""Patch vLLM to allow KVConnector on hybrid models."""
|
||||
from vllm.v1.core import kv_cache_utils
|
||||
|
||||
original_unify = kv_cache_utils.unify_hybrid_kv_cache_specs
|
||||
|
||||
def patched_unify(kv_cache_spec):
|
||||
try:
|
||||
original_unify(kv_cache_spec)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
kv_cache_utils.unify_hybrid_kv_cache_specs = patched_unify
|
||||
|
||||
from vllm.v1.core.sched import scheduler as sched_mod
|
||||
original_connector_finished = sched_mod.Scheduler._connector_finished
|
||||
|
||||
def patched_connector_finished(self, request):
|
||||
return False, None
|
||||
|
||||
sched_mod.Scheduler._connector_finished = patched_connector_finished
|
||||
|
||||
from capture_connector import CaptureConnector
|
||||
from vllm.distributed.kv_transfer.kv_connector import factory
|
||||
|
||||
original_get = factory.KVConnectorFactory._get_connector_class_with_compat
|
||||
|
||||
@classmethod
|
||||
def patched_get(cls, kv_transfer_config):
|
||||
if "capture_connector" in (kv_transfer_config.kv_connector or ""):
|
||||
return CaptureConnector, None
|
||||
return original_get.__func__(cls, kv_transfer_config)
|
||||
|
||||
factory.KVConnectorFactory._get_connector_class_with_compat = patched_get
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
_lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec lobortis risus a elit. Etiam tempor. Ut ullamcorper, ligula ut dictum pharetra, nisi nunc fringilla magna, in commodo elit erat nec turpis. Ut pharetra augue nec augue. Nam elit agna, endrerit sit amet, tincidunt ac, viverra sed, nulla. Donec porta diam eu massa. Quisque diam lorem, interdum vitae, dapibus ac, scelerisque vitae, pede. Donec eget tellus non erat lacinia fermentum. Donec in velit vel ipsum auctor pulvinar. Vestibulum iaculis lacinia est. Proin dictum elementum velit. Fusce euismod consequat ante. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Pellentesque sed dolor. Aliquam congue fermentum nisl. Mauris accumsan nulla vel diam. Sed in lacus ut enim adipiscing aliquet. Nulla venenatis. In pede mi, aliquet sit amet, euismod in, auctor ut, ligula. Aliquam dapibus tincidunt metus. Praesent justo dolor, lobortis quis, lobortis dignissim, pulvinar ac, lorem. "
|
||||
parser.add_argument("--prompt", default=_lorem * 21 + "Now answer this question: What is the capital of France and why is it historically significant? Give a detailed answer.")
|
||||
args = parser.parse_args()
|
||||
|
||||
out_dir = Path(args.output)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_patch_vllm_for_connector()
|
||||
|
||||
from vllm.engine.arg_utils import EngineArgs
|
||||
from vllm.v1.engine.llm_engine import LLMEngine
|
||||
|
||||
from exo.worker.engines.mlx.cache import KVPrefixCache
|
||||
from exo.worker.engines.vllm.growable_cache import patch_vllm, set_prefix_cache
|
||||
|
||||
patch_vllm()
|
||||
|
||||
prefix_cache = KVPrefixCache(group=None)
|
||||
set_prefix_cache(prefix_cache)
|
||||
|
||||
engine_args = EngineArgs(
|
||||
model=args.model,
|
||||
served_model_name=args.model,
|
||||
gpu_memory_utilization=0.05,
|
||||
trust_remote_code=True,
|
||||
load_format="fastsafetensors",
|
||||
enable_prefix_caching=False,
|
||||
attention_backend="TRITON_ATTN",
|
||||
enforce_eager=True,
|
||||
disable_log_stats=True,
|
||||
kv_transfer_config={
|
||||
"kv_connector": "capture_connector:CaptureConnector",
|
||||
"kv_role": "kv_both",
|
||||
},
|
||||
)
|
||||
|
||||
print("Loading engine with KVConnector...")
|
||||
engine = LLMEngine.from_engine_args(engine_args)
|
||||
print("Engine loaded.")
|
||||
|
||||
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
|
||||
model_runner = get_model_runner()
|
||||
|
||||
import vllm.model_executor.layers.mamba.ops.causal_conv1d as cc_mod
|
||||
from vllm.model_executor.layers.mamba.ops.causal_conv1d import (
|
||||
causal_conv1d_fn as orig_causal_conv1d_fn,
|
||||
)
|
||||
|
||||
gdn_states: dict[int, dict[str, torch.Tensor]] = {}
|
||||
gdn_call_idx = [0]
|
||||
gdn_layer_order = [0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 16, 17, 18, 20, 21, 22]
|
||||
|
||||
def patched_causal_conv1d_fn(*args, conv_states=None, cache_indices=None, **kwargs):
|
||||
result = orig_causal_conv1d_fn(*args, conv_states=conv_states, cache_indices=cache_indices, **kwargs)
|
||||
if conv_states is not None and cache_indices is not None:
|
||||
x = args[0] if args else None
|
||||
if x is not None and x.shape[0] <= 100:
|
||||
return result
|
||||
import time as _time
|
||||
t0 = _time.perf_counter()
|
||||
torch.cuda.synchronize()
|
||||
t_sync = _time.perf_counter() - t0
|
||||
ci = cache_indices[0].item() if cache_indices.numel() > 0 else 0
|
||||
idx = gdn_call_idx[0]
|
||||
layer_idx = gdn_layer_order[idx % len(gdn_layer_order)]
|
||||
t1 = _time.perf_counter()
|
||||
conv_at_ci = conv_states[ci:ci+1].transpose(-1, -2).contiguous().cpu()
|
||||
t_copy = _time.perf_counter() - t1
|
||||
gdn_states.setdefault(layer_idx, {})["conv"] = conv_at_ci
|
||||
gdn_states[layer_idx]["ci"] = ci
|
||||
if gdn_call_idx[0] < 3:
|
||||
print(f" [gdn save] sync={t_sync*1000:.1f}ms copy={t_copy*1000:.1f}ms layer={layer_idx}")
|
||||
gdn_call_idx[0] += 1
|
||||
return result
|
||||
|
||||
cc_mod.causal_conv1d_fn = patched_causal_conv1d_fn
|
||||
for mod in list(sys.modules.values()):
|
||||
if mod is None or mod is cc_mod:
|
||||
continue
|
||||
if hasattr(mod, 'causal_conv1d_fn') and mod.causal_conv1d_fn is orig_causal_conv1d_fn:
|
||||
mod.causal_conv1d_fn = patched_causal_conv1d_fn
|
||||
print(" Patched causal_conv1d_fn")
|
||||
|
||||
from exo.shared.types.tasks import TaskId
|
||||
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
|
||||
from exo.worker.engines.vllm.vllm_generator import VllmBatchEngine
|
||||
|
||||
batch_engine = VllmBatchEngine(engine=engine, model_id=args.model, prefix_cache=prefix_cache)
|
||||
|
||||
task = TextGenerationTaskParams(
|
||||
model=args.model,
|
||||
input=[InputMessage(role="user", content=args.prompt)],
|
||||
max_completion_tokens=1,
|
||||
)
|
||||
|
||||
task_id = batch_engine.submit(task_id=TaskId("extract"), task_params=task, prompt=args.prompt)
|
||||
|
||||
print("Running prefill via VllmBatchEngine...")
|
||||
t0 = time.perf_counter()
|
||||
while batch_engine.has_work:
|
||||
results = batch_engine.step()
|
||||
for tid, resp in results:
|
||||
print(f" Prefill done in {(time.perf_counter()-t0)*1000:.0f}ms")
|
||||
batch_engine.cancel([tid])
|
||||
break
|
||||
if results:
|
||||
break
|
||||
t1 = time.perf_counter()
|
||||
print(f"Total: {(t1-t0)*1000:.0f}ms")
|
||||
|
||||
prompt_mx = prefix_cache.prompts[0] if prefix_cache.prompts else None
|
||||
token_ids = [int(x) for x in prompt_mx.tolist()] if prompt_mx is not None else []
|
||||
|
||||
from capture_connector import captured_layers
|
||||
|
||||
print(f"\nCaptured {len(captured_layers)} layers via save_kv_layer:")
|
||||
for name in sorted(captured_layers.keys()):
|
||||
v = captured_layers[name]
|
||||
if isinstance(v, list):
|
||||
print(f" {name}: {[tuple(t.shape) for t in v]}")
|
||||
elif isinstance(v, torch.Tensor):
|
||||
print(f" {name}: {tuple(v.shape)}")
|
||||
else:
|
||||
print(f" {name}: {type(v).__name__}")
|
||||
|
||||
num_tokens = len(token_ids)
|
||||
print(f" Chat-templated prompt: {num_tokens} tokens")
|
||||
total_layers = 24
|
||||
|
||||
for f_old in out_dir.glob("layer_*"):
|
||||
f_old.unlink()
|
||||
|
||||
metadata = {
|
||||
"model": args.model,
|
||||
"prompt": args.prompt,
|
||||
"num_tokens": num_tokens,
|
||||
"token_ids": token_ids,
|
||||
"num_layers": total_layers,
|
||||
"layers": [],
|
||||
}
|
||||
|
||||
print(f"\nSaving {total_layers} layers...")
|
||||
|
||||
torch.cuda.synchronize()
|
||||
for layer_idx in sorted(gdn_states.keys()):
|
||||
ci = gdn_states[layer_idx]["ci"]
|
||||
kv = model_runner.kv_caches[layer_idx]
|
||||
if isinstance(kv, (list, tuple)) and len(kv) > 1:
|
||||
rec_pool = kv[1]
|
||||
rec = rec_pool[ci:ci+1].cpu().clone()
|
||||
gdn_states[layer_idx]["rec"] = rec
|
||||
|
||||
for li in range(total_layers):
|
||||
if li in gdn_states:
|
||||
s = gdn_states[li]
|
||||
conv = s.get("conv")
|
||||
rec = s.get("rec")
|
||||
torch.save(conv, out_dir / f"layer_{li:03d}_conv.pt")
|
||||
if rec is not None:
|
||||
torch.save(rec, out_dir / f"layer_{li:03d}_rec.pt")
|
||||
metadata["layers"].append({"type": "gdn", "conv": list(conv.shape), "rec": list(rec.shape) if rec is not None else None})
|
||||
print(f" Layer {li}: GDN conv={tuple(conv.shape)}, rec={tuple(rec.shape) if rec is not None else 'None'}")
|
||||
else:
|
||||
attn_name = None
|
||||
for n in captured_layers:
|
||||
parts = n.split(".")
|
||||
for pi, p in enumerate(parts):
|
||||
if p == "layers" and pi + 1 < len(parts) and parts[pi + 1] == str(li):
|
||||
attn_name = n
|
||||
break
|
||||
if attn_name and isinstance(captured_layers[attn_name], dict):
|
||||
kv = captured_layers[attn_name]
|
||||
torch.save(kv["keys"], out_dir / f"layer_{li:03d}_keys.pt")
|
||||
torch.save(kv["values"], out_dir / f"layer_{li:03d}_values.pt")
|
||||
if "last_chunk_keys" in kv:
|
||||
torch.save(kv["last_chunk_keys"], out_dir / f"layer_{li:03d}_keys_last.pt")
|
||||
torch.save(kv["last_chunk_values"], out_dir / f"layer_{li:03d}_values_last.pt")
|
||||
metadata["layers"].append({"type": "kv", "keys_shape": list(kv["keys"].shape), "values_shape": list(kv["values"].shape)})
|
||||
print(f" Layer {li}: KV keys={tuple(kv['keys'].shape)}, values={tuple(kv['values'].shape)}")
|
||||
else:
|
||||
metadata["layers"].append({"type": "missing"})
|
||||
print(f" Layer {li}: MISSING")
|
||||
|
||||
with open(out_dir / "metadata.json", "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
print(f"\nSaved metadata to {out_dir}/metadata.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Inject extracted vLLM KV cache into MLX model caches and test decode.
|
||||
|
||||
Runs on Mac (Apple Silicon). Loads per-layer KV tensors saved by
|
||||
test_kv_extract.py, converts to MLX format, injects into MLX caches,
|
||||
and generates tokens to verify correctness.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/disaggregated/test_kv_inject.py \
|
||||
--model mlx-community/gpt-oss-20b-MXFP4-Q8 \
|
||||
--kv-dir /path/to/extracted/kv_cache/ \
|
||||
--num-tokens 20
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
|
||||
|
||||
import mlx.core as mx
|
||||
import torch
|
||||
from mlx_lm import load
|
||||
from mlx_lm.models.cache import ArraysCache, KVCache, RotatingKVCache
|
||||
|
||||
|
||||
def _torch_to_mx(t: torch.Tensor) -> mx.array:
|
||||
t = t.detach().cpu()
|
||||
if t.dtype == torch.bfloat16:
|
||||
return mx.array(t.float().numpy()).astype(mx.bfloat16)
|
||||
return mx.array(t.numpy())
|
||||
|
||||
|
||||
def _to_bhsd(keys: torch.Tensor, values: torch.Tensor, num_tokens: int) -> tuple[mx.array, mx.array]:
|
||||
"""Convert vLLM block format to MLX BHSD [1, H, S, D].
|
||||
|
||||
Input can be:
|
||||
- 4D [blocks, block_size, H, D] — flatten to [blocks*block_size, H, D], trim to num_tokens
|
||||
- 3D [S, H, D] — use directly
|
||||
"""
|
||||
if keys.dim() == 4:
|
||||
keys = keys.reshape(-1, keys.shape[2], keys.shape[3])[:num_tokens]
|
||||
values = values.reshape(-1, values.shape[2], values.shape[3])[:num_tokens]
|
||||
elif keys.dim() == 3:
|
||||
keys = keys[:num_tokens]
|
||||
values = values[:num_tokens]
|
||||
|
||||
k_mx = _torch_to_mx(keys.permute(1, 0, 2).unsqueeze(0))
|
||||
v_mx = _torch_to_mx(values.permute(1, 0, 2).unsqueeze(0))
|
||||
return k_mx, v_mx
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", required=True, help="MLX model path/ID")
|
||||
parser.add_argument("--kv-dir", required=True, help="Directory with extracted KV tensors")
|
||||
parser.add_argument("--num-tokens", type=int, default=500, help="Tokens to generate")
|
||||
parser.add_argument("--prompt", default=None, help="Override prompt (must match extraction prompt)")
|
||||
args = parser.parse_args()
|
||||
|
||||
kv_dir = Path(args.kv_dir)
|
||||
with open(kv_dir / "metadata.json") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
num_extracted_layers = metadata["num_layers"]
|
||||
num_tokens = metadata["num_tokens"]
|
||||
vllm_token_ids = metadata.get("token_ids", [])
|
||||
|
||||
print(f"Extracted KV: {num_extracted_layers} layers, {num_tokens} tokens")
|
||||
if vllm_token_ids:
|
||||
print(f" Using vLLM token_ids ({len(vllm_token_ids)} tokens)")
|
||||
else:
|
||||
print(" WARNING: No token_ids in metadata")
|
||||
|
||||
print(f"\nLoading MLX model: {args.model}")
|
||||
model, tokenizer = load(args.model)
|
||||
|
||||
caches = model.make_cache()
|
||||
num_model_layers = len(caches)
|
||||
print(f"\nMLX model expects {num_model_layers} cache layers:")
|
||||
for i, c in enumerate(caches):
|
||||
print(f" Layer {i:3d}: {type(c).__name__}", end="")
|
||||
if isinstance(c, RotatingKVCache):
|
||||
print(f" (max_size={c.max_size}, keep={c.keep})", end="")
|
||||
elif isinstance(c, ArraysCache):
|
||||
print(f" (size={len(c.state)})", end="")
|
||||
print()
|
||||
|
||||
layer_info = metadata.get("layers", [])
|
||||
print(f"\nExtracted {num_extracted_layers} layers from vLLM")
|
||||
|
||||
print("\nInjecting KV cache into MLX caches...")
|
||||
injected = 0
|
||||
skipped = 0
|
||||
for i in range(num_model_layers):
|
||||
cache = caches[i]
|
||||
|
||||
if isinstance(cache, ArraysCache):
|
||||
conv_path = kv_dir / f"layer_{i:03d}_conv.pt"
|
||||
rec_path = kv_dir / f"layer_{i:03d}_rec.pt"
|
||||
keys_path = kv_dir / f"layer_{i:03d}_keys.pt"
|
||||
values_path = kv_dir / f"layer_{i:03d}_values.pt"
|
||||
if conv_path.exists():
|
||||
conv = torch.load(conv_path, weights_only=True)
|
||||
rec = torch.load(rec_path, weights_only=True) if rec_path.exists() else None
|
||||
states = [_torch_to_mx(conv)]
|
||||
states.append(_torch_to_mx(rec) if rec is not None else None)
|
||||
cache.state = states
|
||||
injected += 1
|
||||
print(f" Layer {i}: ArraysCache conv={tuple(conv.shape)}, rec={tuple(rec.shape) if rec is not None else 'None'}")
|
||||
elif keys_path.exists():
|
||||
conv = torch.load(keys_path, weights_only=True)
|
||||
rec = torch.load(values_path, weights_only=True)
|
||||
cache.state = [_torch_to_mx(conv), _torch_to_mx(rec)]
|
||||
injected += 1
|
||||
print(f" Layer {i}: ArraysCache (legacy) conv={tuple(conv.shape)}, rec={tuple(rec.shape)}")
|
||||
else:
|
||||
print(f" Layer {i}: SKIP — ArraysCache, no files")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
keys_path = kv_dir / f"layer_{i:03d}_keys.pt"
|
||||
values_path = kv_dir / f"layer_{i:03d}_values.pt"
|
||||
if not keys_path.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
keys_torch = torch.load(keys_path, weights_only=True)
|
||||
values_torch = torch.load(values_path, weights_only=True)
|
||||
k_mx, v_mx = _to_bhsd(keys_torch, values_torch, num_tokens)
|
||||
seq_len = int(k_mx.shape[2])
|
||||
|
||||
if isinstance(cache, KVCache) and not isinstance(cache, RotatingKVCache):
|
||||
cache.keys = k_mx
|
||||
cache.values = v_mx
|
||||
cache.offset = seq_len
|
||||
injected += 1
|
||||
elif isinstance(cache, RotatingKVCache):
|
||||
if seq_len <= cache.max_size:
|
||||
cache.keys = k_mx
|
||||
cache.values = v_mx
|
||||
cache.offset = seq_len
|
||||
cache._idx = seq_len
|
||||
else:
|
||||
keep = cache.keep
|
||||
window = cache.max_size
|
||||
sink_keys = k_mx[:, :, :keep, :]
|
||||
sink_values = v_mx[:, :, :keep, :]
|
||||
recent_keys = k_mx[:, :, -(window - keep):, :]
|
||||
recent_values = v_mx[:, :, -(window - keep):, :]
|
||||
cache.keys = mx.concatenate([sink_keys, recent_keys], axis=2)
|
||||
cache.values = mx.concatenate([sink_values, recent_values], axis=2)
|
||||
cache.offset = seq_len
|
||||
cache._idx = keep
|
||||
injected += 1
|
||||
print(f" Layer {i}: RotatingKVCache (seq_len={seq_len}, max_size={cache.max_size})")
|
||||
else:
|
||||
print(f" Layer {i}: SKIP — {type(cache).__name__}")
|
||||
skipped += 1
|
||||
|
||||
print(f"\n Injected: {injected} layers, Skipped: {skipped} layers")
|
||||
|
||||
from exo.worker.engines.vllm.kv_cache import TorchKVCache as TKV
|
||||
print("\nRound-trip test (MLX → torch → MLX)...")
|
||||
rt_caches = model.make_cache()
|
||||
rt_tokens = mx.array(vllm_token_ids)
|
||||
rt_logits = model(rt_tokens[None], cache=rt_caches)
|
||||
mx.eval(rt_logits)
|
||||
torch_rt = TKV.from_mlx_cache(rt_caches)
|
||||
back_rt = torch_rt.to_mlx_cache()
|
||||
rt_max_diff = 0.0
|
||||
for i in range(len(rt_caches)):
|
||||
nc = rt_caches[i]
|
||||
bc = back_rt[i]
|
||||
if isinstance(nc, ArraysCache):
|
||||
for ai in range(len(nc.state)):
|
||||
if nc.state[ai] is not None and bc.state[ai] is not None:
|
||||
d = mx.max(mx.abs(nc.state[ai].astype(mx.float32) - bc.state[ai].astype(mx.float32))).item()
|
||||
rt_max_diff = max(rt_max_diff, d)
|
||||
elif isinstance(nc, (KVCache, RotatingKVCache)) and nc.keys is not None:
|
||||
nk, nv = nc.state
|
||||
bk, bv = bc.state
|
||||
d = mx.max(mx.abs(nk.astype(mx.float32) - bk.astype(mx.float32))).item()
|
||||
rt_max_diff = max(rt_max_diff, d)
|
||||
print(f" Round-trip max diff: {rt_max_diff:.4e} ({'PASS' if rt_max_diff < 0.01 else 'FAIL'})")
|
||||
|
||||
print("\nComparing with MLX-native prefill...")
|
||||
native_caches = rt_caches
|
||||
|
||||
for i in range(num_model_layers):
|
||||
nc = native_caches[i]
|
||||
ic = caches[i]
|
||||
if isinstance(nc, KVCache) and not isinstance(nc, RotatingKVCache) and nc.keys is not None and ic.keys is not None:
|
||||
s = min(nc.offset, ic.offset)
|
||||
nk = nc.keys[:, :, :s, :].astype(mx.float32)
|
||||
ik = ic.keys[:, :, :s, :].astype(mx.float32)
|
||||
nv = nc.values[:, :, :s, :].astype(mx.float32)
|
||||
iv = ic.values[:, :, :s, :].astype(mx.float32)
|
||||
k_diff = mx.max(mx.abs(nk - ik)).item()
|
||||
v_diff = mx.max(mx.abs(nv - iv)).item()
|
||||
if k_diff > 0.01 or i < 4 or i == num_model_layers - 1:
|
||||
print(f" Layer {i:3d} KVCache: k_diff={k_diff:.4e}, v_diff={v_diff:.4e}, offset native={nc.offset} injected={ic.offset}")
|
||||
elif isinstance(nc, RotatingKVCache):
|
||||
pass
|
||||
elif isinstance(nc, ArraysCache):
|
||||
for ai in range(len(nc.state)):
|
||||
na = nc.state[ai]
|
||||
ia = ic.state[ai]
|
||||
if na is not None and ia is not None:
|
||||
diff = mx.max(mx.abs(na.astype(mx.float32) - ia.astype(mx.float32))).item()
|
||||
if diff > 0.01 or i < 4 or i == num_model_layers - 1:
|
||||
print(f" Layer {i:3d} Arrays[{ai}]: diff={diff:.4e}, native_shape={na.shape}, injected_shape={ia.shape}")
|
||||
|
||||
native_last = mx.array([vllm_token_ids[-1]])
|
||||
native_decode_logits = model(native_last[None], cache=native_caches)
|
||||
mx.eval(native_decode_logits)
|
||||
native_first = mx.argmax(native_decode_logits[:, -1, :], axis=-1)
|
||||
print(f" Native decode first token: {native_first.item()}, text: {tokenizer.decode([native_first.item()])!r}")
|
||||
|
||||
print(f"\nDecoding {args.num_tokens} tokens with injected cache...")
|
||||
last_tokens = mx.array(vllm_token_ids[-2:])
|
||||
logits = model(last_tokens[None], cache=caches)
|
||||
mx.eval(logits)
|
||||
|
||||
generated_tokens = []
|
||||
token = mx.argmax(logits[:, -1, :], axis=-1)
|
||||
mx.eval(token)
|
||||
generated_tokens.append(token.item())
|
||||
|
||||
for _ in range(args.num_tokens - 1):
|
||||
logits = model(token[None], cache=caches)
|
||||
mx.eval(logits)
|
||||
token = mx.argmax(logits[:, -1, :], axis=-1)
|
||||
mx.eval(token)
|
||||
generated_tokens.append(token.item())
|
||||
|
||||
generated_text = tokenizer.decode(generated_tokens)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("RESULTS")
|
||||
print(f"{'='*70}")
|
||||
print(f" Model (vLLM): {metadata['model']}")
|
||||
print(f" Model (MLX): {args.model}")
|
||||
print(f" Prompt tokens: {num_tokens}")
|
||||
print(f" Layers injected: {injected}/{num_model_layers}")
|
||||
print(" Type mismatches: 0")
|
||||
print(f" Generated {len(generated_tokens)} tokens")
|
||||
print(f" Text: {generated_text!r}")
|
||||
|
||||
if False:
|
||||
print("\n GAPS FOUND:")
|
||||
for idx, got, expected in type_mismatches:
|
||||
print(f" Layer {idx}: vLLM gives KV tensors, MLX wants {expected}")
|
||||
arrays_layers = [i for i, c in enumerate(caches) if isinstance(c, ArraysCache)]
|
||||
if arrays_layers:
|
||||
print(f" ArraysCache layers (not populated): {arrays_layers[:10]}{'...' if len(arrays_layers) > 10 else ''}")
|
||||
|
||||
if generated_tokens and not all(t == generated_tokens[0] for t in generated_tokens):
|
||||
print("\n COHERENT OUTPUT: YES (varied tokens)")
|
||||
else:
|
||||
print("\n COHERENT OUTPUT: POSSIBLY NOT (all same token)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-gx10-de89}"
|
||||
PORT="${2:-52415}"
|
||||
NUM_REQUESTS="${3:-4}"
|
||||
MODEL="${4:-Qwen/Qwen2.5-0.5B-Instruct}"
|
||||
|
||||
echo "Sending $NUM_REQUESTS parallel requests to $HOST:$PORT ($MODEL) with ~32k token prompts..."
|
||||
echo
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
pids=()
|
||||
for i in $(seq 1 "$NUM_REQUESTS"); do
|
||||
(
|
||||
python3 -c "
|
||||
import json, sys, time, urllib.request
|
||||
|
||||
import random
|
||||
random.seed($i * 9999)
|
||||
topics = [
|
||||
'mathematics', 'philosophy', 'religion', 'culture', 'astronomy',
|
||||
'biology', 'music', 'architecture', 'literature', 'physics',
|
||||
'chemistry', 'geology', 'psychology', 'economics', 'linguistics',
|
||||
]
|
||||
random.shuffle(topics)
|
||||
sentences = []
|
||||
for j in range(95):
|
||||
t1, t2, t3 = topics[j % len(topics)], topics[(j+3) % len(topics)], topics[(j+7) % len(topics)]
|
||||
sentences.append(
|
||||
f'In the field of {t1}, the number {$i * 1000 + j} holds particular significance '
|
||||
f'when examining its relationship to {t2} and {t3}. Scholars have long debated '
|
||||
f'whether the patterns observed in iteration {j} of this analysis reveal deeper '
|
||||
f'structural connections between seemingly unrelated disciplines. The evidence '
|
||||
f'from experiment {$i * 7 + j * 13} suggests that cross-domain numerical '
|
||||
f'correlations emerge at scale {j * $i}, challenging conventional assumptions '
|
||||
f'about the independence of these fields. ')
|
||||
prompt = ' '.join(sentences) + f' Summarize the key finding about the number {$i}.'
|
||||
payload = json.dumps({
|
||||
'model': '$MODEL',
|
||||
'messages': [{'role': 'user', 'content': prompt}],
|
||||
'max_tokens': 1,
|
||||
'stream': True,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'http://$HOST:$PORT/v1/chat/completions',
|
||||
data=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=300)
|
||||
first_byte = None
|
||||
for line in resp:
|
||||
if first_byte is None:
|
||||
first_byte = time.perf_counter()
|
||||
line = line.decode().strip()
|
||||
if line.startswith('data: ') and line != 'data: [DONE]':
|
||||
break
|
||||
ttft = (first_byte or time.perf_counter()) - t0
|
||||
prompt_tokens = len(prompt.split()) * 1.3 # rough estimate
|
||||
tps = prompt_tokens / ttft
|
||||
print(f'request $i: TTFT={ttft:.2f}s ~{int(prompt_tokens)} prompt tokens ~{int(tps)} tok/s prefill')
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - t0
|
||||
print(f'request $i: FAILED after {elapsed:.2f}s — {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" >"$tmpdir/$i" 2>&1
|
||||
) &
|
||||
pids+=($!)
|
||||
done
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid"
|
||||
done
|
||||
|
||||
for i in $(seq 1 "$NUM_REQUESTS"); do
|
||||
cat "$tmpdir/$i"
|
||||
done
|
||||
rm -rf "$tmpdir"
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from multiprocessing import freeze_support
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
from .api import AddCustomModelParams as AddCustomModelParams
|
||||
from .api import AdvancedImageParams as AdvancedImageParams
|
||||
from .api import BenchChatCompletionRequest as BenchChatCompletionRequest
|
||||
from .api import BenchChatCompletionResponse as BenchChatCompletionResponse
|
||||
from .api import BenchImageGenerationResponse as BenchImageGenerationResponse
|
||||
from .api import BenchImageGenerationTaskParams as BenchImageGenerationTaskParams
|
||||
from .api import CancelCommandResponse as CancelCommandResponse
|
||||
from .api import ChatCompletionChoice as ChatCompletionChoice
|
||||
from .api import ChatCompletionMessage as ChatCompletionMessage
|
||||
from .api import ChatCompletionMessageText as ChatCompletionMessageText
|
||||
from .api import ChatCompletionRequest as ChatCompletionRequest
|
||||
from .api import ChatCompletionResponse as ChatCompletionResponse
|
||||
from .api import CompletionTokensDetails as CompletionTokensDetails
|
||||
from .api import CreateInstanceParams as CreateInstanceParams
|
||||
from .api import CreateInstanceResponse as CreateInstanceResponse
|
||||
from .api import DeleteDownloadResponse as DeleteDownloadResponse
|
||||
from .api import DeleteInstanceResponse as DeleteInstanceResponse
|
||||
from .api import DeleteTracesRequest as DeleteTracesRequest
|
||||
from .api import DeleteTracesResponse as DeleteTracesResponse
|
||||
from .api import ErrorInfo as ErrorInfo
|
||||
from .api import ErrorResponse as ErrorResponse
|
||||
from .api import FinishReason as FinishReason
|
||||
from .api import GenerationStats as GenerationStats
|
||||
from .api import HuggingFaceSearchResult as HuggingFaceSearchResult
|
||||
from .api import ImageData as ImageData
|
||||
from .api import ImageEditsTaskParams as ImageEditsTaskParams
|
||||
from .api import ImageGenerationResponse as ImageGenerationResponse
|
||||
from .api import ImageGenerationStats as ImageGenerationStats
|
||||
from .api import ImageGenerationTaskParams as ImageGenerationTaskParams
|
||||
from .api import ImageListItem as ImageListItem
|
||||
from .api import ImageListResponse as ImageListResponse
|
||||
from .api import ImageSize as ImageSize
|
||||
from .api import Logprobs as Logprobs
|
||||
from .api import LogprobsContentItem as LogprobsContentItem
|
||||
from .api import ModelList as ModelList
|
||||
from .api import ModelListModel as ModelListModel
|
||||
from .api import NodePowerStats as NodePowerStats
|
||||
from .api import PlaceInstanceParams as PlaceInstanceParams
|
||||
from .api import PlacementPreview as PlacementPreview
|
||||
from .api import PlacementPreviewResponse as PlacementPreviewResponse
|
||||
from .api import PowerUsage as PowerUsage
|
||||
from .api import PromptTokensDetails as PromptTokensDetails
|
||||
from .api import StartDownloadParams as StartDownloadParams
|
||||
from .api import StartDownloadResponse as StartDownloadResponse
|
||||
from .api import StreamingChoiceResponse as StreamingChoiceResponse
|
||||
from .api import ToolCall as ToolCall
|
||||
from .api import ToolCallItem as ToolCallItem
|
||||
from .api import TopLogprobItem as TopLogprobItem
|
||||
from .api import TraceCategoryStats as TraceCategoryStats
|
||||
from .api import TraceEventResponse as TraceEventResponse
|
||||
from .api import TraceListItem as TraceListItem
|
||||
from .api import TraceListResponse as TraceListResponse
|
||||
from .api import TraceRankStats as TraceRankStats
|
||||
from .api import TraceResponse as TraceResponse
|
||||
from .api import TraceStatsResponse as TraceStatsResponse
|
||||
from .api import Usage as Usage
|
||||
from .api import normalize_image_size as normalize_image_size
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import ( # pyright: ignore[reportMissingImports]
|
||||
KVConnectorBase_V1, # pyright: ignore[reportUnknownVariableType]
|
||||
KVConnectorMetadata, # pyright: ignore[reportUnknownVariableType]
|
||||
KVConnectorRole, # pyright: ignore[reportUnknownVariableType]
|
||||
SupportsHMA, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
_LAYER_RE = re.compile(r"layers\.(\d+)\.")
|
||||
|
||||
_shared_captured_layers: dict[int, dict[str, torch.Tensor]] = {}
|
||||
_shared_captured_arrays: dict[int, list[torch.Tensor]] = {}
|
||||
|
||||
|
||||
def get_shared_captured_layers() -> dict[int, dict[str, torch.Tensor]]:
|
||||
return _shared_captured_layers
|
||||
|
||||
|
||||
def get_shared_captured_arrays() -> dict[int, list[torch.Tensor]]:
|
||||
return _shared_captured_arrays
|
||||
|
||||
|
||||
def clear_shared_captured_layers() -> None:
|
||||
_shared_captured_layers.clear()
|
||||
_shared_captured_arrays.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchConnectorMetadata(KVConnectorMetadata): # pyright: ignore[reportUntypedBaseClass]
|
||||
pass
|
||||
|
||||
|
||||
class BatchConnector(KVConnectorBase_V1, SupportsHMA): # pyright: ignore[reportUntypedBaseClass]
|
||||
captured_layers: dict[int, dict[str, torch.Tensor]]
|
||||
|
||||
def __init__(self, vllm_config: Any, role: KVConnectorRole, kv_cache_config: Any = None) -> None: # type: ignore
|
||||
super().__init__(vllm_config, role, kv_cache_config) # pyright: ignore[reportUnknownMemberType]
|
||||
self.captured_layers = _shared_captured_layers
|
||||
|
||||
def start_load_kv(self, forward_context: Any, **kwargs: Any) -> None: # pyright: ignore[reportAny]
|
||||
pass
|
||||
|
||||
def wait_for_layer_load(self, layer_name: str) -> None:
|
||||
pass
|
||||
|
||||
def save_kv_layer(self, layer_name: str, kv_layer: Any, attn_metadata: Any, **kwargs: Any) -> None: # pyright: ignore[reportAny]
|
||||
slot_mapping = getattr(attn_metadata, "slot_mapping", None) # pyright: ignore[reportAny]
|
||||
if slot_mapping is not None and slot_mapping.shape[0] <= 100: # pyright: ignore[reportAny]
|
||||
return
|
||||
|
||||
m = _LAYER_RE.search(layer_name)
|
||||
if m is None:
|
||||
return
|
||||
layer_idx = int(m.group(1))
|
||||
|
||||
if isinstance(kv_layer, (list, tuple)):
|
||||
from exo.disaggregated.streaming_connector import _to_bf16
|
||||
|
||||
_shared_captured_arrays[layer_idx] = [_to_bf16(t).cpu() for t in kv_layer] # pyright: ignore[reportAny]
|
||||
return
|
||||
|
||||
if slot_mapping is not None:
|
||||
from exo.disaggregated.streaming_connector import _to_nhd
|
||||
|
||||
if kv_layer.shape[0] == 2: # pyright: ignore[reportAny]
|
||||
k_all = _to_nhd(kv_layer[0]) # pyright: ignore[reportAny]
|
||||
v_all = _to_nhd(kv_layer[1]) # pyright: ignore[reportAny]
|
||||
else:
|
||||
k_all = _to_nhd(kv_layer[:, 0]) # pyright: ignore[reportAny]
|
||||
v_all = _to_nhd(kv_layer[:, 1]) # pyright: ignore[reportAny]
|
||||
k_flat = k_all.reshape(-1, *k_all.shape[-2:]) # pyright: ignore[reportAny]
|
||||
v_flat = v_all.reshape(-1, *v_all.shape[-2:]) # pyright: ignore[reportAny]
|
||||
valid = slot_mapping >= 0 # pyright: ignore[reportAny]
|
||||
safe_sm = slot_mapping.clamp(min=0) # pyright: ignore[reportAny]
|
||||
keys = k_flat[safe_sm][valid] # pyright: ignore[reportAny]
|
||||
values = v_flat[safe_sm][valid] # pyright: ignore[reportAny]
|
||||
from exo.disaggregated.streaming_connector import _to_bf16
|
||||
|
||||
keys = _to_bf16(keys) # pyright: ignore[reportAny]
|
||||
values = _to_bf16(values) # pyright: ignore[reportAny]
|
||||
|
||||
prev = self.captured_layers.get(layer_idx)
|
||||
if prev is not None:
|
||||
self.captured_layers[layer_idx] = {
|
||||
"keys": torch.cat([prev["keys"], keys.cpu()], dim=0), # type: ignore
|
||||
"values": torch.cat([prev["values"], values.cpu()], dim=0), # type: ignore
|
||||
}
|
||||
else:
|
||||
self.captured_layers[layer_idx] = {
|
||||
"keys": keys.cpu(), # pyright: ignore[reportAny]
|
||||
"values": values.cpu(), # pyright: ignore[reportAny]
|
||||
}
|
||||
|
||||
def wait_for_save(self) -> None:
|
||||
pass
|
||||
|
||||
def request_finished_all_groups(self, request: Any, block_ids: tuple[list[int], ...]) -> tuple[bool, dict[str, Any] | None]: # pyright: ignore[reportAny]
|
||||
return False, None
|
||||
|
||||
def get_num_new_matched_tokens(self, request: Any, num_computed_tokens: int) -> tuple[int, bool]: # pyright: ignore[reportAny]
|
||||
return 0, False
|
||||
|
||||
def update_state_after_alloc(self, request: Any, blocks: Any, num_external_tokens: int) -> None: # pyright: ignore[reportAny]
|
||||
pass
|
||||
|
||||
def build_connector_meta(self, scheduler_output: Any) -> BatchConnectorMetadata: # pyright: ignore[reportAny]
|
||||
return BatchConnectorMetadata()
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, BinaryIO, cast
|
||||
|
||||
import mlx.core as mx
|
||||
import torch
|
||||
from mlx_lm.models.cache import ArraysCache, KVCache, RotatingKVCache
|
||||
|
||||
from exo.disaggregated.protocol import (
|
||||
ArraysState,
|
||||
Done,
|
||||
KVChunk,
|
||||
read_header,
|
||||
read_message,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from exo.shared.types.mlx import Model
|
||||
|
||||
from exo.worker.runner.bootstrap import logger
|
||||
|
||||
|
||||
def _torch_to_mx(t: torch.Tensor) -> mx.array:
|
||||
t_cpu: torch.Tensor = t.detach().cpu()
|
||||
if t_cpu.dtype == torch.bfloat16:
|
||||
return mx.array(t_cpu.float().numpy()).astype(mx.bfloat16) # pyright: ignore[reportAny]
|
||||
return mx.array(t_cpu.numpy()) # pyright: ignore[reportAny]
|
||||
|
||||
|
||||
def _nhd_to_bhsd(keys: torch.Tensor, values: torch.Tensor) -> tuple[mx.array, mx.array]:
|
||||
k_mx = _torch_to_mx(keys.permute(1, 0, 2).unsqueeze(0))
|
||||
v_mx = _torch_to_mx(values.permute(1, 0, 2).unsqueeze(0))
|
||||
return k_mx, v_mx
|
||||
|
||||
|
||||
def _inject_kv_cache(cache: KVCache, keys: torch.Tensor, values: torch.Tensor, num_tokens: int) -> None:
|
||||
k_mx, v_mx = _nhd_to_bhsd(keys, values)
|
||||
cache.keys = k_mx
|
||||
cache.values = v_mx
|
||||
cache.offset = num_tokens
|
||||
|
||||
|
||||
def _inject_rotating_kv_cache(cache: RotatingKVCache, keys: torch.Tensor, values: torch.Tensor, num_tokens: int) -> None:
|
||||
k_mx, v_mx = _nhd_to_bhsd(keys, values)
|
||||
seq_len = int(k_mx.shape[2])
|
||||
cache.keys = k_mx
|
||||
cache.values = v_mx
|
||||
cache.offset = num_tokens
|
||||
cache._idx = seq_len
|
||||
|
||||
|
||||
def _inject_arrays_cache(cache: ArraysCache, arrays: list[torch.Tensor]) -> None:
|
||||
cache.state = [_torch_to_mx(arr) for arr in arrays]
|
||||
|
||||
|
||||
def remote_prefill(
|
||||
endpoint: str,
|
||||
token_ids: list[int],
|
||||
model_id: str,
|
||||
mlx_model: Model,
|
||||
on_prefill_progress: Callable[[int, int], None] | None = None,
|
||||
existing_cache: list[KVCache | RotatingKVCache | ArraysCache] | None = None,
|
||||
start_pos: int = 0,
|
||||
) -> tuple[list[KVCache | RotatingKVCache | ArraysCache], int]:
|
||||
if ":" in endpoint:
|
||||
host, port_str = endpoint.rsplit(":", 1)
|
||||
port = int(port_str)
|
||||
else:
|
||||
host = endpoint
|
||||
port = 8900
|
||||
|
||||
logger.info(f"Connecting to prefill server at {host}:{port} ({len(token_ids)} tokens, start_pos={start_pos})")
|
||||
t0 = time.perf_counter()
|
||||
|
||||
sock = socket.create_connection((host, port), timeout=60)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)
|
||||
try:
|
||||
request = json.dumps({"model": model_id, "token_ids": token_ids, "start_pos": start_pos}).encode("utf-8") + b"\n"
|
||||
sock.sendall(request)
|
||||
|
||||
raw_stream = sock.makefile("rb", buffering=256 * 1024)
|
||||
stream: BinaryIO = raw_stream # pyright: ignore[reportAssignmentType]
|
||||
|
||||
first_byte: bytes = raw_stream.peek(1)[:1] # type: ignore
|
||||
if first_byte == b"{":
|
||||
line = stream.readline()
|
||||
error_resp: dict[str, object] = json.loads(line.decode("utf-8")) # pyright: ignore[reportAny]
|
||||
raise RuntimeError(f"Prefill server error: {error_resp.get('error', 'unknown')}")
|
||||
|
||||
header = read_header(stream)
|
||||
num_layers: int = header["num_layers"] # pyright: ignore[reportAssignmentType]
|
||||
total_prompt_tokens = len(token_ids)
|
||||
|
||||
kv_buffers: dict[int, list[tuple[torch.Tensor, torch.Tensor]]] = defaultdict(list)
|
||||
arrays_buffers: dict[int, list[torch.Tensor]] = {}
|
||||
total_tokens = 0
|
||||
layers_seen: set[int] = set()
|
||||
|
||||
tokens_received = 0
|
||||
chunks_received = 0
|
||||
t_first_chunk = None
|
||||
while True:
|
||||
msg = read_message(stream, header)
|
||||
if msg is None:
|
||||
break
|
||||
|
||||
if isinstance(msg, KVChunk):
|
||||
if t_first_chunk is None:
|
||||
t_first_chunk = time.perf_counter()
|
||||
kv_buffers[msg.layer_idx].append((msg.keys, msg.values))
|
||||
chunks_received += 1
|
||||
layers_seen.add(msg.layer_idx)
|
||||
tokens_received += msg.num_tokens
|
||||
if on_prefill_progress and num_layers > 0 and chunks_received % num_layers == 0:
|
||||
on_prefill_progress(
|
||||
min(tokens_received // num_layers, total_prompt_tokens - start_pos),
|
||||
total_prompt_tokens - start_pos,
|
||||
)
|
||||
elif isinstance(msg, ArraysState):
|
||||
arrays_buffers[msg.layer_idx] = msg.arrays
|
||||
elif isinstance(msg, Done): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
total_tokens = msg.total_tokens
|
||||
break
|
||||
|
||||
t_received = time.perf_counter()
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if existing_cache is not None and start_pos > 0:
|
||||
caches = existing_cache
|
||||
else:
|
||||
if hasattr(mlx_model, "make_cache"):
|
||||
caches = cast(list[KVCache | RotatingKVCache | ArraysCache], mlx_model.make_cache()) # pyright: ignore[reportUnknownMemberType]
|
||||
else:
|
||||
from mlx_lm.models.cache import make_prompt_cache
|
||||
caches = cast(list[KVCache | RotatingKVCache | ArraysCache], make_prompt_cache(mlx_model)) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
max_received = max((sum(k.shape[0] for k, _v in chunks) for chunks in kv_buffers.values()), default=0)
|
||||
final_offset = start_pos + max_received
|
||||
|
||||
for i, cache in enumerate(caches):
|
||||
if i in kv_buffers:
|
||||
chunks = kv_buffers[i]
|
||||
all_keys: torch.Tensor
|
||||
all_values: torch.Tensor
|
||||
if len(chunks) == 1:
|
||||
all_keys, all_values = chunks[0]
|
||||
else:
|
||||
all_keys = torch.cat([k for k, _v in chunks], dim=0) # type: ignore
|
||||
all_values = torch.cat([v for _k, v in chunks], dim=0) # type: ignore
|
||||
|
||||
if isinstance(cache, RotatingKVCache):
|
||||
_inject_rotating_kv_cache(cache, all_keys, all_values, final_offset) # pyright: ignore[reportUnknownArgumentType]
|
||||
elif isinstance(cache, KVCache):
|
||||
if start_pos > 0 and cache.keys is not None:
|
||||
k_new, v_new = _nhd_to_bhsd(all_keys, all_values) # pyright: ignore[reportUnknownArgumentType]
|
||||
cache.keys = mx.concatenate([cache.keys[:, :, :start_pos, :], k_new], axis=2)
|
||||
cache.values = mx.concatenate([cache.values[:, :, :start_pos, :], v_new], axis=2)
|
||||
cache.offset = final_offset
|
||||
else:
|
||||
_inject_kv_cache(cache, all_keys, all_values, final_offset) # pyright: ignore[reportUnknownArgumentType]
|
||||
|
||||
if i in arrays_buffers and isinstance(cache, ArraysCache):
|
||||
_inject_arrays_cache(cache, arrays_buffers[i])
|
||||
|
||||
t_injected = time.perf_counter()
|
||||
logger.info(
|
||||
f"Remote prefill: {total_tokens} new tokens (start_pos={start_pos}, final_offset={final_offset}), "
|
||||
f"transfer={((t_received - t0) * 1000):.0f}ms, "
|
||||
f"inject={((t_injected - t_received) * 1000):.0f}ms, "
|
||||
f"total={((t_injected - t0) * 1000):.0f}ms"
|
||||
)
|
||||
|
||||
return caches, final_offset
|
||||
@@ -0,0 +1,665 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
|
||||
from exo.disaggregated.protocol import (
|
||||
write_arrays_state,
|
||||
write_done,
|
||||
write_header,
|
||||
write_kv_chunk,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.v1.engine.llm_engine import LLMEngine
|
||||
|
||||
from exo.worker.engines.mlx.cache import KVPrefixCache
|
||||
from exo.worker.engines.vllm.kv_cache import KVLayerState, TorchKVCache
|
||||
from exo.worker.runner.bootstrap import logger
|
||||
|
||||
_engine_ref: LLMEngine | None = None
|
||||
_prefix_cache_ref: KVPrefixCache | None = None
|
||||
_overlapping: bool = True
|
||||
_on_status_change: Callable[[bool], None] | None = None
|
||||
_connector_patched: bool = False
|
||||
_gdn_patched: bool = False
|
||||
_gdn_states: dict[int, dict[str, torch.Tensor]] = {}
|
||||
_gdn_layer_order: list[int] = []
|
||||
_gdn_call_idx: list[int] = [0]
|
||||
_ssm_call_idx: list[int] = [0]
|
||||
|
||||
|
||||
def _patch_vllm_for_connector(connector_class: type[Any]) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
global _connector_patched
|
||||
if _connector_patched:
|
||||
return
|
||||
_connector_patched = True
|
||||
|
||||
from vllm.v1.core import kv_cache_utils
|
||||
|
||||
original_unify = kv_cache_utils.unify_hybrid_kv_cache_specs # type: ignore
|
||||
|
||||
def patched_unify(kv_cache_spec: Any) -> None: # pyright: ignore[reportAny]
|
||||
with contextlib.suppress(ValueError):
|
||||
original_unify(kv_cache_spec)
|
||||
|
||||
kv_cache_utils.unify_hybrid_kv_cache_specs = patched_unify # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
from vllm.v1.core.sched import ( # pyright: ignore[reportMissingImports]
|
||||
scheduler as sched_mod, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
def patched_connector_finished(_self: Any, _request: Any) -> tuple[bool, Any]: # pyright: ignore[reportAny]
|
||||
return False, None
|
||||
|
||||
sched_mod.Scheduler._connector_finished = patched_connector_finished # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector import ( # pyright: ignore[reportMissingImports]
|
||||
factory, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
original_get = factory.KVConnectorFactory._get_connector_class_with_compat # type: ignore
|
||||
|
||||
@classmethod
|
||||
def patched_get(cls: Any, kv_transfer_config: Any) -> tuple[Any, Any]: # pyright: ignore[reportAny]
|
||||
kv_conn = getattr(kv_transfer_config, "kv_connector", None) or "" # pyright: ignore[reportAny]
|
||||
if "streaming_connector" in kv_conn or "batch_connector" in kv_conn:
|
||||
return connector_class, None
|
||||
return original_get.__func__(cls, kv_transfer_config) # type: ignore
|
||||
|
||||
factory.KVConnectorFactory._get_connector_class_with_compat = patched_get # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
|
||||
def _patch_gdn_capture() -> None:
|
||||
global _gdn_patched
|
||||
if _gdn_patched:
|
||||
return
|
||||
_gdn_patched = True
|
||||
|
||||
try:
|
||||
import vllm.model_executor.layers.mamba.ops.causal_conv1d as cc_mod # type: ignore
|
||||
from vllm.model_executor.layers.mamba.ops.causal_conv1d import (
|
||||
causal_conv1d_fn as orig_fn, # type: ignore
|
||||
)
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
def patched_fn(*args: Any, conv_states: Any = None, cache_indices: Any = None, **kwargs: Any) -> Any:
|
||||
result = orig_fn(*args, conv_states=conv_states, cache_indices=cache_indices, **kwargs) # type: ignore
|
||||
if conv_states is not None and cache_indices is not None:
|
||||
x = args[0] if args else None
|
||||
if x is not None and x.shape[0] <= 100: # type: ignore
|
||||
return result
|
||||
ci: int = cache_indices[0].item() if cache_indices.numel() > 0 else 0 # type: ignore
|
||||
idx = _gdn_call_idx[0]
|
||||
if _gdn_layer_order and idx < len(_gdn_layer_order) * 100:
|
||||
layer_idx = _gdn_layer_order[idx % len(_gdn_layer_order)]
|
||||
conv_at_ci = conv_states[ci : ci + 1].transpose(-1, -2).contiguous().cpu() # type: ignore
|
||||
_gdn_states.setdefault(layer_idx, {})["conv"] = conv_at_ci
|
||||
_gdn_states[layer_idx]["ci"] = ci # type: ignore
|
||||
_gdn_call_idx[0] += 1
|
||||
return result
|
||||
|
||||
cc_mod.causal_conv1d_fn = patched_fn # type: ignore
|
||||
import sys
|
||||
for mod in list(sys.modules.values()):
|
||||
if mod is None or mod is cc_mod:
|
||||
continue
|
||||
if hasattr(mod, "causal_conv1d_fn") and mod.causal_conv1d_fn is orig_fn:
|
||||
mod.causal_conv1d_fn = patched_fn
|
||||
logger.info("Patched causal_conv1d_fn for GDN state capture")
|
||||
|
||||
try:
|
||||
from vllm.model_executor.models import qwen3_next as qn_mod # type: ignore
|
||||
|
||||
orig_chunk = getattr(qn_mod, "fi_chunk_gated_delta_rule", None) # type: ignore
|
||||
if orig_chunk is None:
|
||||
return
|
||||
|
||||
def patched_chunk(*args: Any, **kwargs: Any) -> Any:
|
||||
result = orig_chunk(*args, **kwargs)
|
||||
output_final_state = kwargs.get("output_final_state", False)
|
||||
if output_final_state and isinstance(result, tuple) and len(result) == 2:
|
||||
_, ssm_state = result
|
||||
idx = _ssm_call_idx[0]
|
||||
if _gdn_layer_order and idx < len(_gdn_layer_order) * 100:
|
||||
layer_idx = _gdn_layer_order[idx % len(_gdn_layer_order)]
|
||||
_gdn_states.setdefault(layer_idx, {})["ssm"] = ssm_state.cpu() # type: ignore
|
||||
_ssm_call_idx[0] += 1
|
||||
return result
|
||||
|
||||
qn_mod.fi_chunk_gated_delta_rule = patched_chunk # type: ignore
|
||||
|
||||
orig_fla_chunk = getattr(qn_mod, "fla_chunk_gated_delta_rule", None) # type: ignore
|
||||
if orig_fla_chunk is not None:
|
||||
def patched_fla_chunk(*args: Any, **kwargs: Any) -> Any:
|
||||
result = orig_fla_chunk(*args, **kwargs)
|
||||
output_final_state = kwargs.get("output_final_state", False)
|
||||
if output_final_state and isinstance(result, tuple) and len(result) == 2:
|
||||
_, ssm_state = result
|
||||
idx = _ssm_call_idx[0]
|
||||
if _gdn_layer_order and idx < len(_gdn_layer_order) * 100:
|
||||
layer_idx = _gdn_layer_order[idx % len(_gdn_layer_order)]
|
||||
_gdn_states.setdefault(layer_idx, {})["ssm"] = ssm_state.cpu() # type: ignore
|
||||
_ssm_call_idx[0] += 1
|
||||
return result
|
||||
qn_mod.fla_chunk_gated_delta_rule = patched_fla_chunk # type: ignore
|
||||
|
||||
logger.info("Patched chunk_gated_delta_rule for SSM state capture")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def _init_gdn_layer_order() -> None:
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
|
||||
model_runner = get_model_runner()
|
||||
if model_runner is None:
|
||||
return
|
||||
kv_caches = model_runner.kv_caches # type: ignore
|
||||
_gdn_layer_order.clear()
|
||||
for li in range(len(kv_caches)): # type: ignore
|
||||
kv = kv_caches[li] # type: ignore
|
||||
if isinstance(kv, (list, tuple)) and len(kv) > 1:
|
||||
_gdn_layer_order.append(li)
|
||||
if _gdn_layer_order:
|
||||
logger.info(f"GDN layer order: {_gdn_layer_order} ({len(_gdn_layer_order)} layers)")
|
||||
|
||||
|
||||
def _get_layer_info(engine: LLMEngine) -> tuple[int, str, list[dict[str, Any]]]:
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
|
||||
model_runner = get_model_runner()
|
||||
assert model_runner is not None
|
||||
kv_caches = model_runner.kv_caches
|
||||
num_layers: int = len(kv_caches)
|
||||
|
||||
layers_info: list[dict[str, Any]] = []
|
||||
for li in range(num_layers):
|
||||
kv = kv_caches[li]
|
||||
if isinstance(kv, (list, tuple)) and len(kv) > 1:
|
||||
layers_info.append({"type": "arrays", "sizes": [2]})
|
||||
else:
|
||||
sample = kv[0] if isinstance(kv, (list, tuple)) else kv
|
||||
n_heads: int = sample.shape[-2]
|
||||
head_dim: int = sample.shape[-1]
|
||||
layers_info.append({"type": "kv", "n_heads": n_heads, "head_dim": head_dim})
|
||||
|
||||
dtype_str = "bfloat16"
|
||||
return num_layers, dtype_str, layers_info
|
||||
|
||||
|
||||
def _run_prefill_overlapping(engine: LLMEngine, token_ids: list[int], start_pos: int, wfile: Any) -> None: # pyright: ignore[reportAny]
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
|
||||
model_runner = get_model_runner()
|
||||
assert model_runner is not None
|
||||
|
||||
from exo.disaggregated.streaming_connector import (
|
||||
get_shared_arrays_queue,
|
||||
get_shared_queue,
|
||||
reset_shared_queue,
|
||||
)
|
||||
|
||||
reset_shared_queue()
|
||||
_gdn_states.clear()
|
||||
_gdn_call_idx[0] = 0
|
||||
_ssm_call_idx[0] = 0
|
||||
layer_queue = get_shared_queue()
|
||||
arrays_queue = get_shared_arrays_queue()
|
||||
|
||||
server_cached = 0
|
||||
cached_data: TorchKVCache | None = None
|
||||
if _prefix_cache_ref is not None:
|
||||
cached_data, server_cached, _ = _prefix_cache_ref.lookup(token_ids)
|
||||
if not isinstance(cached_data, TorchKVCache):
|
||||
cached_data = None
|
||||
server_cached = 0
|
||||
skip_tokens = max(0, start_pos - server_cached)
|
||||
|
||||
num_layers, dtype_str, layers_info = _get_layer_info(engine)
|
||||
write_header(wfile, {"num_layers": num_layers, "dtype": dtype_str, "layers": layers_info}) # pyright: ignore[reportAny]
|
||||
|
||||
if cached_data is not None and start_pos < server_cached:
|
||||
from exo.worker.engines.vllm.kv_cache import ArraysLayerState
|
||||
kv_sent = 0
|
||||
arr_sent = 0
|
||||
for i, layer in enumerate(cached_data.layers):
|
||||
if isinstance(layer, KVLayerState) and layer.keys.numel() > 0:
|
||||
keys = layer.keys
|
||||
values = layer.values
|
||||
if keys.shape != values.shape:
|
||||
logger.warning(f"Skipping layer {i}: keys={list(keys.shape)} != values={list(values.shape)}")
|
||||
continue
|
||||
if keys.dim() == 4:
|
||||
keys = keys.reshape(-1, keys.shape[-2], keys.shape[-1])
|
||||
values = values.reshape(-1, values.shape[-2], values.shape[-1])
|
||||
keys = keys[start_pos:server_cached]
|
||||
values = values[start_pos:server_cached]
|
||||
if keys.numel() > 0:
|
||||
write_kv_chunk(wfile, i, keys, values) # pyright: ignore[reportAny]
|
||||
kv_sent += 1
|
||||
|
||||
elif isinstance(layer, ArraysLayerState):
|
||||
arrays = [a for a in layer.arrays if a is not None]
|
||||
if arrays:
|
||||
write_arrays_state(wfile, i, arrays) # pyright: ignore[reportAny]
|
||||
arr_sent += 1
|
||||
logger.info(f"Sent cached: {kv_sent} KV, {arr_sent} arrays for positions {start_pos}-{server_cached}")
|
||||
|
||||
from vllm.sampling_params import (
|
||||
SamplingParams,
|
||||
)
|
||||
|
||||
prefill_token_ids = token_ids[:-2] if len(token_ids) > 2 else token_ids
|
||||
request_id = f"prefill-{time.monotonic_ns()}"
|
||||
params = SamplingParams(max_tokens=2, detokenize=False) # pyright: ignore[reportCallIssue]
|
||||
engine.add_request(request_id, {"prompt_token_ids": prefill_token_ids}, params) # pyright: ignore[reportArgumentType]
|
||||
|
||||
chunks_sent = [0]
|
||||
layer_token_counts: dict[int, int] = {}
|
||||
all_kv_chunks: list[tuple[int, torch.Tensor, torch.Tensor]] = []
|
||||
|
||||
def writer_loop() -> None:
|
||||
while True:
|
||||
item = layer_queue.get()
|
||||
if item is None:
|
||||
break
|
||||
layer_idx, keys, values = item
|
||||
all_kv_chunks.append((layer_idx, keys, values))
|
||||
|
||||
prev = layer_token_counts.get(layer_idx, 0)
|
||||
n = keys.shape[0]
|
||||
new_total = prev + n
|
||||
layer_token_counts[layer_idx] = new_total
|
||||
|
||||
if new_total <= skip_tokens:
|
||||
continue
|
||||
if prev < skip_tokens:
|
||||
trim = skip_tokens - prev
|
||||
keys = keys[trim:]
|
||||
values = values[trim:]
|
||||
if chunks_sent[0] == 0:
|
||||
logger.info(f"First KV chunk: layer={layer_idx} keys={keys.shape} keys.dtype={keys.dtype} values.dtype={values.dtype}")
|
||||
write_kv_chunk(wfile, layer_idx, keys, values) # pyright: ignore[reportAny]
|
||||
chunks_sent[0] += 1
|
||||
|
||||
writer_thread = threading.Thread(target=writer_loop, daemon=True)
|
||||
writer_thread.start()
|
||||
|
||||
while engine.has_unfinished_requests():
|
||||
outputs = engine.step()
|
||||
for output in outputs:
|
||||
if output.request_id == request_id and output.outputs[0].token_ids:
|
||||
engine.abort_request([request_id]) # type: ignore
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
layer_queue.put(None)
|
||||
writer_thread.join()
|
||||
actual_per_layer = max(layer_token_counts.values()) if layer_token_counts else 0
|
||||
cached_tokens_sent = max(0, server_cached - start_pos) if cached_data is not None and start_pos < server_cached else 0
|
||||
tokens_sent = cached_tokens_sent + max(0, actual_per_layer - skip_tokens)
|
||||
logger.info(f"Overlapping prefill: sent {chunks_sent[0]} chunks, {tokens_sent} tokens (server_cached={server_cached}, skip={skip_tokens})")
|
||||
|
||||
while not arrays_queue.empty():
|
||||
item = arrays_queue.get_nowait()
|
||||
if item is not None:
|
||||
layer_idx, arrays = item
|
||||
write_arrays_state(wfile, layer_idx, arrays) # pyright: ignore[reportAny]
|
||||
|
||||
gdn_snapshot: list[tuple[int, list[torch.Tensor]]] = []
|
||||
for layer_idx in sorted(_gdn_states.keys()):
|
||||
state = _gdn_states[layer_idx]
|
||||
arrs: list[torch.Tensor] = []
|
||||
if "conv" in state:
|
||||
arrs.append(state["conv"])
|
||||
if "ssm" in state:
|
||||
arrs.append(state["ssm"])
|
||||
if arrs:
|
||||
gdn_snapshot.append((layer_idx, arrs))
|
||||
|
||||
cached_arrays: list[tuple[int, list[torch.Tensor]]] = []
|
||||
_stream_gdn_states_and_collect(engine, wfile, num_layers, layers_info, cached_arrays)
|
||||
write_done(wfile, tokens_sent) # pyright: ignore[reportAny]
|
||||
|
||||
connector_cache = _build_torch_cache(all_kv_chunks, gdn_snapshot, num_layers)
|
||||
threading.Thread(target=_store_prefix_cache, args=(prefill_token_ids, connector_cache), daemon=True).start()
|
||||
|
||||
|
||||
def _run_prefill_batch(engine: LLMEngine, token_ids: list[int], start_pos: int, wfile: Any) -> None: # pyright: ignore[reportAny]
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
|
||||
num_layers, dtype_str, layers_info = _get_layer_info(engine)
|
||||
|
||||
model_runner = get_model_runner()
|
||||
assert model_runner is not None
|
||||
|
||||
from exo.disaggregated.batch_connector import (
|
||||
clear_shared_captured_layers,
|
||||
get_shared_captured_arrays,
|
||||
get_shared_captured_layers,
|
||||
)
|
||||
|
||||
_gdn_states.clear()
|
||||
_gdn_call_idx[0] = 0
|
||||
clear_shared_captured_layers()
|
||||
captured_layers = get_shared_captured_layers()
|
||||
captured_arrays = get_shared_captured_arrays()
|
||||
|
||||
server_cached = 0
|
||||
if _prefix_cache_ref is not None:
|
||||
_, server_cached, _ = _prefix_cache_ref.lookup(token_ids)
|
||||
skip_tokens = max(0, start_pos - server_cached)
|
||||
|
||||
from vllm.sampling_params import (
|
||||
SamplingParams,
|
||||
)
|
||||
|
||||
prefill_token_ids = token_ids[:-2] if len(token_ids) > 2 else token_ids
|
||||
request_id = f"prefill-{time.monotonic_ns()}"
|
||||
params = SamplingParams(max_tokens=2, detokenize=False) # pyright: ignore[reportCallIssue]
|
||||
engine.add_request(request_id, {"prompt_token_ids": prefill_token_ids}, params) # pyright: ignore[reportArgumentType]
|
||||
|
||||
while engine.has_unfinished_requests():
|
||||
outputs = engine.step()
|
||||
for output in outputs:
|
||||
if output.request_id == request_id and output.outputs[0].token_ids:
|
||||
engine.abort_request([request_id]) # type: ignore
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
write_header(wfile, {"num_layers": num_layers, "dtype": dtype_str, "layers": layers_info}) # pyright: ignore[reportAny]
|
||||
|
||||
all_kv: list[tuple[int, torch.Tensor, torch.Tensor]] = []
|
||||
for layer_idx in sorted(captured_layers.keys()):
|
||||
layer_data = captured_layers[layer_idx]
|
||||
keys = layer_data["keys"]
|
||||
values = layer_data["values"]
|
||||
all_kv.append((layer_idx, keys, values))
|
||||
if keys.shape[0] > skip_tokens:
|
||||
write_kv_chunk(wfile, layer_idx, keys[skip_tokens:], values[skip_tokens:]) # pyright: ignore[reportAny]
|
||||
|
||||
actual_per_layer = max((k.shape[0] for _, k, _ in all_kv), default=0)
|
||||
tokens_sent = max(0, actual_per_layer - skip_tokens)
|
||||
logger.info(f"Batch prefill: {len(all_kv)} layers, {tokens_sent} tokens sent (server_cached={server_cached}, skip={skip_tokens}, captured={actual_per_layer})")
|
||||
|
||||
batch_arrays: list[tuple[int, list[torch.Tensor]]] = list(captured_arrays.items())
|
||||
for layer_idx, arrs in batch_arrays:
|
||||
write_arrays_state(wfile, layer_idx, arrs) # pyright: ignore[reportAny]
|
||||
clear_shared_captured_layers()
|
||||
|
||||
cached_arrays: list[tuple[int, list[torch.Tensor]]] = []
|
||||
_stream_gdn_states_and_collect(engine, wfile, num_layers, layers_info, cached_arrays)
|
||||
write_done(wfile, tokens_sent) # pyright: ignore[reportAny]
|
||||
|
||||
connector_cache = _build_torch_cache(all_kv, batch_arrays, num_layers)
|
||||
threading.Thread(target=_store_prefix_cache, args=(prefill_token_ids, connector_cache), daemon=True).start()
|
||||
|
||||
|
||||
def _stream_gdn_states_and_collect(
|
||||
_engine: LLMEngine,
|
||||
wfile: Any,
|
||||
num_layers: int,
|
||||
layers_info: list[dict[str, Any]],
|
||||
out_arrays: list[tuple[int, list[torch.Tensor]]],
|
||||
) -> None: # type: ignore
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
|
||||
if not _gdn_states:
|
||||
return
|
||||
|
||||
model_runner = get_model_runner()
|
||||
if model_runner is None:
|
||||
return
|
||||
|
||||
kv_caches = model_runner.kv_caches # type: ignore
|
||||
torch.cuda.synchronize()
|
||||
|
||||
for layer_idx in sorted(_gdn_states.keys()):
|
||||
try:
|
||||
state = _gdn_states[layer_idx]
|
||||
conv = state.get("conv")
|
||||
ssm = state.get("ssm")
|
||||
|
||||
arrays: list[torch.Tensor] = []
|
||||
if conv is not None:
|
||||
arrays.append(conv)
|
||||
if ssm is not None:
|
||||
arrays.append(ssm)
|
||||
if arrays:
|
||||
write_arrays_state(wfile, layer_idx, arrays) # type: ignore
|
||||
out_arrays.append((layer_idx, arrays))
|
||||
except Exception:
|
||||
logger.opt(exception=True).warning(f"Failed to capture GDN state for layer {layer_idx}")
|
||||
|
||||
_gdn_states.clear()
|
||||
_gdn_call_idx[0] = 0
|
||||
|
||||
|
||||
def _build_torch_cache(kv_chunks: list[tuple[int, torch.Tensor, torch.Tensor]], arrays_chunks: list[tuple[int, list[torch.Tensor]]], num_layers: int) -> TorchKVCache:
|
||||
from exo.worker.engines.vllm.kv_cache import ArraysLayerState
|
||||
|
||||
layers_by_idx: dict[int, KVLayerState | ArraysLayerState] = {}
|
||||
for layer_idx, keys, values in kv_chunks:
|
||||
if layer_idx in layers_by_idx:
|
||||
prev = layers_by_idx[layer_idx]
|
||||
if isinstance(prev, KVLayerState):
|
||||
layers_by_idx[layer_idx] = KVLayerState(
|
||||
keys=torch.cat([prev.keys, keys], dim=0), # type: ignore
|
||||
values=torch.cat([prev.values, values], dim=0), # type: ignore
|
||||
)
|
||||
else:
|
||||
layers_by_idx[layer_idx] = KVLayerState(keys=keys, values=values)
|
||||
for layer_idx, arrays in arrays_chunks:
|
||||
layers_by_idx[layer_idx] = ArraysLayerState(arrays=[a if isinstance(a, torch.Tensor) else None for a in arrays])
|
||||
|
||||
ordered: list[KVLayerState | ArraysLayerState] = []
|
||||
for i in range(num_layers):
|
||||
if i in layers_by_idx:
|
||||
ordered.append(layers_by_idx[i])
|
||||
else:
|
||||
ordered.append(KVLayerState(keys=torch.empty(0), values=torch.empty(0)))
|
||||
return TorchKVCache(ordered)
|
||||
|
||||
|
||||
def _extract_vllm_cache(engine: LLMEngine, request_id: str, num_tokens: int) -> TorchKVCache | None:
|
||||
try:
|
||||
from exo.worker.engines.vllm.vllm_generator import _save_prefix_cache
|
||||
from exo.worker.engines.vllm.growable_cache import get_model_runner
|
||||
from exo.worker.engines.vllm.vllm_generator import _build_layer_groups
|
||||
model_runner = get_model_runner()
|
||||
if model_runner is None:
|
||||
return None
|
||||
engine_core = engine.engine_core.engine_core # type: ignore
|
||||
coordinator = engine_core.scheduler.kv_cache_manager.coordinator # type: ignore
|
||||
kv_cache_config = engine_core.scheduler.kv_cache_manager.kv_cache_config # type: ignore
|
||||
|
||||
internal_id: str | None = None
|
||||
for mgr in coordinator.single_type_managers: # type: ignore
|
||||
for key in mgr.req_to_blocks: # type: ignore
|
||||
if str(key).startswith(request_id): # type: ignore
|
||||
internal_id = str(key) # type: ignore
|
||||
break
|
||||
if internal_id:
|
||||
break
|
||||
if internal_id is None:
|
||||
return None
|
||||
|
||||
null_block = coordinator.block_pool.null_block # type: ignore
|
||||
block_ids_per_group: list[list[int]] = []
|
||||
token_offset_per_group: list[int] = []
|
||||
block_sizes_per_group: list[int] = []
|
||||
for mgr in coordinator.single_type_managers: # type: ignore
|
||||
blocks = mgr.req_to_blocks.get(internal_id) # type: ignore
|
||||
if not blocks:
|
||||
block_ids_per_group.append([])
|
||||
token_offset_per_group.append(0)
|
||||
block_sizes_per_group.append(0)
|
||||
continue
|
||||
block_size: int = mgr.block_size # type: ignore
|
||||
block_sizes_per_group.append(block_size)
|
||||
num_leading_nulls = 0
|
||||
for b in blocks: # type: ignore
|
||||
if b is null_block or b.is_null: # type: ignore
|
||||
num_leading_nulls += 1
|
||||
else:
|
||||
break
|
||||
real_blocks = [b for b in blocks if b is not null_block and not b.is_null] # type: ignore
|
||||
block_ids_per_group.append([b.block_id for b in real_blocks]) # type: ignore
|
||||
token_offset_per_group.append(num_leading_nulls * block_size)
|
||||
|
||||
layer_to_group = _build_layer_groups(kv_cache_config)
|
||||
return TorchKVCache.from_vllm_cache(
|
||||
model_runner.kv_caches, # type: ignore
|
||||
block_ids_per_group,
|
||||
layer_to_group,
|
||||
num_tokens,
|
||||
token_offset_per_group,
|
||||
block_sizes_per_group,
|
||||
)
|
||||
except Exception:
|
||||
logger.opt(exception=True).warning("Failed to extract vLLM cache")
|
||||
return None
|
||||
|
||||
|
||||
def _store_prefix_cache(token_ids: list[int], torch_cache: TorchKVCache) -> None:
|
||||
if _prefix_cache_ref is None:
|
||||
return
|
||||
try:
|
||||
before = len(_prefix_cache_ref.prompts)
|
||||
_prefix_cache_ref.add_from_torch(token_ids, torch_cache)
|
||||
after = len(_prefix_cache_ref.prompts)
|
||||
if after > before:
|
||||
logger.info(f"Server prefix cache: saved {len(token_ids)} tokens (entries: {before} → {after})")
|
||||
except Exception:
|
||||
logger.opt(exception=True).warning("Failed to store prefix cache")
|
||||
|
||||
|
||||
def _check_cache(token_ids: list[int]) -> TorchKVCache | None:
|
||||
if _prefix_cache_ref is None:
|
||||
return None
|
||||
import mlx.core as mx
|
||||
|
||||
prompt_arr = mx.array(token_ids)
|
||||
best_index: int | None = None
|
||||
best_length = 0
|
||||
for i, cached_prompt in enumerate(_prefix_cache_ref.prompts):
|
||||
prefix_len = min(len(cached_prompt), len(prompt_arr))
|
||||
if prefix_len == 0:
|
||||
continue
|
||||
match_len = int(mx.sum(cached_prompt[:prefix_len] == prompt_arr[:prefix_len]).item()) # pyright: ignore[reportAny]
|
||||
if match_len == len(token_ids) and match_len == len(cached_prompt) and match_len > best_length:
|
||||
best_index = i
|
||||
best_length = match_len
|
||||
|
||||
if best_index is None:
|
||||
return None
|
||||
|
||||
cached = _prefix_cache_ref.caches[best_index]
|
||||
if isinstance(cached, TorchKVCache):
|
||||
return cached
|
||||
return None
|
||||
|
||||
|
||||
def _send_cached(torch_cache: TorchKVCache, token_ids: list[int], wfile: Any, engine: LLMEngine) -> None:
|
||||
num_layers, dtype_str, layers_info = _get_layer_info(engine)
|
||||
write_header(wfile, {"num_layers": num_layers, "dtype": dtype_str, "layers": layers_info}) # type: ignore
|
||||
from exo.worker.engines.vllm.kv_cache import ArraysLayerState
|
||||
|
||||
kv_sent = 0
|
||||
arr_sent = 0
|
||||
for i, layer in enumerate(torch_cache.layers):
|
||||
if isinstance(layer, KVLayerState) and layer.keys.numel() > 0:
|
||||
write_kv_chunk(wfile, i, layer.keys, layer.values) # type: ignore
|
||||
kv_sent += 1
|
||||
elif isinstance(layer, ArraysLayerState):
|
||||
arrays = [a for a in layer.arrays if a is not None]
|
||||
if arrays:
|
||||
write_arrays_state(wfile, i, arrays) # type: ignore
|
||||
arr_sent += 1
|
||||
logger.info(f"_send_cached: sent {kv_sent} KV layers, {arr_sent} arrays layers")
|
||||
write_done(wfile, len(token_ids)) # type: ignore
|
||||
|
||||
|
||||
class _PrefillHandler(socketserver.StreamRequestHandler):
|
||||
def setup(self) -> None:
|
||||
super().setup()
|
||||
self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # type: ignore
|
||||
self.request.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * 1024 * 1024) # type: ignore
|
||||
|
||||
def handle(self) -> None:
|
||||
try:
|
||||
line = self.rfile.readline()
|
||||
if not line:
|
||||
return
|
||||
request: dict[str, Any] = json.loads(line.decode("utf-8")) # pyright: ignore[reportAny]
|
||||
token_ids: list[int] = request["token_ids"] # pyright: ignore[reportAny]
|
||||
start_pos: int = request.get("start_pos", 0) # pyright: ignore[reportAny]
|
||||
|
||||
engine = _engine_ref
|
||||
if engine is None:
|
||||
error = json.dumps({"error": "No engine loaded"}).encode("utf-8") + b"\n"
|
||||
self.wfile.write(error)
|
||||
return
|
||||
|
||||
if engine.has_unfinished_requests():
|
||||
error = json.dumps({"error": "Engine busy"}).encode("utf-8") + b"\n"
|
||||
self.wfile.write(error)
|
||||
return
|
||||
|
||||
logger.info(f"Prefill request: {len(token_ids)} tokens, start_pos={start_pos}, overlapping={_overlapping}")
|
||||
t0 = time.perf_counter()
|
||||
|
||||
if _on_status_change:
|
||||
_on_status_change(True)
|
||||
try:
|
||||
if _overlapping:
|
||||
_run_prefill_overlapping(engine, token_ids, start_pos, self.wfile)
|
||||
else:
|
||||
_run_prefill_batch(engine, token_ids, start_pos, self.wfile)
|
||||
finally:
|
||||
if _on_status_change:
|
||||
_on_status_change(False)
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
logger.info(f"Prefill complete: {len(token_ids)} tokens in {elapsed*1000:.0f}ms ({len(token_ids)/elapsed:.0f} tok/s)")
|
||||
except Exception:
|
||||
logger.opt(exception=True).error("Prefill handler error")
|
||||
|
||||
|
||||
def start_prefill_server(
|
||||
engine: LLMEngine,
|
||||
bind_address: str,
|
||||
port: int,
|
||||
overlapping: bool = True,
|
||||
prefix_cache: KVPrefixCache | None = None,
|
||||
on_status_change: Callable[[bool], None] | None = None,
|
||||
) -> socketserver.ThreadingTCPServer:
|
||||
global _engine_ref, _overlapping, _prefix_cache_ref, _on_status_change
|
||||
_engine_ref = engine
|
||||
_overlapping = overlapping
|
||||
_prefix_cache_ref = prefix_cache
|
||||
_on_status_change = on_status_change
|
||||
|
||||
_patch_gdn_capture()
|
||||
_init_gdn_layer_order()
|
||||
|
||||
server = socketserver.ThreadingTCPServer((bind_address, port), _PrefillHandler)
|
||||
server.daemon_threads = True
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
logger.info(f"Prefill TCP server started on {bind_address}:{port} (overlapping={overlapping})")
|
||||
return server
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import BinaryIO
|
||||
|
||||
import torch
|
||||
|
||||
MSG_KV_CHUNK: int = 0x01
|
||||
MSG_ARRAYS_STATE: int = 0x02
|
||||
MSG_DONE: int = 0x03
|
||||
|
||||
|
||||
@dataclass
|
||||
class KVChunk:
|
||||
layer_idx: int
|
||||
num_tokens: int
|
||||
keys: torch.Tensor
|
||||
values: torch.Tensor
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArraysState:
|
||||
layer_idx: int
|
||||
arrays: list[torch.Tensor]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Done:
|
||||
total_tokens: int
|
||||
|
||||
|
||||
Message = KVChunk | ArraysState | Done
|
||||
|
||||
|
||||
def _write_exactly(stream: BinaryIO, data: bytes) -> None:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
|
||||
|
||||
def _read_exactly(stream: BinaryIO, n: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = stream.read(n - len(buf))
|
||||
if not chunk:
|
||||
if len(buf) == 0:
|
||||
return b""
|
||||
raise ConnectionError(f"Connection closed after {len(buf)}/{n} bytes")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def _str_to_dtype(s: str) -> torch.dtype:
|
||||
return {
|
||||
"float16": torch.float16,
|
||||
"bfloat16": torch.bfloat16,
|
||||
"float32": torch.float32,
|
||||
}[s]
|
||||
|
||||
|
||||
def _dtype_size(dtype: torch.dtype) -> int:
|
||||
return {torch.float16: 2, torch.bfloat16: 2, torch.float32: 4}[dtype]
|
||||
|
||||
|
||||
def write_header(stream: BinaryIO, header: dict[str, object]) -> None:
|
||||
payload = json.dumps(header).encode("utf-8")
|
||||
_write_exactly(stream, struct.pack(">I", len(payload)))
|
||||
_write_exactly(stream, payload)
|
||||
|
||||
|
||||
def _tensor_to_bytes(t: torch.Tensor) -> bytes:
|
||||
if t.dtype == torch.bfloat16:
|
||||
return t.contiguous().view(torch.int16).numpy().tobytes() # type: ignore
|
||||
return t.contiguous().numpy().tobytes() # type: ignore
|
||||
|
||||
|
||||
def write_kv_chunk(stream: BinaryIO, layer_idx: int, keys: torch.Tensor, values: torch.Tensor) -> None:
|
||||
if keys.dim() == 4:
|
||||
keys = keys.reshape(-1, keys.shape[-2], keys.shape[-1])
|
||||
values = values.reshape(-1, values.shape[-2], values.shape[-1])
|
||||
keys_bytes = _tensor_to_bytes(keys)
|
||||
values_bytes = _tensor_to_bytes(values)
|
||||
num_tokens: int = keys.shape[0]
|
||||
n_heads: int = keys.shape[1]
|
||||
head_dim: int = keys.shape[2]
|
||||
header = struct.pack(">BIIII", MSG_KV_CHUNK, layer_idx, num_tokens, n_heads, head_dim)
|
||||
_write_exactly(stream, header + keys_bytes + values_bytes)
|
||||
|
||||
|
||||
def _dtype_to_str(dtype: torch.dtype) -> str:
|
||||
return {torch.float16: "float16", torch.bfloat16: "bfloat16", torch.float32: "float32"}[dtype]
|
||||
|
||||
|
||||
def write_arrays_state(stream: BinaryIO, layer_idx: int, arrays: list[torch.Tensor]) -> None:
|
||||
buf = io.BytesIO()
|
||||
buf.write(struct.pack(">BI", MSG_ARRAYS_STATE, layer_idx))
|
||||
buf.write(struct.pack(">I", len(arrays)))
|
||||
for arr in arrays:
|
||||
dtype_str = _dtype_to_str(arr.dtype).encode("utf-8")
|
||||
buf.write(struct.pack(">I", len(dtype_str)))
|
||||
buf.write(dtype_str)
|
||||
shape: tuple[int, ...] = tuple(arr.shape)
|
||||
buf.write(struct.pack(">I", len(shape)))
|
||||
for dim in shape:
|
||||
buf.write(struct.pack(">I", dim))
|
||||
buf.write(_tensor_to_bytes(arr))
|
||||
_write_exactly(stream, buf.getvalue())
|
||||
|
||||
|
||||
def write_done(stream: BinaryIO, total_tokens: int) -> None:
|
||||
_write_exactly(stream, struct.pack(">BI", MSG_DONE, total_tokens))
|
||||
|
||||
|
||||
def read_header(stream: BinaryIO) -> dict[str, object]:
|
||||
raw = _read_exactly(stream, 4)
|
||||
if not raw:
|
||||
raise ConnectionError("No header received")
|
||||
length: int = struct.unpack(">I", raw)[0] # pyright: ignore[reportAny]
|
||||
payload = _read_exactly(stream, length)
|
||||
return json.loads(payload.decode("utf-8")) # pyright: ignore[reportAny]
|
||||
|
||||
|
||||
def read_message(stream: BinaryIO, header: dict[str, object]) -> Message | None:
|
||||
type_byte = _read_exactly(stream, 1)
|
||||
if not type_byte:
|
||||
return None
|
||||
msg_type = type_byte[0]
|
||||
|
||||
if msg_type == MSG_KV_CHUNK:
|
||||
layer_idx: int
|
||||
num_tokens: int
|
||||
n_heads: int
|
||||
head_dim: int
|
||||
layer_idx, num_tokens, n_heads, head_dim = struct.unpack(">IIII", _read_exactly(stream, 16)) # pyright: ignore[reportAny]
|
||||
dtype = _str_to_dtype(str(header["dtype"]))
|
||||
elem_size = _dtype_size(dtype)
|
||||
tensor_bytes: int = num_tokens * n_heads * head_dim * elem_size
|
||||
keys_raw = _read_exactly(stream, tensor_bytes)
|
||||
values_raw = _read_exactly(stream, tensor_bytes)
|
||||
shape = (num_tokens, n_heads, head_dim)
|
||||
if dtype == torch.bfloat16:
|
||||
keys: torch.Tensor = torch.frombuffer(bytearray(keys_raw), dtype=torch.int16).view(torch.bfloat16).reshape(shape).clone() # type: ignore
|
||||
values: torch.Tensor = torch.frombuffer(bytearray(values_raw), dtype=torch.int16).view(torch.bfloat16).reshape(shape).clone() # type: ignore
|
||||
else:
|
||||
keys = torch.frombuffer(bytearray(keys_raw), dtype=dtype).reshape(shape).clone() # type: ignore
|
||||
values = torch.frombuffer(bytearray(values_raw), dtype=dtype).reshape(shape).clone() # type: ignore
|
||||
return KVChunk(layer_idx=layer_idx, num_tokens=num_tokens, keys=keys, values=values) # pyright: ignore[reportUnknownArgumentType]
|
||||
|
||||
if msg_type == MSG_ARRAYS_STATE:
|
||||
arr_layer_idx: int
|
||||
num_arrays: int
|
||||
arr_layer_idx, = struct.unpack(">I", _read_exactly(stream, 4)) # pyright: ignore[reportAny]
|
||||
num_arrays, = struct.unpack(">I", _read_exactly(stream, 4)) # pyright: ignore[reportAny]
|
||||
fallback_dtype = _str_to_dtype(str(header["dtype"]))
|
||||
arrays: list[torch.Tensor] = []
|
||||
for _ in range(num_arrays):
|
||||
dtype_len_raw = _read_exactly(stream, 4)
|
||||
dtype_len: int = struct.unpack(">I", dtype_len_raw)[0] # pyright: ignore[reportAny]
|
||||
if dtype_len > 0 and dtype_len < 20:
|
||||
dtype_str_bytes = _read_exactly(stream, dtype_len)
|
||||
arr_dtype = _str_to_dtype(dtype_str_bytes.decode("utf-8"))
|
||||
else:
|
||||
arr_dtype = fallback_dtype
|
||||
elem_size = _dtype_size(arr_dtype)
|
||||
ndim: int
|
||||
ndim, = struct.unpack(">I", _read_exactly(stream, 4)) # pyright: ignore[reportAny]
|
||||
shape_arr = struct.unpack(f">{ndim}I", _read_exactly(stream, ndim * 4))
|
||||
total_elems = 1
|
||||
for d in shape_arr: # pyright: ignore[reportAny]
|
||||
total_elems *= d # pyright: ignore[reportAny]
|
||||
raw = _read_exactly(stream, total_elems * elem_size)
|
||||
if arr_dtype == torch.bfloat16:
|
||||
t: torch.Tensor = torch.frombuffer(bytearray(raw), dtype=torch.int16).view(torch.bfloat16).reshape(shape_arr).clone() # type: ignore
|
||||
else:
|
||||
t = torch.frombuffer(bytearray(raw), dtype=arr_dtype).reshape(shape_arr).clone() # type: ignore
|
||||
arrays.append(t) # pyright: ignore[reportUnknownArgumentType]
|
||||
return ArraysState(layer_idx=arr_layer_idx, arrays=arrays)
|
||||
|
||||
if msg_type == MSG_DONE:
|
||||
total_tokens: int
|
||||
total_tokens, = struct.unpack(">I", _read_exactly(stream, 4)) # pyright: ignore[reportAny]
|
||||
return Done(total_tokens=total_tokens)
|
||||
|
||||
raise ValueError(f"Unknown message type: {msg_type:#x}")
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import ( # pyright: ignore[reportMissingImports]
|
||||
KVConnectorBase_V1, # pyright: ignore[reportUnknownVariableType]
|
||||
KVConnectorMetadata, # pyright: ignore[reportUnknownVariableType]
|
||||
KVConnectorRole, # pyright: ignore[reportUnknownVariableType]
|
||||
SupportsHMA, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
_LAYER_RE = re.compile(r"layers\.(\d+)\.")
|
||||
|
||||
|
||||
def _to_nhd(t: torch.Tensor) -> torch.Tensor:
|
||||
if os.environ.get("VLLM_KV_CACHE_LAYOUT", "HND") == "HND" and t.dim() == 3:
|
||||
return t.permute(1, 0, 2)
|
||||
return t
|
||||
|
||||
|
||||
def _to_bf16(t: torch.Tensor) -> torch.Tensor:
|
||||
if t.dtype == torch.uint8:
|
||||
t = t.view(torch.float8_e4m3fn) # type: ignore
|
||||
if t.dtype in (torch.float8_e4m3fn, torch.float8_e5m2): # type: ignore
|
||||
return t.to(torch.float32).to(torch.bfloat16)
|
||||
if t.dtype in (torch.bfloat16, torch.float16, torch.float32):
|
||||
return t
|
||||
return t.to(torch.bfloat16)
|
||||
|
||||
_shared_queue: queue.Queue[tuple[int, torch.Tensor, torch.Tensor] | None] = queue.Queue()
|
||||
_shared_arrays_queue: queue.Queue[tuple[int, list[torch.Tensor]] | None] = queue.Queue()
|
||||
|
||||
|
||||
def get_shared_queue() -> queue.Queue[tuple[int, torch.Tensor, torch.Tensor] | None]:
|
||||
return _shared_queue
|
||||
|
||||
|
||||
def get_shared_arrays_queue() -> queue.Queue[tuple[int, list[torch.Tensor]] | None]:
|
||||
return _shared_arrays_queue
|
||||
|
||||
|
||||
def reset_shared_queue() -> None:
|
||||
while not _shared_queue.empty():
|
||||
try:
|
||||
_shared_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
while not _shared_arrays_queue.empty():
|
||||
try:
|
||||
_shared_arrays_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamingConnectorMetadata(KVConnectorMetadata): # pyright: ignore[reportUntypedBaseClass]
|
||||
pass
|
||||
|
||||
|
||||
class StreamingConnector(KVConnectorBase_V1, SupportsHMA): # pyright: ignore[reportUntypedBaseClass]
|
||||
_queue: queue.Queue[tuple[int, torch.Tensor, torch.Tensor] | None]
|
||||
|
||||
_save_count: int = 0
|
||||
|
||||
def __init__(self, vllm_config: Any, role: KVConnectorRole, kv_cache_config: Any = None) -> None: # type: ignore
|
||||
super().__init__(vllm_config, role, kv_cache_config) # pyright: ignore[reportUnknownMemberType]
|
||||
self._queue = _shared_queue
|
||||
|
||||
@property
|
||||
def layer_queue(self) -> queue.Queue[tuple[int, torch.Tensor, torch.Tensor] | None]:
|
||||
return self._queue
|
||||
|
||||
def start_load_kv(self, forward_context: Any, **kwargs: Any) -> None: # pyright: ignore[reportAny]
|
||||
pass
|
||||
|
||||
def wait_for_layer_load(self, layer_name: str) -> None:
|
||||
pass
|
||||
|
||||
def save_kv_layer(self, layer_name: str, kv_layer: Any, attn_metadata: Any, **kwargs: Any) -> None: # pyright: ignore[reportAny]
|
||||
slot_mapping = getattr(attn_metadata, "slot_mapping", None) # pyright: ignore[reportAny]
|
||||
if slot_mapping is not None and slot_mapping.shape[0] <= 100: # pyright: ignore[reportAny]
|
||||
return
|
||||
|
||||
m = _LAYER_RE.search(layer_name)
|
||||
if m is None:
|
||||
return
|
||||
layer_idx = int(m.group(1))
|
||||
|
||||
if isinstance(kv_layer, (list, tuple)):
|
||||
arrays = [_to_bf16(t).cpu() for t in kv_layer] # pyright: ignore[reportAny]
|
||||
_shared_arrays_queue.put((layer_idx, arrays))
|
||||
return
|
||||
|
||||
if self._save_count < 1:
|
||||
self._save_count += 1
|
||||
|
||||
if slot_mapping is not None:
|
||||
if kv_layer.shape[0] == 2: # pyright: ignore[reportAny]
|
||||
k_all = _to_nhd(kv_layer[0]) # pyright: ignore[reportAny]
|
||||
v_all = _to_nhd(kv_layer[1]) # pyright: ignore[reportAny]
|
||||
else:
|
||||
k_all = _to_nhd(kv_layer[:, 0]) # pyright: ignore[reportAny]
|
||||
v_all = _to_nhd(kv_layer[:, 1]) # pyright: ignore[reportAny]
|
||||
k_flat = k_all.reshape(-1, *k_all.shape[-2:]) # pyright: ignore[reportAny]
|
||||
v_flat = v_all.reshape(-1, *v_all.shape[-2:]) # pyright: ignore[reportAny]
|
||||
valid = slot_mapping >= 0 # pyright: ignore[reportAny]
|
||||
safe_sm = slot_mapping.clamp(min=0) # pyright: ignore[reportAny]
|
||||
keys = k_flat[safe_sm][valid] # pyright: ignore[reportAny]
|
||||
values = v_flat[safe_sm][valid] # pyright: ignore[reportAny]
|
||||
keys = _to_bf16(keys) # pyright: ignore[reportAny]
|
||||
values = _to_bf16(values) # pyright: ignore[reportAny]
|
||||
self._queue.put((layer_idx, keys.cpu(), values.cpu())) # pyright: ignore[reportAny]
|
||||
else:
|
||||
self._queue.put((layer_idx, kv_layer.cpu().clone(), kv_layer.cpu().clone())) # pyright: ignore[reportAny]
|
||||
|
||||
def wait_for_save(self) -> None:
|
||||
pass
|
||||
|
||||
def finish(self) -> None:
|
||||
self._queue.put(None)
|
||||
|
||||
def request_finished_all_groups(self, request: Any, block_ids: tuple[list[int], ...]) -> tuple[bool, dict[str, Any] | None]: # pyright: ignore[reportAny]
|
||||
return False, None
|
||||
|
||||
def get_num_new_matched_tokens(self, request: Any, num_computed_tokens: int) -> tuple[int, bool]: # pyright: ignore[reportAny]
|
||||
return 0, False
|
||||
|
||||
def update_state_after_alloc(self, request: Any, blocks: Any, num_external_tokens: int) -> None: # pyright: ignore[reportAny]
|
||||
pass
|
||||
|
||||
def build_connector_meta(self, scheduler_output: Any) -> StreamingConnectorMetadata: # pyright: ignore[reportAny]
|
||||
return StreamingConnectorMetadata()
|
||||
@@ -744,33 +744,19 @@ async def download_shard(
|
||||
logger.debug(f"Downloading {shard.model_card.model_id=} with {allow_patterns=}")
|
||||
|
||||
all_start_time = time.time()
|
||||
try:
|
||||
file_list = await fetch_file_list_with_cache(
|
||||
shard.model_card.model_id,
|
||||
revision,
|
||||
recursive=True,
|
||||
skip_internet=skip_internet,
|
||||
on_connection_lost=on_connection_lost,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
not_started_progress = RepoDownloadProgress(
|
||||
repo_id=str(shard.model_card.model_id),
|
||||
repo_revision=revision,
|
||||
shard=shard,
|
||||
completed_files=0,
|
||||
total_files=0,
|
||||
downloaded=Memory.from_bytes(0),
|
||||
downloaded_this_session=Memory.from_bytes(0),
|
||||
total=Memory.from_bytes(0),
|
||||
overall_speed=0.0,
|
||||
overall_eta=timedelta(0),
|
||||
status="not_started",
|
||||
file_progress={},
|
||||
)
|
||||
return target_dir, not_started_progress
|
||||
file_list = await fetch_file_list_with_cache(
|
||||
shard.model_card.model_id,
|
||||
revision,
|
||||
recursive=True,
|
||||
skip_internet=skip_internet,
|
||||
on_connection_lost=on_connection_lost,
|
||||
)
|
||||
filtered_file_list = list(
|
||||
filter_repo_objects(
|
||||
file_list, allow_patterns=allow_patterns, key=lambda x: x.path
|
||||
file_list,
|
||||
allow_patterns=allow_patterns,
|
||||
ignore_patterns=["original/*", "metal/*"],
|
||||
key=lambda x: x.path,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+11
-28
@@ -11,9 +11,9 @@ from loguru import logger
|
||||
from pydantic import PositiveInt
|
||||
|
||||
import exo.routing.topics as topics
|
||||
from exo.api.main import API
|
||||
from exo.download.coordinator import DownloadCoordinator
|
||||
from exo.download.impl_shard_downloader import exo_shard_downloader
|
||||
from exo.master.api import API # TODO: should API be in master?
|
||||
from exo.master.main import Master
|
||||
from exo.routing.event_router import EventRouter
|
||||
from exo.routing.router import Router, get_node_id_keypair
|
||||
@@ -47,11 +47,7 @@ class Node:
|
||||
keypair = get_node_id_keypair()
|
||||
node_id = NodeId(keypair.to_node_id())
|
||||
session_id = SessionId(master_node_id=node_id, election_clock=0)
|
||||
router = Router.create(
|
||||
keypair,
|
||||
bootstrap_peers=args.bootstrap_peers,
|
||||
listen_port=args.libp2p_port,
|
||||
)
|
||||
router = Router.create(keypair)
|
||||
await router.register_topic(topics.GLOBAL_EVENTS)
|
||||
await router.register_topic(topics.LOCAL_EVENTS)
|
||||
await router.register_topic(topics.COMMANDS)
|
||||
@@ -268,21 +264,20 @@ def main():
|
||||
mp.set_start_method("spawn", force=True)
|
||||
# TODO: Refactor the current verbosity system
|
||||
logger_setup(EXO_LOG, args.verbosity)
|
||||
logger.info(f"{'=' * 40}")
|
||||
logger.info(f"Starting EXO | pid={os.getpid()}")
|
||||
logger.info(f"{'=' * 40}")
|
||||
logger.info("Starting EXO")
|
||||
logger.info(f"EXO_LIBP2P_NAMESPACE: {os.getenv('EXO_LIBP2P_NAMESPACE')}")
|
||||
|
||||
if args.offline:
|
||||
logger.info("Running in OFFLINE mode — no internet checks, local models only")
|
||||
|
||||
if args.bootstrap_peers:
|
||||
logger.info(f"Bootstrap peers: {args.bootstrap_peers}")
|
||||
|
||||
if args.no_batch:
|
||||
os.environ["EXO_NO_BATCH"] = "1"
|
||||
logger.info("Continuous batching disabled (--no-batch)")
|
||||
|
||||
if args.no_overlapping_prefill_sends:
|
||||
os.environ["EXO_NO_OVERLAPPING_PREFILL_SENDS"] = "1"
|
||||
logger.info("Overlapping prefill sends disabled (--no-overlapping-prefill-sends)")
|
||||
|
||||
# Set FAST_SYNCH override env var for runner subprocesses
|
||||
if args.fast_synch is True:
|
||||
os.environ["EXO_FAST_SYNCH"] = "on"
|
||||
@@ -314,9 +309,8 @@ class Args(CamelCaseModel):
|
||||
no_downloads: bool = False
|
||||
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
|
||||
no_batch: bool = False
|
||||
no_overlapping_prefill_sends: bool = False
|
||||
fast_synch: bool | None = None # None = auto, True = force on, False = force off
|
||||
bootstrap_peers: list[str] = []
|
||||
libp2p_port: int
|
||||
|
||||
@classmethod
|
||||
def parse(cls) -> Self:
|
||||
@@ -375,20 +369,9 @@ class Args(CamelCaseModel):
|
||||
help="Disable continuous batching, use sequential generation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bootstrap-peers",
|
||||
type=lambda s: [p for p in s.split(",") if p],
|
||||
default=os.getenv("EXO_BOOTSTRAP_PEERS", "").split(",")
|
||||
if os.getenv("EXO_BOOTSTRAP_PEERS")
|
||||
else [],
|
||||
dest="bootstrap_peers",
|
||||
help="Comma-separated libp2p multiaddrs to dial on startup (env: EXO_BOOTSTRAP_PEERS)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--libp2p-port",
|
||||
type=int,
|
||||
default=0,
|
||||
dest="libp2p_port",
|
||||
help="Fixed TCP port for libp2p to listen on (0 = OS-assigned).",
|
||||
"--no-overlapping-prefill-sends",
|
||||
action="store_true",
|
||||
help="Disable overlapping KV transfer during disaggregated prefill",
|
||||
)
|
||||
fast_synch_group = parser.add_mutually_exclusive_group()
|
||||
fast_synch_group.add_argument(
|
||||
|
||||
+2
-6
@@ -4,7 +4,7 @@ import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from exo.api.types import (
|
||||
from exo.shared.types.api import (
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionMessageText,
|
||||
@@ -107,6 +107,7 @@ def chat_request_to_text_generation(
|
||||
min_p=request.min_p,
|
||||
repetition_penalty=request.repetition_penalty,
|
||||
repetition_context_size=request.repetition_context_size,
|
||||
prefill_endpoints=request.prefill_endpoints,
|
||||
)
|
||||
|
||||
|
||||
@@ -202,8 +203,6 @@ async def generate_chat_stream(
|
||||
usage=last_usage,
|
||||
)
|
||||
yield f"data: {tool_response.model_dump_json()}\n\n"
|
||||
if chunk.stats is not None:
|
||||
yield f": generation_stats {chunk.stats.model_dump_json()}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
@@ -218,10 +217,7 @@ async def generate_chat_stream(
|
||||
yield f"data: {chunk_response.model_dump_json()}\n\n"
|
||||
|
||||
if chunk.finish_reason is not None:
|
||||
if chunk.stats is not None:
|
||||
yield f": generation_stats {chunk.stats.model_dump_json()}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
|
||||
async def collect_chat_response(
|
||||
@@ -5,8 +5,14 @@ import re
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from exo.api.types import FinishReason, Usage
|
||||
from exo.api.types.claude_api import (
|
||||
from exo.shared.types.api import FinishReason, Usage
|
||||
from exo.shared.types.chunks import (
|
||||
ErrorChunk,
|
||||
PrefillProgressChunk,
|
||||
TokenChunk,
|
||||
ToolCallChunk,
|
||||
)
|
||||
from exo.shared.types.claude_api import (
|
||||
ClaudeContentBlock,
|
||||
ClaudeContentBlockDeltaEvent,
|
||||
ClaudeContentBlockStartEvent,
|
||||
@@ -29,12 +35,6 @@ from exo.api.types.claude_api import (
|
||||
ClaudeToolUseBlock,
|
||||
ClaudeUsage,
|
||||
)
|
||||
from exo.shared.types.chunks import (
|
||||
ErrorChunk,
|
||||
PrefillProgressChunk,
|
||||
TokenChunk,
|
||||
ToolCallChunk,
|
||||
)
|
||||
from exo.shared.types.common import CommandId
|
||||
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from exo.api.types.ollama_api import (
|
||||
from exo.shared.types.chunks import (
|
||||
ErrorChunk,
|
||||
PrefillProgressChunk,
|
||||
TokenChunk,
|
||||
ToolCallChunk,
|
||||
)
|
||||
from exo.shared.types.common import CommandId
|
||||
from exo.shared.types.ollama_api import (
|
||||
OllamaChatRequest,
|
||||
OllamaChatResponse,
|
||||
OllamaDoneReason,
|
||||
@@ -14,13 +19,6 @@ from exo.api.types.ollama_api import (
|
||||
OllamaToolCall,
|
||||
OllamaToolFunction,
|
||||
)
|
||||
from exo.shared.types.chunks import (
|
||||
ErrorChunk,
|
||||
PrefillProgressChunk,
|
||||
TokenChunk,
|
||||
ToolCallChunk,
|
||||
)
|
||||
from exo.shared.types.common import CommandId
|
||||
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
|
||||
|
||||
|
||||
@@ -4,8 +4,15 @@ from collections.abc import AsyncGenerator
|
||||
from itertools import count
|
||||
from typing import Any
|
||||
|
||||
from exo.api.types import Usage
|
||||
from exo.api.types.openai_responses import (
|
||||
from exo.shared.types.api import Usage
|
||||
from exo.shared.types.chunks import (
|
||||
ErrorChunk,
|
||||
PrefillProgressChunk,
|
||||
TokenChunk,
|
||||
ToolCallChunk,
|
||||
)
|
||||
from exo.shared.types.common import CommandId
|
||||
from exo.shared.types.openai_responses import (
|
||||
FunctionCallInputItem,
|
||||
ResponseCompletedEvent,
|
||||
ResponseContentPart,
|
||||
@@ -35,13 +42,6 @@ from exo.api.types.openai_responses import (
|
||||
ResponseTextDoneEvent,
|
||||
ResponseUsage,
|
||||
)
|
||||
from exo.shared.types.chunks import (
|
||||
ErrorChunk,
|
||||
PrefillProgressChunk,
|
||||
TokenChunk,
|
||||
ToolCallChunk,
|
||||
)
|
||||
from exo.shared.types.common import CommandId
|
||||
from exo.shared.types.text_generation import (
|
||||
InputMessage,
|
||||
TextGenerationTaskParams,
|
||||
@@ -21,17 +21,17 @@ from hypercorn.config import Config
|
||||
from hypercorn.typing import ASGIFramework
|
||||
from loguru import logger
|
||||
|
||||
from exo.api.adapters.chat_completions import (
|
||||
from exo.master.adapters.chat_completions import (
|
||||
chat_request_to_text_generation,
|
||||
collect_chat_response,
|
||||
generate_chat_stream,
|
||||
)
|
||||
from exo.api.adapters.claude import (
|
||||
from exo.master.adapters.claude import (
|
||||
claude_request_to_text_generation,
|
||||
collect_claude_response,
|
||||
generate_claude_stream,
|
||||
)
|
||||
from exo.api.adapters.ollama import (
|
||||
from exo.master.adapters.ollama import (
|
||||
collect_ollama_chat_response,
|
||||
collect_ollama_generate_response,
|
||||
generate_ollama_chat_stream,
|
||||
@@ -39,12 +39,34 @@ from exo.api.adapters.ollama import (
|
||||
ollama_generate_request_to_text_generation,
|
||||
ollama_request_to_text_generation,
|
||||
)
|
||||
from exo.api.adapters.responses import (
|
||||
from exo.master.adapters.responses import (
|
||||
collect_responses_response,
|
||||
generate_responses_stream,
|
||||
responses_request_to_text_generation,
|
||||
)
|
||||
from exo.api.types import (
|
||||
from exo.master.event_log import DiskEventLog
|
||||
from exo.master.image_store import ImageStore
|
||||
from exo.master.placement import place_instance as get_instance_placements
|
||||
from exo.shared.apply import apply
|
||||
from exo.shared.constants import (
|
||||
DASHBOARD_DIR,
|
||||
EXO_CACHE_HOME,
|
||||
EXO_EVENT_LOG_DIR,
|
||||
EXO_IMAGE_CACHE_DIR,
|
||||
EXO_MAX_CHUNK_SIZE,
|
||||
EXO_TRACING_CACHE_DIR,
|
||||
)
|
||||
from exo.shared.election import ElectionMessage
|
||||
from exo.shared.logging import InterceptLogger
|
||||
from exo.shared.models.model_cards import (
|
||||
ModelCard,
|
||||
ModelId,
|
||||
delete_custom_card,
|
||||
get_model_cards,
|
||||
is_custom_card,
|
||||
)
|
||||
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
|
||||
from exo.shared.types.api import (
|
||||
AddCustomModelParams,
|
||||
AdvancedImageParams,
|
||||
BenchChatCompletionRequest,
|
||||
@@ -92,47 +114,6 @@ from exo.api.types import (
|
||||
TraceStatsResponse,
|
||||
normalize_image_size,
|
||||
)
|
||||
from exo.api.types.claude_api import (
|
||||
ClaudeMessagesRequest,
|
||||
ClaudeMessagesResponse,
|
||||
)
|
||||
from exo.api.types.ollama_api import (
|
||||
OllamaChatRequest,
|
||||
OllamaChatResponse,
|
||||
OllamaGenerateRequest,
|
||||
OllamaGenerateResponse,
|
||||
OllamaModelDetails,
|
||||
OllamaModelTag,
|
||||
OllamaPsModel,
|
||||
OllamaPsResponse,
|
||||
OllamaShowRequest,
|
||||
OllamaShowResponse,
|
||||
OllamaTagsResponse,
|
||||
)
|
||||
from exo.api.types.openai_responses import (
|
||||
ResponsesRequest,
|
||||
ResponsesResponse,
|
||||
)
|
||||
from exo.master.image_store import ImageStore
|
||||
from exo.master.placement import place_instance as get_instance_placements
|
||||
from exo.shared.apply import apply
|
||||
from exo.shared.constants import (
|
||||
DASHBOARD_DIR,
|
||||
EXO_CACHE_HOME,
|
||||
EXO_EVENT_LOG_DIR,
|
||||
EXO_IMAGE_CACHE_DIR,
|
||||
EXO_MAX_CHUNK_SIZE,
|
||||
EXO_TRACING_CACHE_DIR,
|
||||
)
|
||||
from exo.shared.election import ElectionMessage
|
||||
from exo.shared.logging import InterceptLogger
|
||||
from exo.shared.models.model_cards import (
|
||||
ModelCard,
|
||||
ModelId,
|
||||
get_card,
|
||||
get_model_cards,
|
||||
)
|
||||
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
|
||||
from exo.shared.types.chunks import (
|
||||
ErrorChunk,
|
||||
ImageChunk,
|
||||
@@ -141,11 +122,13 @@ from exo.shared.types.chunks import (
|
||||
TokenChunk,
|
||||
ToolCallChunk,
|
||||
)
|
||||
from exo.shared.types.claude_api import (
|
||||
ClaudeMessagesRequest,
|
||||
ClaudeMessagesResponse,
|
||||
)
|
||||
from exo.shared.types.commands import (
|
||||
AddCustomModelCard,
|
||||
Command,
|
||||
CreateInstance,
|
||||
DeleteCustomModelCard,
|
||||
DeleteDownload,
|
||||
DeleteInstance,
|
||||
DownloadCommand,
|
||||
@@ -168,13 +151,29 @@ from exo.shared.types.events import (
|
||||
TracesMerged,
|
||||
)
|
||||
from exo.shared.types.memory import Memory
|
||||
from exo.shared.types.ollama_api import (
|
||||
OllamaChatRequest,
|
||||
OllamaChatResponse,
|
||||
OllamaGenerateRequest,
|
||||
OllamaGenerateResponse,
|
||||
OllamaModelDetails,
|
||||
OllamaModelTag,
|
||||
OllamaPsModel,
|
||||
OllamaPsResponse,
|
||||
OllamaShowRequest,
|
||||
OllamaShowResponse,
|
||||
OllamaTagsResponse,
|
||||
)
|
||||
from exo.shared.types.openai_responses import (
|
||||
ResponsesRequest,
|
||||
ResponsesResponse,
|
||||
)
|
||||
from exo.shared.types.state import State
|
||||
from exo.shared.types.worker.downloads import DownloadCompleted
|
||||
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
|
||||
from exo.shared.types.worker.shards import Sharding
|
||||
from exo.utils.banner import print_startup_banner
|
||||
from exo.utils.channels import Receiver, Sender, channel
|
||||
from exo.utils.disk_event_log import DiskEventLog
|
||||
from exo.utils.power_sampler import PowerSampler
|
||||
from exo.utils.task_group import TaskGroup
|
||||
|
||||
@@ -342,6 +341,7 @@ class API:
|
||||
self.app.get("/ollama/api/version")(self.ollama_version)
|
||||
|
||||
self.app.get("/state")(lambda: self.state)
|
||||
self.app.get("/capabilities")(self._get_capabilities)
|
||||
self.app.get("/events")(self.stream_events)
|
||||
self.app.post("/download/start")(self.start_download)
|
||||
self.app.delete("/download/{node_id}/{model_id:path}")(self.delete_download)
|
||||
@@ -414,6 +414,7 @@ class API:
|
||||
node_network=self.state.node_network,
|
||||
topology=self.state.topology,
|
||||
current_instances=self.state.instances,
|
||||
node_vllm=self.state.node_vllm,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -449,18 +450,15 @@ class API:
|
||||
status_code=400, detail=f"Failed to load model card: {exc}"
|
||||
) from exc
|
||||
instance_combinations: list[tuple[Sharding, InstanceMeta, int]] = []
|
||||
node_count = len(list(self.state.topology.list_nodes()))
|
||||
|
||||
for sharding in (Sharding.Pipeline, Sharding.Tensor):
|
||||
for instance_meta in (InstanceMeta.MlxRing, InstanceMeta.MlxJaccl):
|
||||
instance_combinations.extend(
|
||||
[
|
||||
(sharding, instance_meta, i)
|
||||
for i in range(
|
||||
1, len(list(self.state.topology.list_nodes())) + 1
|
||||
)
|
||||
]
|
||||
[(sharding, instance_meta, i) for i in range(1, node_count + 1)]
|
||||
)
|
||||
# TODO: PDD
|
||||
# instance_combinations.append((Sharding.PrefillDecodeDisaggregation, InstanceMeta.MlxRing, 1))
|
||||
if any(self.state.node_vllm.values()):
|
||||
instance_combinations.append((Sharding.Pipeline, InstanceMeta.Vllm, 1))
|
||||
|
||||
for sharding, instance_meta, min_nodes in instance_combinations:
|
||||
try:
|
||||
@@ -473,6 +471,7 @@ class API:
|
||||
),
|
||||
node_memory=self.state.node_memory,
|
||||
node_network=self.state.node_network,
|
||||
node_vllm=self.state.node_vllm,
|
||||
topology=self.state.topology,
|
||||
current_instances=self.state.instances,
|
||||
required_nodes=required_nodes,
|
||||
@@ -742,7 +741,9 @@ class API:
|
||||
)
|
||||
task_params = task_params.model_copy(update={"model": resolved_model})
|
||||
|
||||
task_params = task_params.model_copy(update={"stream": False, "bench": True})
|
||||
task_params = task_params.model_copy(
|
||||
update={"stream": False, "bench": True, **({"disaggregated_bench": True} if payload.disaggregated else {})}
|
||||
)
|
||||
|
||||
command = TextGeneration(task_params=task_params)
|
||||
await self._send(command)
|
||||
@@ -750,20 +751,25 @@ class API:
|
||||
return await self._collect_text_generation_with_stats(command.command_id)
|
||||
|
||||
async def _resolve_and_validate_text_model(self, model_id: ModelId) -> ModelId:
|
||||
"""Validate a text model exists and return the resolved model ID.
|
||||
from exo.shared.models.model_cards import derive_base_model
|
||||
|
||||
Raises HTTPException 404 if no instance is found for the model.
|
||||
"""
|
||||
if not any(
|
||||
if any(
|
||||
instance.shard_assignments.model_id == model_id
|
||||
for instance in self.state.instances.values()
|
||||
):
|
||||
await self._trigger_notify_user_to_download_model(model_id)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No instance found for model {model_id}",
|
||||
)
|
||||
return model_id
|
||||
return model_id
|
||||
|
||||
request_base = derive_base_model(str(model_id))
|
||||
for instance in self.state.instances.values():
|
||||
first_shard = next(iter(instance.shard_assignments.runner_to_shard.values()), None)
|
||||
if first_shard is not None and first_shard.model_card.base_model.lower() == request_base.lower():
|
||||
return instance.shard_assignments.model_id
|
||||
|
||||
await self._trigger_notify_user_to_download_model(model_id)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No instance found for model {model_id}",
|
||||
)
|
||||
|
||||
async def _validate_image_model(self, model: ModelId) -> ModelId:
|
||||
"""Validate model exists and return resolved model ID.
|
||||
@@ -782,6 +788,9 @@ class API:
|
||||
)
|
||||
return resolved_model
|
||||
|
||||
def _get_capabilities(self) -> dict[str, bool]:
|
||||
return {"vllm_available": any(self.state.node_vllm.values())}
|
||||
|
||||
def stream_events(self) -> StreamingResponse:
|
||||
def _generate_json_array(events: Iterable[Event]) -> Iterable[str]:
|
||||
yield "["
|
||||
@@ -1559,7 +1568,7 @@ class API:
|
||||
storage_size_megabytes=card.storage_size.in_mb,
|
||||
supports_tensor=card.supports_tensor,
|
||||
tasks=[task.value for task in card.tasks],
|
||||
is_custom=card.is_custom,
|
||||
is_custom=is_custom_card(card.model_id),
|
||||
family=card.family,
|
||||
quantization=card.quantization,
|
||||
base_model=card.base_model,
|
||||
@@ -1570,7 +1579,7 @@ class API:
|
||||
)
|
||||
|
||||
async def add_custom_model(self, payload: AddCustomModelParams) -> ModelListModel:
|
||||
"""Fetch a model from HuggingFace and save as a custom model card, then sync across the cluster."""
|
||||
"""Fetch a model from HuggingFace and save as a custom model card."""
|
||||
try:
|
||||
card = await ModelCard.fetch_from_hf(payload.model_id)
|
||||
except Exception as exc:
|
||||
@@ -1578,13 +1587,6 @@ class API:
|
||||
status_code=400, detail=f"Failed to fetch model: {exc}"
|
||||
) from exc
|
||||
|
||||
await self.command_sender.send(
|
||||
ForwarderCommand(
|
||||
origin=self._system_id,
|
||||
command=AddCustomModelCard(model_card=card),
|
||||
)
|
||||
)
|
||||
|
||||
return ModelListModel(
|
||||
id=card.model_id,
|
||||
hugging_face_id=card.model_id,
|
||||
@@ -1598,18 +1600,10 @@ class API:
|
||||
)
|
||||
|
||||
async def delete_custom_model(self, model_id: ModelId) -> JSONResponse:
|
||||
"""Delete a user-added custom model card and sync deletion across the cluster."""
|
||||
card = get_card(model_id)
|
||||
if card is None or not card.is_custom:
|
||||
"""Delete a user-added custom model card."""
|
||||
deleted = await delete_custom_card(model_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Custom model card not found")
|
||||
|
||||
await self.command_sender.send(
|
||||
ForwarderCommand(
|
||||
origin=self._system_id,
|
||||
command=DeleteCustomModelCard(model_id=model_id),
|
||||
)
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{"message": "Model card deleted", "model_id": str(model_id)}
|
||||
)
|
||||
+106
-35
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import anyio
|
||||
from loguru import logger
|
||||
|
||||
from exo.master.event_log import DiskEventLog
|
||||
from exo.master.placement import (
|
||||
add_instance_to_placements,
|
||||
cancel_unnecessary_downloads,
|
||||
@@ -13,9 +14,7 @@ from exo.master.placement import (
|
||||
from exo.shared.apply import apply
|
||||
from exo.shared.constants import EXO_EVENT_LOG_DIR, EXO_TRACING_ENABLED
|
||||
from exo.shared.types.commands import (
|
||||
AddCustomModelCard,
|
||||
CreateInstance,
|
||||
DeleteCustomModelCard,
|
||||
DeleteInstance,
|
||||
ForwarderCommand,
|
||||
ForwarderDownloadCommand,
|
||||
@@ -31,8 +30,6 @@ from exo.shared.types.commands import (
|
||||
)
|
||||
from exo.shared.types.common import CommandId, NodeId, SessionId, SystemId
|
||||
from exo.shared.types.events import (
|
||||
CustomModelCardAdded,
|
||||
CustomModelCardDeleted,
|
||||
Event,
|
||||
GlobalForwarderEvent,
|
||||
IndexedEvent,
|
||||
@@ -62,9 +59,9 @@ from exo.shared.types.tasks import (
|
||||
from exo.shared.types.tasks import (
|
||||
TextGeneration as TextGenerationTask,
|
||||
)
|
||||
from exo.shared.types.worker.instances import InstanceId
|
||||
from exo.shared.types.worker.instances import Instance, InstanceId, VllmInstance
|
||||
from exo.shared.types.worker.runners import RunnerReady, RunnerRunning
|
||||
from exo.utils.channels import Receiver, Sender
|
||||
from exo.utils.disk_event_log import DiskEventLog
|
||||
from exo.utils.event_buffer import MultiSourceBuffer
|
||||
from exo.utils.task_group import TaskGroup
|
||||
|
||||
@@ -97,8 +94,73 @@ class Master:
|
||||
self._pending_traces: dict[TaskId, dict[int, list[TraceEventData]]] = {}
|
||||
self._expected_ranks: dict[TaskId, set[int]] = {}
|
||||
|
||||
def _find_prefill_endpoints(self, decode_instance: Instance, decode_model_base: str) -> list[str]:
|
||||
from exo.master.placement_utils import (
|
||||
_find_ip_prioritised as find_ip_prioritised, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from exo.shared.models.model_cards import derive_base_model
|
||||
|
||||
endpoints: list[tuple[int, str]] = []
|
||||
vllm_instance_count = 0
|
||||
for instance in self.state.instances.values():
|
||||
if not isinstance(instance, VllmInstance):
|
||||
continue
|
||||
if instance.instance_id == decode_instance.instance_id:
|
||||
continue
|
||||
vllm_instance_count += 1
|
||||
first_shard = next(iter(instance.shard_assignments.runner_to_shard.values()), None)
|
||||
if first_shard is None:
|
||||
logger.info(f"Prefill routing: VllmInstance {instance.instance_id} has no shards")
|
||||
continue
|
||||
if derive_base_model(first_shard.model_card.base_model).lower() != decode_model_base.lower():
|
||||
logger.info(
|
||||
f"Prefill routing: VllmInstance {instance.instance_id} base_model "
|
||||
f"{first_shard.model_card.base_model!r} != decode {decode_model_base!r}"
|
||||
)
|
||||
continue
|
||||
|
||||
pass
|
||||
|
||||
for node_id, runner_id in instance.shard_assignments.node_to_runner.items():
|
||||
runner_status = self.state.runners.get(runner_id)
|
||||
if not isinstance(runner_status, (RunnerReady, RunnerRunning)):
|
||||
logger.info(f"Prefill routing: runner {runner_id} not ready ({type(runner_status).__name__})")
|
||||
continue
|
||||
port = runner_status.prefill_server_port
|
||||
if port is None:
|
||||
logger.info(f"Prefill routing: runner {runner_id} has no prefill_server_port")
|
||||
continue
|
||||
|
||||
decode_node = next(iter(decode_instance.shard_assignments.node_to_runner.keys()), None)
|
||||
if decode_node is None:
|
||||
continue
|
||||
|
||||
ip = find_ip_prioritised(decode_node, node_id, self.state.topology, self.state.node_network, ring=True)
|
||||
if ip is None:
|
||||
logger.info(f"Prefill routing: no IP route from {decode_node} to {node_id}")
|
||||
continue
|
||||
|
||||
ip_type = "unknown"
|
||||
node_net = self.state.node_network.get(node_id)
|
||||
if node_net:
|
||||
for iface in node_net.interfaces:
|
||||
if iface.ip_address == ip:
|
||||
ip_type = iface.interface_type
|
||||
break
|
||||
priority = {"thunderbolt": 0, "maybe_ethernet": 1, "ethernet": 2, "wifi": 3, "unknown": 4}.get(ip_type, 4)
|
||||
endpoints.append((priority, f"{ip}:{port}"))
|
||||
|
||||
if not endpoints:
|
||||
logger.info(
|
||||
f"Prefill routing: no endpoints found for base_model={decode_model_base!r} "
|
||||
f"(total VllmInstances in cluster: {vllm_instance_count})"
|
||||
)
|
||||
|
||||
endpoints.sort(key=lambda x: x[0])
|
||||
return [ep for _, ep in endpoints]
|
||||
|
||||
async def run(self):
|
||||
logger.info("Starting Master")
|
||||
logger.debug("Starting Master")
|
||||
|
||||
try:
|
||||
async with self._tg as tg:
|
||||
@@ -112,14 +174,14 @@ class Master:
|
||||
self.command_receiver.close()
|
||||
|
||||
async def shutdown(self):
|
||||
logger.info("Stopping Master")
|
||||
logger.debug("Stopping Master")
|
||||
self._tg.cancel_tasks()
|
||||
|
||||
async def _command_processor(self) -> None:
|
||||
with self.command_receiver as commands:
|
||||
async for forwarder_command in commands:
|
||||
try:
|
||||
logger.info(f"Executing command: {forwarder_command.command}")
|
||||
logger.debug(f"Executing command: {forwarder_command.command}")
|
||||
|
||||
generated_events: list[Event] = []
|
||||
command = forwarder_command.command
|
||||
@@ -128,19 +190,22 @@ class Master:
|
||||
case TestCommand():
|
||||
pass
|
||||
case TextGeneration():
|
||||
from exo.shared.models.model_cards import derive_base_model
|
||||
|
||||
request_base = derive_base_model(str(command.task_params.model))
|
||||
|
||||
for instance in self.state.instances.values():
|
||||
if (
|
||||
instance.shard_assignments.model_id
|
||||
== command.task_params.model
|
||||
):
|
||||
task_count = sum(
|
||||
1
|
||||
for task in self.state.tasks.values()
|
||||
if task.instance_id == instance.instance_id
|
||||
)
|
||||
instance_task_counts[instance.instance_id] = (
|
||||
task_count
|
||||
)
|
||||
exact_match = instance.shard_assignments.model_id == command.task_params.model
|
||||
first_shard = next(iter(instance.shard_assignments.runner_to_shard.values()), None)
|
||||
base_match = first_shard is not None and first_shard.model_card.base_model.lower() == request_base.lower()
|
||||
if not (exact_match or base_match):
|
||||
continue
|
||||
task_count = sum(
|
||||
1
|
||||
for task in self.state.tasks.values()
|
||||
if task.instance_id == instance.instance_id
|
||||
)
|
||||
instance_task_counts[instance.instance_id] = task_count
|
||||
|
||||
if not instance_task_counts:
|
||||
raise ValueError(
|
||||
@@ -149,12 +214,26 @@ class Master:
|
||||
|
||||
available_instance_ids = sorted(
|
||||
instance_task_counts.keys(),
|
||||
key=lambda instance_id: instance_task_counts[
|
||||
instance_id
|
||||
],
|
||||
key=lambda instance_id: (
|
||||
0 if not isinstance(self.state.instances[instance_id], VllmInstance) else 1,
|
||||
instance_task_counts[instance_id],
|
||||
),
|
||||
)
|
||||
|
||||
task_id = TaskId()
|
||||
decode_instance = self.state.instances[available_instance_ids[0]]
|
||||
logger.info(
|
||||
f"Decode routing: model={command.task_params.model} base={request_base} "
|
||||
f"instance={available_instance_ids[0]} type={type(decode_instance).__name__} "
|
||||
f"candidates={len(instance_task_counts)}"
|
||||
)
|
||||
task_params = command.task_params
|
||||
if not task_params.prefill_endpoints:
|
||||
prefill_eps = self._find_prefill_endpoints(decode_instance, request_base)
|
||||
logger.info(f"Prefill endpoints resolved: {prefill_eps}")
|
||||
if prefill_eps:
|
||||
task_params = task_params.model_copy(update={"prefill_endpoints": prefill_eps})
|
||||
|
||||
generated_events.append(
|
||||
TaskCreated(
|
||||
task_id=task_id,
|
||||
@@ -163,7 +242,7 @@ class Master:
|
||||
command_id=command.command_id,
|
||||
instance_id=available_instance_ids[0],
|
||||
task_status=TaskStatus.Pending,
|
||||
task_params=command.task_params,
|
||||
task_params=task_params,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -298,7 +377,7 @@ class Master:
|
||||
self.state.instances,
|
||||
self.state.node_memory,
|
||||
self.state.node_network,
|
||||
download_status=self.state.downloads,
|
||||
self.state.node_vllm,
|
||||
)
|
||||
transition_events = get_transition_events(
|
||||
self.state.instances, placement, self.state.tasks
|
||||
@@ -349,14 +428,6 @@ class Master:
|
||||
f"Finished command {command.finished_command_id} finished"
|
||||
)
|
||||
|
||||
case AddCustomModelCard():
|
||||
generated_events.append(
|
||||
CustomModelCardAdded(model_card=command.model_card)
|
||||
)
|
||||
case DeleteCustomModelCard():
|
||||
generated_events.append(
|
||||
CustomModelCardDeleted(model_id=command.model_id)
|
||||
)
|
||||
case RequestEventLog():
|
||||
# We should just be able to send everything, since other buffers will ignore old messages
|
||||
# rate limit to 1000 at a time
|
||||
@@ -388,7 +459,7 @@ class Master:
|
||||
for node_id, time in self.state.last_seen.items():
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if now - time > timedelta(seconds=30):
|
||||
logger.info(f"Manually removing node {node_id} due to inactivity")
|
||||
logger.debug(f"Manually removing node {node_id} due to inactivity")
|
||||
await self.event_sender.send(NodeTimedOut(node_id=node_id))
|
||||
|
||||
await anyio.sleep(10)
|
||||
|
||||
+48
-59
@@ -32,10 +32,7 @@ from exo.shared.types.memory import Memory
|
||||
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo
|
||||
from exo.shared.types.tasks import Task, TaskId, TaskStatus
|
||||
from exo.shared.types.worker.downloads import (
|
||||
DownloadCompleted,
|
||||
DownloadFailed,
|
||||
DownloadOngoing,
|
||||
DownloadPending,
|
||||
DownloadProgress,
|
||||
)
|
||||
from exo.shared.types.worker.instances import (
|
||||
@@ -44,6 +41,7 @@ from exo.shared.types.worker.instances import (
|
||||
InstanceMeta,
|
||||
MlxJacclInstance,
|
||||
MlxRingInstance,
|
||||
VllmInstance,
|
||||
)
|
||||
from exo.shared.types.worker.shards import Sharding
|
||||
|
||||
@@ -63,57 +61,50 @@ def add_instance_to_placements(
|
||||
return {**current_instances, command.instance.instance_id: command.instance}
|
||||
|
||||
|
||||
def _get_node_download_fraction(
|
||||
node_id: NodeId,
|
||||
model_id: ModelId,
|
||||
download_status: Mapping[NodeId, Sequence[DownloadProgress]],
|
||||
) -> float:
|
||||
"""Return the download fraction (0.0–1.0) for a model on a given node."""
|
||||
for progress in download_status.get(node_id, []):
|
||||
if progress.shard_metadata.model_card.model_id != model_id:
|
||||
continue
|
||||
match progress:
|
||||
case DownloadCompleted():
|
||||
return 1.0
|
||||
case DownloadOngoing():
|
||||
total = progress.download_progress.total.in_bytes
|
||||
return (
|
||||
progress.download_progress.downloaded.in_bytes / total
|
||||
if total > 0
|
||||
else 0.0
|
||||
)
|
||||
case DownloadPending():
|
||||
total = progress.total.in_bytes
|
||||
return progress.downloaded.in_bytes / total if total > 0 else 0.0
|
||||
case DownloadFailed():
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _cycle_download_score(
|
||||
cycle: Cycle,
|
||||
model_id: ModelId,
|
||||
download_status: Mapping[NodeId, Sequence[DownloadProgress]],
|
||||
) -> float:
|
||||
"""Sum of download fractions across all nodes in a cycle."""
|
||||
return sum(
|
||||
_get_node_download_fraction(node_id, model_id, download_status)
|
||||
for node_id in cycle
|
||||
)
|
||||
|
||||
|
||||
def place_instance(
|
||||
command: PlaceInstance,
|
||||
topology: Topology,
|
||||
current_instances: Mapping[InstanceId, Instance],
|
||||
node_memory: Mapping[NodeId, MemoryUsage],
|
||||
node_network: Mapping[NodeId, NodeNetworkInfo],
|
||||
node_vllm: Mapping[NodeId, bool],
|
||||
required_nodes: set[NodeId] | None = None,
|
||||
download_status: Mapping[NodeId, Sequence[DownloadProgress]] | None = None,
|
||||
) -> dict[InstanceId, Instance]:
|
||||
cycles = topology.get_cycles()
|
||||
candidate_cycles = list(filter(lambda it: len(it) >= command.min_nodes, cycles))
|
||||
|
||||
# vLLM instances can only be placed on nodes that have vLLM available.
|
||||
# vLLM does not support quantized mlx-community models (only bf16 or unquantized).
|
||||
if command.instance_meta == InstanceMeta.Vllm:
|
||||
is_mlx_community = str(command.model_card.model_id).startswith("mlx-community/")
|
||||
if is_mlx_community and command.model_card.quantization not in ("", "bf16"):
|
||||
raise ValueError("vLLM does not support quantized mlx-community models")
|
||||
candidate_cycles = [
|
||||
cycle
|
||||
for cycle in candidate_cycles
|
||||
if all(node_vllm.get(nid, False) for nid in cycle.node_ids)
|
||||
]
|
||||
|
||||
# QMM/quantized ops are not available on MLX CUDA — exclude CUDA nodes for quantized MLX models.
|
||||
if command.instance_meta in (InstanceMeta.MlxRing, InstanceMeta.MlxJaccl):
|
||||
if command.model_card.quantization not in ("", "bf16"):
|
||||
candidate_cycles = [
|
||||
cycle
|
||||
for cycle in candidate_cycles
|
||||
if not any(node_vllm.get(nid, False) for nid in cycle.node_ids)
|
||||
]
|
||||
|
||||
# mlx-community models should prefer Apple Silicon nodes over CUDA nodes.
|
||||
if command.instance_meta in (InstanceMeta.MlxRing, InstanceMeta.MlxJaccl):
|
||||
if str(command.model_card.model_id).startswith("mlx-community/"):
|
||||
apple_silicon_cycles = [
|
||||
cycle
|
||||
for cycle in candidate_cycles
|
||||
if not any(node_vllm.get(nid, False) for nid in cycle.node_ids)
|
||||
]
|
||||
if apple_silicon_cycles:
|
||||
candidate_cycles = apple_silicon_cycles
|
||||
|
||||
# Filter to cycles containing all required nodes (subset matching)
|
||||
if required_nodes:
|
||||
candidate_cycles = [
|
||||
@@ -121,8 +112,11 @@ def place_instance(
|
||||
for cycle in candidate_cycles
|
||||
if required_nodes.issubset(cycle.node_ids)
|
||||
]
|
||||
required_memory = command.model_card.storage_size
|
||||
if command.instance_meta == InstanceMeta.Vllm:
|
||||
required_memory = Memory.from_bytes(int(required_memory.in_bytes * 1.3))
|
||||
cycles_with_sufficient_memory = filter_cycles_by_memory(
|
||||
candidate_cycles, node_memory, command.model_card.storage_size
|
||||
candidate_cycles, node_memory, required_memory
|
||||
)
|
||||
if len(cycles_with_sufficient_memory) == 0:
|
||||
raise ValueError("No cycles found with sufficient memory")
|
||||
@@ -173,26 +167,16 @@ def place_instance(
|
||||
if any(topology.node_is_leaf(node_id) for node_id in cycle)
|
||||
]
|
||||
|
||||
resolved_download_status = download_status or {}
|
||||
candidate_cycles = (
|
||||
cycles_with_leaf_nodes if cycles_with_leaf_nodes != [] else smallest_cycles
|
||||
)
|
||||
|
||||
selected_cycle = max(
|
||||
candidate_cycles,
|
||||
key=lambda cycle: (
|
||||
_cycle_download_score(
|
||||
cycle, command.model_card.model_id, resolved_download_status
|
||||
),
|
||||
sum(
|
||||
(node_memory[node_id].ram_available for node_id in cycle),
|
||||
start=Memory(),
|
||||
),
|
||||
cycles_with_leaf_nodes if cycles_with_leaf_nodes != [] else smallest_cycles,
|
||||
key=lambda cycle: sum(
|
||||
(node_memory[node_id].ram_available for node_id in cycle),
|
||||
start=Memory(),
|
||||
),
|
||||
)
|
||||
|
||||
# Single-node: force Pipeline/Ring (Tensor and Jaccl require multi-node)
|
||||
if len(selected_cycle) == 1:
|
||||
if len(selected_cycle) == 1 and command.instance_meta != InstanceMeta.Vllm:
|
||||
command.instance_meta = InstanceMeta.MlxRing
|
||||
command.sharding = Sharding.Pipeline
|
||||
|
||||
@@ -252,6 +236,11 @@ def place_instance(
|
||||
hosts_by_node=hosts_by_node,
|
||||
ephemeral_port=ephemeral_port,
|
||||
)
|
||||
case InstanceMeta.Vllm:
|
||||
target_instances[instance_id] = VllmInstance(
|
||||
instance_id=instance_id,
|
||||
shard_assignments=shard_assignments,
|
||||
)
|
||||
|
||||
return target_instances
|
||||
|
||||
|
||||
+1
-2
@@ -4,11 +4,10 @@ from typing import Any
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from exo.api.main import API
|
||||
|
||||
|
||||
def test_http_exception_handler_formats_openai_style() -> None:
|
||||
"""Test that HTTPException is converted to OpenAI-style error format."""
|
||||
from exo.master.api import API
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
+1
-1
@@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from exo.api.main import API
|
||||
from exo.shared.types.common import CommandId
|
||||
|
||||
|
||||
def _make_api() -> Any:
|
||||
"""Create a minimal API instance with cancel route and error handler."""
|
||||
from exo.master.api import API
|
||||
|
||||
app = FastAPI()
|
||||
api = object.__new__(API)
|
||||
@@ -3,11 +3,11 @@
|
||||
import pydantic
|
||||
import pytest
|
||||
|
||||
from exo.api.adapters.claude import (
|
||||
from exo.master.adapters.claude import (
|
||||
claude_request_to_text_generation,
|
||||
finish_reason_to_claude_stop_reason,
|
||||
)
|
||||
from exo.api.types.claude_api import (
|
||||
from exo.shared.types.claude_api import (
|
||||
ClaudeMessage,
|
||||
ClaudeMessagesRequest,
|
||||
ClaudeTextBlock,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user