This commit is contained in:
Evan
2026-03-19 14:51:08 +00:00
parent a3ce437fd4
commit f538b44211
22 changed files with 5506 additions and 346 deletions
+14
View File
@@ -0,0 +1,14 @@
[project]
name = "exo-core"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Evan", email = "[email protected]" }
]
requires-python = ">=3.13"
dependencies = []
[build-system]
requires = ["uv_build>=0.9.24,<0.10.0"]
build-backend = "uv_build"
+65
View File
@@ -0,0 +1,65 @@
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable
from typing import Self
class TaskId(str): ...
class Cancelled: ...
class Finished: ...
CANCEL_ALL_TASKS = TaskId("CANCEL_TALL_TASKS")
class Engine[TaskType, ResponseType](ABC):
_cancelled_tasks: set[TaskId]
def should_cancel(self, task_id: TaskId) -> bool:
return (
task_id in self._cancelled_tasks
or CANCEL_ALL_TASKS in self._cancelled_tasks
)
def cancel_task(self, task_id: TaskId):
self._cancelled_tasks.add(task_id)
@abstractmethod
def warmup(self) -> None: ...
@abstractmethod
def submit(
self,
task: TaskType,
) -> None: ...
@abstractmethod
def step(
self,
) -> Iterable[
tuple[TaskId, ResponseType | Cancelled | Finished]
]: ...
@abstractmethod
def close(self) -> None: ...
class EngineBuilder[SetupType, TaskType, ResponseType](ABC):
@classmethod
@abstractmethod
def create(
cls,
bound_instance: SetupType,
) -> Self: ...
@abstractmethod
def connect(self) -> None: ...
@abstractmethod
def load(
self,
on_timeout: Callable[[], None],
on_layer_loaded: Callable[[int, int], None],
) -> None: ...
@abstractmethod
def build(self) -> Engine[TaskType, ResponseType]: ...
@abstractmethod
def close(self) -> None: ...
View File
@@ -1,5 +1,5 @@
[project]
name = "mlx-runner"
name = "mlx-engine"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
-6
View File
@@ -1,6 +0,0 @@
def main():
print("Hello from mlx-runner!")
if __name__ == "__main__":
main()
View File
View File
+38
View File
@@ -0,0 +1,38 @@
[project]
name = "vllm-runner"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"torch>=2.10.0; sys_platform == 'linux'",
"vllm>=0.13.0; sys_platform == 'linux' and platform_machine == 'aarch64'",
"mlx-cuda-13==0.30.6; sys_platform == 'linux'",
"fastsafetensors>=0.1.10; sys_platform == 'linux'",
]
[tool.uv]
environments = [
"sys_platform == 'linux' and platform_machine == 'aarch64'"
]
[tool.uv.sources]
vllm = { git = "https://github.com/hmellor/vllm.git", rev = "b99bedc737166ae5ca98cb9e3534b96e0c8c69aa" }
torch = [{ index = "pytorch-cu130", marker = "platform_machine == 'aarch64'" },
{ index = "pytorch-cpu", marker = "platform_machine == 'x86_64'" },
]
[[tool.uv.index]]
name = "pytorch-cu130"
url = "https://download.pytorch.org/whl/cu130"
explicit = true
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[build-system]
requires = ["uv_build>=0.8.9,<0.9.0"]
build-backend = "uv_build"
@@ -0,0 +1,413 @@
import torch
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
from exo.shared.logging import logger
from exo.worker.engines.mlx.cache import KVPrefixCache
INITIAL_FRACTION = 0.05
GROWTH_HEADROOM_BYTES = 512 * 1024 * 1024
MIN_GROWTH_BLOCKS = 16
_patched = False
_prefix_cache: KVPrefixCache | None = None
_model_runner: GPUModelRunner | None = None
def get_prefix_cache() -> KVPrefixCache | None:
return _prefix_cache
def set_prefix_cache(cache: KVPrefixCache | None) -> None:
global _prefix_cache
_prefix_cache = cache
def get_model_runner() -> GPUModelRunner | None:
return _model_runner
def set_model_runner(runner: GPUModelRunner | None) -> None:
global _model_runner
_model_runner = runner
def patch_vllm() -> None:
global _patched
if _patched:
return
_patched = True
_patch_determine_available_memory()
_patch_check_enough_kv_cache_memory()
_patch_initialize_kv_cache_tensors()
_patch_initialize_from_config()
_patch_kv_cache_manager_init()
_patch_allocate_slots()
_patch_get_computed_blocks()
_patch_moe_sum()
_patch_marlin_w2_thread_config()
logger.info("vLLM growable KV cache patch applied")
def _patch_determine_available_memory() -> None:
from vllm.v1.worker.gpu_worker import Worker
original = Worker.determine_available_memory
@torch.inference_mode()
def patched(self: "Worker") -> int:
try:
original(self)
except AssertionError:
logger.warning(
"vLLM memory profiling assertion failed (free memory changed during init, "
"likely another process released GPU memory). Continuing with growable cache."
)
torch.cuda.empty_cache()
free_bytes, _ = torch.cuda.mem_get_info()
initial = max(int(free_bytes * INITIAL_FRACTION), 1)
self._growable_max_kv_bytes = free_bytes
logger.info(
f"Growable KV cache: initial {initial / (1024**3):.2f} GiB "
f"(max {free_bytes / (1024**3):.2f} GiB)"
)
return initial
Worker.determine_available_memory = patched # type: ignore
def _patch_check_enough_kv_cache_memory() -> None:
from vllm.v1.core import kv_cache_utils
def noop(*_args: "object", **_kwargs: "object") -> None:
pass
kv_cache_utils._check_enough_kv_cache_memory = noop # type: ignore
def _patch_initialize_kv_cache_tensors() -> None:
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
original_alloc = GPUModelRunner._allocate_kv_cache_tensors
def patched_alloc(
self: "GPUModelRunner", kv_cache_config: "object"
) -> "dict[str, torch.Tensor]":
raw_tensors = original_alloc(self, kv_cache_config)
self._growable_raw_tensors = {name: t for name, t in raw_tensors.items()}
return raw_tensors
GPUModelRunner._allocate_kv_cache_tensors = patched_alloc # type: ignore
original_init_tensors = GPUModelRunner.initialize_kv_cache_tensors
def patched_init_tensors(
self: "GPUModelRunner",
kv_cache_config: "object",
kernel_block_sizes: "list[int]",
) -> "dict[str, torch.Tensor]":
self._growable_kv_cache_config = kv_cache_config
self._growable_kernel_block_sizes = kernel_block_sizes
return original_init_tensors(self, kv_cache_config, kernel_block_sizes)
GPUModelRunner.initialize_kv_cache_tensors = patched_init_tensors # type: ignore
def _patch_initialize_from_config() -> None:
from vllm.v1.worker.gpu_worker import Worker
original = Worker.initialize_from_config
def patched(self: "Worker", kv_cache_config: "object") -> None:
original(self, kv_cache_config)
set_model_runner(self.model_runner)
Worker.initialize_from_config = patched # type: ignore
def _patch_kv_cache_manager_init() -> None:
from vllm.v1.core.kv_cache_manager import KVCacheManager
original_init = KVCacheManager.__init__
def patched_init(
self: "KVCacheManager", *args: "object", **kwargs: "object"
) -> None:
original_init(self, *args, **kwargs)
self._growable_model_runner = get_model_runner()
KVCacheManager.__init__ = patched_init # type: ignore
def _patch_allocate_slots() -> None:
from vllm.v1.core.kv_cache_manager import KVCacheManager
original = KVCacheManager.allocate_slots
def patched(
self: "KVCacheManager",
request: "object",
num_new_tokens: int,
*args: "object",
**kwargs: "object",
) -> "object":
result = original(self, request, num_new_tokens, *args, **kwargs)
if result is None and _try_grow_cache(self):
result = original(self, request, num_new_tokens, *args, **kwargs)
return result
KVCacheManager.allocate_slots = patched # type: ignore
def _try_grow_cache(kv_cache_manager: "object") -> bool:
block_pool = kv_cache_manager.block_pool # type: ignore
model_runner = kv_cache_manager._growable_model_runner # type: ignore
if model_runner is None:
logger.debug("No model_runner reference — cannot grow cache")
return False
free_bytes, _ = torch.cuda.mem_get_info()
if free_bytes < GROWTH_HEADROOM_BYTES:
logger.debug(f"Only {free_bytes / (1024**3):.2f} GiB free — not enough to grow")
return False
kv_cache_config = model_runner._growable_kv_cache_config # type: ignore
old_num_blocks: int = kv_cache_config.num_blocks
total_tensor_bytes = sum(t.size for t in kv_cache_config.kv_cache_tensors)
per_block_bytes = total_tensor_bytes // old_num_blocks
usable_bytes = int(free_bytes * 0.8)
growth_blocks = min(usable_bytes // per_block_bytes, old_num_blocks)
if growth_blocks < MIN_GROWTH_BLOCKS:
logger.debug(f"Growth too small ({growth_blocks} blocks)")
return False
new_num_blocks = old_num_blocks + growth_blocks
logger.info(
f"Growing KV cache: {old_num_blocks}{new_num_blocks} blocks "
f"(+{growth_blocks * per_block_bytes / (1024**3):.2f} GiB)"
)
try:
_grow_tensors(model_runner, kv_cache_config, old_num_blocks, new_num_blocks)
_grow_block_pool(block_pool, old_num_blocks, new_num_blocks)
kv_cache_config.num_blocks = new_num_blocks
for tensor_spec in kv_cache_config.kv_cache_tensors:
tensor_spec.size = int(tensor_spec.size * new_num_blocks / old_num_blocks)
logger.info(f"KV cache grown successfully to {new_num_blocks} blocks")
return True
except Exception:
logger.opt(exception=True).error("Failed to grow KV cache")
return False
def _grow_tensors(
model_runner: "object",
kv_cache_config: "object",
old_num_blocks: int,
new_num_blocks: int,
) -> None:
raw_tensors: dict[str, torch.Tensor] = model_runner._growable_raw_tensors # type: ignore
ratio = new_num_blocks / old_num_blocks
already_grown: dict[int, torch.Tensor] = {}
new_raw_tensors: dict[str, torch.Tensor] = {}
for layer_name, old_raw in raw_tensors.items():
storage_id = old_raw.data_ptr()
if storage_id in already_grown:
new_raw_tensors[layer_name] = already_grown[storage_id]
continue
old_size = old_raw.numel()
new_size = int(old_size * ratio)
new_raw = torch.zeros(new_size, dtype=torch.int8, device=old_raw.device)
new_raw[:old_size] = old_raw
already_grown[storage_id] = new_raw
new_raw_tensors[layer_name] = new_raw
model_runner._growable_raw_tensors = new_raw_tensors # type: ignore
kernel_block_sizes: list[int] = model_runner._growable_kernel_block_sizes # type: ignore
new_kv_caches: dict[str, torch.Tensor] = model_runner._reshape_kv_cache_tensors( # type: ignore
kv_cache_config,
new_raw_tensors,
kernel_block_sizes,
)
forward_context: dict[str, "object"] = (
model_runner.compilation_config.static_forward_context
) # type: ignore
runner_kv_caches: list[torch.Tensor] = model_runner.kv_caches # type: ignore
runner_kv_caches.clear()
from collections import defaultdict
from vllm.v1.worker.utils import extract_layer_index
num_attn_module = 1
hf_config = getattr(getattr(model_runner, "model_config", None), "hf_config", None) # type: ignore
if getattr(hf_config, "model_type", "") == "longcat_flash":
num_attn_module = 2
index2name: dict[int, list[str]] = defaultdict(list)
for ln in new_kv_caches:
index2name[extract_layer_index(ln, num_attn_module)].append(ln)
for layer_index in sorted(index2name.keys()):
for ln in index2name[layer_index]:
runner_kv_caches.append(new_kv_caches[ln])
for layer_name, kv_cache in new_kv_caches.items():
forward_context[layer_name].kv_cache = [kv_cache] # type: ignore
def _grow_block_pool(
block_pool: "object", old_num_blocks: int, new_num_blocks: int
) -> None:
from vllm.v1.core.kv_cache_utils import KVCacheBlock
new_blocks: list["KVCacheBlock"] = []
for idx in range(old_num_blocks, new_num_blocks):
block = KVCacheBlock(idx)
block_pool.blocks.append(block) # type: ignore
new_blocks.append(block)
block_pool.free_block_queue.append_n(new_blocks) # type: ignore
block_pool.num_gpu_blocks = new_num_blocks # type: ignore
def _patch_moe_sum() -> None:
import vllm._custom_ops as ops # type: ignore[reportMissingImports]
def moe_sum_f32(x: "torch.Tensor", output: "torch.Tensor") -> None:
output[:] = x.to(torch.float32).sum(dim=1).to(output.dtype) # type: ignore
ops.moe_sum = moe_sum_f32 # type: ignore
def _patch_marlin_w2_thread_config() -> None:
try:
import vllm._custom_ops as ops # type: ignore[reportMissingImports]
except ImportError:
return
original_gemm = ops.moe_wna16_marlin_gemm
def patched_gemm(*args: "object", **kwargs: "object") -> "object":
kwargs["thread_k"] = 64
kwargs["thread_n"] = 128
return original_gemm(*args, **kwargs)
ops.moe_wna16_marlin_gemm = patched_gemm # type: ignore
def _patch_get_computed_blocks() -> None:
from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager
from vllm.v1.core.kv_cache_utils import KVCacheBlock
from vllm.v1.request import Request
original = KVCacheManager.get_computed_blocks
def patched(
self: KVCacheManager,
request: Request,
) -> tuple[KVCacheBlocks, int]:
prefix_cache = get_prefix_cache()
if prefix_cache is None or request.prompt_token_ids is None:
return original(self, request)
from exo.worker.engines.vllm.kv_cache import (
TorchKVCache as _TorchKVCache, # noqa: F811
)
try:
torch_cache, num_matched, _ = prefix_cache.lookup(
list(request.prompt_token_ids)
) # type: ignore[reportUnknownMemberType]
except Exception:
return original(self, request)
if (
torch_cache is None
or not isinstance(torch_cache, _TorchKVCache)
or num_matched == 0
):
return original(self, request)
from vllm.utils.math_utils import cdiv # type: ignore[reportMissingImports]
from exo.worker.engines.vllm.vllm_generator import _build_layer_groups
num_groups = len(self.kv_cache_config.kv_cache_groups)
null_block = self.block_pool.null_block
save_offsets = torch_cache.token_offset_per_group or [0] * num_groups
for gi in range(num_groups):
save_off = save_offsets[gi] if gi < len(save_offsets) else 0
if save_off > 0:
spec = self.kv_cache_config.kv_cache_groups[gi].kv_cache_spec # type: ignore
window = getattr(spec, "sliding_window", 0) or 0
if window > 0 and num_matched < save_off + window:
return original(self, request)
real_block_counts: list[int] = []
skipped_block_counts: list[int] = []
total_needed = 0
for gi in range(num_groups):
mgr = self.coordinator.single_type_managers[gi] # type: ignore
block_size: int = self.kv_cache_config.kv_cache_groups[
gi
].kv_cache_spec.block_size # type: ignore
num_skipped: int = mgr.get_num_skipped_tokens(num_matched) # type: ignore
num_skipped_blocks = num_skipped // block_size
num_real = cdiv(num_matched, block_size) - num_skipped_blocks
real_block_counts.append(num_real)
skipped_block_counts.append(num_skipped_blocks)
total_needed += num_real
if self.block_pool.get_num_free_blocks() < total_needed:
return original(self, request)
blocks_per_group: list[list[KVCacheBlock]] = []
token_offset_per_group: list[int] = []
for gi in range(num_groups):
mgr = self.coordinator.single_type_managers[gi] # type: ignore
block_size = self.kv_cache_config.kv_cache_groups[
gi
].kv_cache_spec.block_size # type: ignore
real_blocks: list[KVCacheBlock] = self.block_pool.get_new_blocks(
real_block_counts[gi]
) # type: ignore
blocks_per_group.append(real_blocks)
full_block_list = [null_block] * skipped_block_counts[gi] + list(
real_blocks
)
req_blocks = mgr.req_to_blocks[request.request_id] # type: ignore
req_blocks.extend(full_block_list) # type: ignore
token_offset_per_group.append(skipped_block_counts[gi] * block_size)
block_ids_per_group = [[b.block_id for b in grp] for grp in blocks_per_group]
layer_to_group = _build_layer_groups(self.kv_cache_config)
model_runner = self._growable_model_runner # type: ignore[reportAttributeAccessIssue]
if model_runner is not None:
torch_cache.write_to_vllm_blocks( # type: ignore
model_runner.kv_caches,
block_ids_per_group,
layer_to_group, # type: ignore
token_offset_per_group,
)
total_blocks = sum(len(g) for g in blocks_per_group)
logger.info(
f"Prefix cache hit: {num_matched} tokens, {total_blocks} blocks ({num_groups} groups)"
)
return self.empty_kv_cache_blocks, num_matched
KVCacheManager.get_computed_blocks = patched # type: ignore[reportAttributeAccessIssue]
@@ -0,0 +1,306 @@
from collections.abc import Iterator, Sequence
from copy import deepcopy
from dataclasses import dataclass
import mlx.core as mx
import numpy as np
import torch
from mlx_lm.models.cache import (
ArraysCache,
CacheList,
KVCache,
QuantizedKVCache,
RotatingKVCache,
)
@dataclass
class KVLayerState:
keys: torch.Tensor # [seq_len, n_heads, head_dim]
values: torch.Tensor # [seq_len, n_heads, head_dim]
@dataclass
class RotatingKVLayerState:
keys: torch.Tensor # [buffer_len, n_heads, head_dim]
values: torch.Tensor # [buffer_len, n_heads, head_dim]
keep: int
max_size: int
offset: int
idx: int
@dataclass
class ArraysLayerState:
arrays: list[torch.Tensor | None]
LayerState = KVLayerState | RotatingKVLayerState | ArraysLayerState
def _mx_to_torch(arr: mx.array) -> torch.Tensor:
mx.eval(arr)
if arr.dtype == mx.bfloat16:
return torch.from_numpy(np.array(arr.astype(mx.float32))).to(torch.bfloat16)
return torch.from_numpy(np.array(arr))
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) # pyright: ignore[reportAny]
return mx.array(t.numpy()) # pyright: ignore[reportAny]
def _split_kv(
kv: torch.Tensor | list[torch.Tensor],
) -> tuple[torch.Tensor, torch.Tensor]:
if isinstance(kv, list):
return kv[0], kv[1]
if kv.shape[0] == 2 and kv.shape[1] != 2:
return kv[0], kv[1]
return kv[:, 0], kv[:, 1]
def _kv_to_nhd(k: mx.array, v: mx.array) -> tuple[torch.Tensor, torch.Tensor]:
"""Convert MLX BHSD [1, H, S, D] to NHD [S, H, D] torch tensors."""
kt = _mx_to_torch(k).squeeze(0).permute(1, 0, 2) # [H,S,D] -> [S,H,D]
vt = _mx_to_torch(v).squeeze(0).permute(1, 0, 2)
return kt, vt
def _nhd_to_bhsd(kt: torch.Tensor, vt: torch.Tensor) -> tuple[mx.array, mx.array]:
"""Convert NHD [S, H, D] torch tensors to MLX BHSD [1, H, S, D]."""
k_mx = _torch_to_mx(kt.permute(1, 0, 2).unsqueeze(0)) # [S,H,D] -> [1,H,S,D]
v_mx = _torch_to_mx(vt.permute(1, 0, 2).unsqueeze(0))
return k_mx, v_mx
class TorchKVCache:
def __init__(
self, layers: list[LayerState], token_offset_per_group: list[int] | None = None
):
self.layers = layers
self.token_offset_per_group = token_offset_per_group or []
self._num_tokens: int | None = None
@property
def num_layers(self) -> int:
return len(self.layers)
def layer(self, idx: int) -> LayerState:
return self.layers[idx]
def kv_layers(self) -> list[tuple[int, KVLayerState | RotatingKVLayerState]]:
return [
(i, layer)
for i, layer in enumerate(self.layers)
if isinstance(layer, (KVLayerState, RotatingKVLayerState))
]
def detach_cpu(self) -> "TorchKVCache":
layers: list[LayerState] = []
for layer in self.layers:
if isinstance(layer, KVLayerState):
if not layer.keys.is_cuda:
layers.append(layer)
else:
layers.append(
KVLayerState(
keys=layer.keys.detach().to("cpu", non_blocking=True),
values=layer.values.detach().to("cpu", non_blocking=True),
)
)
elif isinstance(layer, RotatingKVLayerState):
layers.append(
RotatingKVLayerState(
keys=layer.keys.detach().to("cpu", non_blocking=True),
values=layer.values.detach().to("cpu", non_blocking=True),
keep=layer.keep,
max_size=layer.max_size,
offset=layer.offset,
idx=layer.idx,
)
)
else:
layers.append(deepcopy(layer))
if any(
layer.keys.is_cuda
for layer in self.layers
if isinstance(layer, (KVLayerState, RotatingKVLayerState))
):
torch.cuda.synchronize()
return TorchKVCache(layers, list(self.token_offset_per_group))
def trim_to(self, num_tokens: int) -> "TorchKVCache":
trimmed = TorchKVCache(list(self.layers), list(self.token_offset_per_group))
trimmed._num_tokens = num_tokens
return trimmed
@property
def num_tokens(self) -> int | None:
return getattr(self, "_num_tokens", None)
@classmethod
def from_mlx_cache(
cls,
cache: Sequence[
KVCache | RotatingKVCache | QuantizedKVCache | ArraysCache | CacheList
],
) -> "TorchKVCache":
layers: list[LayerState] = []
for c in cache:
if isinstance(c, RotatingKVCache):
if c.keys is None:
layers.append(
RotatingKVLayerState(
keys=torch.empty(0),
values=torch.empty(0),
keep=c.keep,
max_size=c.max_size,
offset=c.offset,
idx=c._idx,
)
)
else:
k, v = c.state
kt, vt = _kv_to_nhd(k, v) # pyright: ignore[reportArgumentType]
keep, max_size, offset, idx = (int(x) for x in c.meta_state) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportUnknownArgumentType]
layers.append(
RotatingKVLayerState(
keys=kt,
values=vt,
keep=keep,
max_size=max_size,
offset=offset,
idx=idx,
)
)
elif isinstance(c, ArraysCache):
arrays: list[torch.Tensor | None] = []
for arr in c.state:
arrays.append(_mx_to_torch(arr) if arr is not None else None)
layers.append(ArraysLayerState(arrays=arrays))
else:
if c.keys is None: # pyright: ignore[reportUnnecessaryComparison]
layers.append(
KVLayerState(keys=torch.empty(0), values=torch.empty(0))
)
else:
k, v = c.state
kt, vt = _kv_to_nhd(k, v) # pyright: ignore[reportArgumentType]
layers.append(KVLayerState(keys=kt, values=vt))
return cls(layers)
def to_mlx_cache(self) -> list[KVCache | RotatingKVCache | ArraysCache]:
result: list[KVCache | RotatingKVCache | ArraysCache] = []
for layer in self.layers:
if isinstance(layer, RotatingKVLayerState):
c = RotatingKVCache(max_size=layer.max_size, keep=layer.keep)
if layer.keys.numel() > 0:
k_mx, v_mx = _nhd_to_bhsd(layer.keys, layer.values)
c.state = (k_mx, v_mx)
c.meta_state = tuple(
str(x)
for x in (layer.keep, layer.max_size, layer.offset, layer.idx)
)
result.append(c)
elif isinstance(layer, ArraysLayerState):
c = ArraysCache(size=len(layer.arrays))
c.state = [
_torch_to_mx(arr) if arr is not None else None
for arr in layer.arrays
]
result.append(c)
else:
c = KVCache()
if layer.keys.numel() > 0:
k_mx, v_mx = _nhd_to_bhsd(layer.keys, layer.values)
c.state = (k_mx, v_mx)
result.append(c)
return result
@classmethod
def from_vllm_cache(
cls,
kv_caches: list[torch.Tensor | list[torch.Tensor]],
block_ids_per_group: list[list[int]],
layer_to_group: list[int],
num_tokens: int,
token_offset_per_group: list[int] | None = None,
) -> "TorchKVCache":
block_tables = [
torch.tensor(ids, dtype=torch.long) for ids in block_ids_per_group
]
if token_offset_per_group is None:
token_offset_per_group = [0] * len(block_ids_per_group)
layers: list[LayerState] = []
for layer_idx, kv in enumerate(kv_caches):
gi = layer_to_group[layer_idx]
bt = block_tables[gi]
k_all, v_all = _split_kv(kv)
if len(bt) == 0:
layers.append(KVLayerState(keys=torch.empty(0), values=torch.empty(0)))
continue
keys = k_all[bt].to("cpu", non_blocking=True)
values = v_all[bt].to("cpu", non_blocking=True)
torch.cuda.synchronize()
layers.append(KVLayerState(keys=keys, values=values))
return cls(layers, list(token_offset_per_group))
def write_to_vllm_blocks(
self,
kv_caches: list[torch.Tensor | list[torch.Tensor]],
block_ids_per_group: list[list[int]],
layer_to_group: list[int],
token_offset_per_group: list[int] | None = None,
) -> None:
block_tables = [
torch.tensor(ids, dtype=torch.long) for ids in block_ids_per_group
]
first = kv_caches[0]
device = first[0].device if isinstance(first, list) else first.device
for layer_idx, layer in enumerate(self.layers):
if not isinstance(layer, KVLayerState):
continue
gi = layer_to_group[layer_idx]
bt = block_tables[gi]
kv = kv_caches[layer_idx]
k_all, v_all = _split_kv(kv)
n_blocks = min(len(bt), layer.keys.shape[0])
if n_blocks > 0:
k_all[bt[:n_blocks]] = layer.keys[:n_blocks].to(
device, non_blocking=True
)
v_all[bt[:n_blocks]] = layer.values[:n_blocks].to(
device, non_blocking=True
)
torch.cuda.synchronize()
def __iter__(self) -> Iterator[LayerState]:
return iter(self.layers)
def __len__(self) -> int:
return len(self.layers)
def __repr__(self) -> str:
parts: list[str] = [f"TorchKVCache({self.num_layers} layers)"]
for i, layer in enumerate(self.layers):
if isinstance(layer, KVLayerState):
parts.append(
f" [{i}] KV: keys={list(layer.keys.shape)} values={list(layer.values.shape)} {layer.keys.dtype}"
)
elif isinstance(layer, RotatingKVLayerState):
parts.append(
f" [{i}] RotatingKV: keys={list(layer.keys.shape)} keep={layer.keep} max_size={layer.max_size} offset={layer.offset} idx={layer.idx}"
)
else:
shapes = [
list(a.shape) if a is not None else None for a in layer.arrays
]
parts.append(f" [{i}] Arrays: {shapes}")
return "\n".join(parts)
@@ -0,0 +1,57 @@
from mlx_lm.tokenizer_utils import TokenizerWrapper
from vllm.sampling_params import SamplingParams
from vllm.v1.engine.llm_engine import LLMEngine
from exo.shared.types.common import ModelId
from exo.shared.types.text_generation import TextGenerationTaskParams
from exo.worker.engines.mlx.utils_mlx import (
apply_chat_template,
get_eos_token_ids_for_model,
)
def format_vllm_prompt(
engine: LLMEngine, params: TextGenerationTaskParams
) -> tuple[list[int], str, int]:
# we should have our own wrapper
# (instead of abusing mlx's TokenizerWrapper, use tokenizers Tokenizer)
tokenizer = TokenizerWrapper(engine.get_tokenizer())
prompt_text = apply_chat_template(tokenizer, params)
token_ids: list[int] = tokenizer.encode(prompt_text, add_special_tokens=False) # type: ignore[reportUnknownMemberType]
return token_ids, prompt_text, len(token_ids)
def make_vllm_sampling_params(
engine: LLMEngine,
params: TextGenerationTaskParams,
model_id: ModelId | None = None,
) -> SamplingParams:
kwargs: dict[str, object] = {}
if params.max_output_tokens is not None:
kwargs["max_tokens"] = params.max_output_tokens
else:
kwargs["max_tokens"] = min(engine.model_config.max_model_len, 32168)
if params.temperature is not None:
kwargs["temperature"] = params.temperature
if params.top_p is not None:
kwargs["top_p"] = params.top_p
if params.top_k is not None:
kwargs["top_k"] = params.top_k
if params.min_p is not None:
kwargs["min_p"] = params.min_p
if params.stop is not None:
kwargs["stop"] = params.stop
if params.seed is not None:
kwargs["seed"] = params.seed
if params.repetition_penalty is not None:
kwargs["repetition_penalty"] = params.repetition_penalty
if params.logprobs:
kwargs["logprobs"] = params.top_logprobs or 1
if model_id is not None:
extra_stop = get_eos_token_ids_for_model(model_id)
if extra_stop:
kwargs["stop_token_ids"] = extra_stop
return SamplingParams(**kwargs)
@@ -0,0 +1,594 @@
import gc
import math
import os
import re
import sys
import time
from collections.abc import Callable, Generator
from dataclasses import dataclass, field
import torch
from vllm.engine.arg_utils import EngineArgs
from vllm.sampling_params import SamplingParams
from vllm.v1.engine.llm_engine import LLMEngine
from vllm.v1.kv_cache_interface import KVCacheConfig
from exo.shared.types.api import (
CompletionTokensDetails,
GenerationStats,
PromptTokensDetails,
Usage,
)
from exo.shared.types.common import ModelId
from exo.shared.types.memory import Memory
from exo.shared.types.tasks import TaskId
from exo.shared.types.text_generation import TextGenerationTaskParams
from exo.shared.types.worker.runner_response import GenerationResponse
from exo.worker.engines.mlx.cache import KVPrefixCache
from exo.worker.engines.mlx.utils_mlx import get_eos_token_ids_for_model
from exo.worker.engines.vllm.growable_cache import (
get_model_runner,
patch_vllm,
set_prefix_cache,
)
from exo.worker.engines.vllm.kv_cache import TorchKVCache
from exo.worker.engines.vllm.prompt_format import (
format_vllm_prompt,
make_vllm_sampling_params,
)
from exo.worker.runner.bootstrap import logger
from exo.worker.runner.llm_inference.tool_parsers import ToolParser, infer_tool_parser
def _build_layer_groups(kv_cache_config: KVCacheConfig) -> list[int]:
group_lookup: dict[str, int] = {}
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: list[int] = []
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
@dataclass
class _EngineRequest:
request_id: str
prompt_token_count: int
prompt_token_ids: list[int]
prefill_done: bool = False
prefill_steps: int = 0
prev_text: str = ""
prev_token_count: int = 0
start_time: float = field(default_factory=time.perf_counter)
first_token_time: float | None = None
on_generation_token: Callable[[], None] | None = None
on_prefill_progress: Callable[[int, int], None] | None = None
def _save_prefix_cache(
engine: LLMEngine,
prefix_cache: KVPrefixCache,
request_id: str,
prompt_token_ids: list[int],
prompt_token_count: int,
) -> None:
try:
coordinator = None
model_runner = get_model_runner()
kv_cache_config = None
try:
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
except Exception:
pass
if coordinator is None or model_runner is None or kv_cache_config is None:
return
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
null_block = coordinator.block_pool.null_block # type: ignore
block_ids_per_group: list[list[int]] = []
token_offset_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)
continue
block_size: int = mgr.block_size # type: ignore
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)
torch_cache = TorchKVCache.from_vllm_cache(
model_runner.kv_caches, # type: ignore
block_ids_per_group,
layer_to_group,
prompt_token_count,
token_offset_per_group,
)
prefix_cache.add_from_torch(prompt_token_ids, torch_cache)
except Exception:
logger.opt(exception=True).warning("Failed to save prefix cache")
def _stop_token_ids(tokenizer: object, model_id: ModelId) -> set[int]:
ids: set[int] = set()
eos_id = getattr(tokenizer, "eos_token_id", None)
if eos_id is not None:
ids.add(eos_id)
extra = get_eos_token_ids_for_model(model_id)
if extra:
ids.update(extra)
return ids
def _build_generation_response(
tokenizer: object,
token_id: int,
finish_reason: str | None,
prompt_token_count: int,
completion_tokens: int,
start_time: float,
first_token_time: float | None,
suppress_text: bool = False,
) -> GenerationResponse:
token_text: str = "" if suppress_text else tokenizer.decode([token_id]) # type: ignore[reportUnknownMemberType]
finish_usage: Usage | None = None
finish_stats: GenerationStats | None = None
mapped_finish_reason: str | None = None
if finish_reason:
now = time.perf_counter()
prefill_elapsed = (first_token_time or now) - start_time
decode_elapsed = now - (first_token_time or now)
finish_usage = Usage(
prompt_tokens=prompt_token_count,
completion_tokens=completion_tokens,
total_tokens=prompt_token_count + completion_tokens,
prompt_tokens_details=PromptTokensDetails(),
completion_tokens_details=CompletionTokensDetails(),
)
finish_stats = GenerationStats(
prompt_tps=prompt_token_count / prefill_elapsed
if prefill_elapsed > 0
else 0.0,
generation_tps=completion_tokens / decode_elapsed
if decode_elapsed > 0
else 0.0,
prompt_tokens=prompt_token_count,
generation_tokens=completion_tokens,
peak_memory_usage=Memory.from_bytes(
torch.cuda.max_memory_allocated() # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType, reportAttributeAccessIssue]
),
)
mapped_finish_reason = (
finish_reason
if finish_reason in ("stop", "length", "content_filter")
else "stop"
)
return GenerationResponse(
text=token_text,
token=token_id,
finish_reason=mapped_finish_reason,
usage=finish_usage,
stats=finish_stats,
)
def vllm_generate(
engine: LLMEngine,
model_id: ModelId,
task: TextGenerationTaskParams,
prompt: str,
prefix_cache: KVPrefixCache,
on_prefill_progress: Callable[[int, int], None] | None = None,
distributed_prompt_progress_callback: Callable[[], None] | None = None,
on_generation_token: Callable[[], None] | None = None,
) -> Generator[GenerationResponse, None, None]:
token_ids, prompt_text, prompt_token_count = format_vllm_prompt(engine, task)
logger.info(prompt_text)
request_id = f"vllm-seq-{time.monotonic_ns()}"
sampling_params = make_vllm_sampling_params(engine, task, model_id)
engine.add_request(request_id, {"prompt_token_ids": token_ids}, sampling_params)
tokenizer = engine.get_tokenizer()
stop_ids = _stop_token_ids(tokenizer, model_id)
max_batch_tokens: int = (
getattr(engine.model_config, "max_num_batched_tokens", 2048) or 2048
) # type: ignore[reportUnknownMemberType]
start_time = time.perf_counter()
first_token_time: float | None = None
prev_token_count = 0
prefill_done = False
prefill_steps = 0
while engine.has_unfinished_requests():
if distributed_prompt_progress_callback and not prefill_done:
distributed_prompt_progress_callback()
outputs = engine.step()
for output in outputs:
if output.request_id != request_id:
continue
completion = output.outputs[0]
new_token_count = len(completion.token_ids)
new_tokens = completion.token_ids[prev_token_count:]
finish_reason = completion.finish_reason
prev_token_count = new_token_count
if not prefill_done and not new_tokens:
prefill_steps += 1
if on_prefill_progress:
on_prefill_progress(
min(prefill_steps * max_batch_tokens, prompt_token_count),
prompt_token_count,
)
continue
if not prefill_done and new_tokens:
first_token_time = time.perf_counter()
prefill_done = True
_save_prefix_cache(
engine, prefix_cache, request_id, token_ids, prompt_token_count
)
for i, token_id in enumerate(new_tokens):
is_last = i == len(new_tokens) - 1
is_final_stop = is_last and finish_reason and token_id in stop_ids
if on_generation_token:
on_generation_token()
if is_final_stop:
yield _build_generation_response(
tokenizer,
token_id,
finish_reason,
prompt_token_count,
new_token_count,
start_time,
first_token_time,
suppress_text=True,
)
else:
yield _build_generation_response(
tokenizer,
token_id,
finish_reason if is_last and finish_reason else None,
prompt_token_count,
new_token_count,
start_time,
first_token_time,
)
def warmup_vllm_engine(engine: LLMEngine) -> int:
tokenizer = engine.get_tokenizer()
messages = [
{
"role": "user",
"content": "Prompt to warm up the inference engine. Repeat this.",
}
]
prompt_text: str = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
) # type: ignore
token_ids: list[int] = tokenizer.encode(prompt_text, add_special_tokens=False) # type: ignore
params = SamplingParams(max_tokens=50, detokenize=False)
engine.add_request("warmup", {"prompt_token_ids": token_ids}, params)
t = time.monotonic()
tokens_generated = 0
while engine.has_unfinished_requests():
engine.step()
tokens_generated += 1
elapsed = max(time.monotonic() - t, 0.001)
check_for_cancel_every = min(math.ceil(tokens_generated / elapsed), 100)
logger.info(
f"vLLM warmup complete, check_for_cancel_every={check_for_cancel_every}"
)
return check_for_cancel_every
@dataclass(eq=False)
class VllmBatchEngine:
engine: LLMEngine
model_id: ModelId
prefix_cache: KVPrefixCache
_active: dict[TaskId, _EngineRequest] = field(default_factory=dict, init=False)
def warmup(self) -> int:
return warmup_vllm_engine(self.engine)
@property
def has_work(self) -> bool:
return bool(self._active) or self.engine.has_unfinished_requests()
def submit(
self,
task_id: TaskId,
task_params: TextGenerationTaskParams,
prompt: str,
on_prefill_progress: Callable[[int, int], None] | None = None,
distributed_prompt_progress_callback: Callable[[], None] | None = None,
on_generation_token: Callable[[], None] | None = None,
) -> TaskId:
token_ids, prompt_text, prompt_token_count = format_vllm_prompt(
self.engine, task_params
)
logger.info(prompt_text)
sampling_params = make_vllm_sampling_params(
self.engine, task_params, self.model_id
)
self.engine.add_request(
task_id, {"prompt_token_ids": token_ids}, sampling_params
)
self._active[task_id] = _EngineRequest(
request_id=task_id,
prompt_token_count=prompt_token_count,
prompt_token_ids=token_ids,
on_generation_token=on_generation_token,
on_prefill_progress=on_prefill_progress,
)
return task_id
def step(self) -> list[tuple[TaskId, GenerationResponse]]:
if not self.has_work:
return []
outputs = self.engine.step()
tokenizer = self.engine.get_tokenizer()
stop_ids = _stop_token_ids(tokenizer, self.model_id)
max_batch_tokens: int = (
getattr(self.engine.model_config, "max_num_batched_tokens", 2048) or 2048
) # type: ignore[reportUnknownMemberType]
results: list[tuple[TaskId, GenerationResponse]] = []
for output in outputs:
task_id = TaskId(output.request_id)
if task_id not in self._active:
continue
req = self._active[task_id]
completion = output.outputs[0]
new_token_count = len(completion.token_ids)
new_tokens = completion.token_ids[req.prev_token_count :]
finish_reason = completion.finish_reason
req.prev_token_count = new_token_count
if not req.prefill_done and not new_tokens:
req.prefill_steps += 1
if req.on_prefill_progress:
req.on_prefill_progress(
min(
req.prefill_steps * max_batch_tokens, req.prompt_token_count
),
req.prompt_token_count,
)
continue
if not req.prefill_done and new_tokens:
req.first_token_time = time.perf_counter()
req.prefill_done = True
_save_prefix_cache(
self.engine,
self.prefix_cache,
req.request_id,
req.prompt_token_ids,
req.prompt_token_count,
)
for i, token_id in enumerate(new_tokens):
is_last = i == len(new_tokens) - 1
is_final_stop = is_last and finish_reason and token_id in stop_ids
if req.on_generation_token:
req.on_generation_token()
results.append(
(
task_id,
_build_generation_response(
tokenizer,
token_id,
finish_reason if is_last and finish_reason else None,
req.prompt_token_count,
new_token_count,
req.start_time,
req.first_token_time,
suppress_text=bool(is_final_stop),
),
)
)
if finish_reason:
del self._active[task_id]
for req in self._active.values():
if not req.prefill_done:
req.prefill_steps += 1
if req.on_prefill_progress:
req.on_prefill_progress(
min(
req.prefill_steps * max_batch_tokens, req.prompt_token_count
),
req.prompt_token_count,
)
return results
def cancel(self, task_ids: list[TaskId]) -> None:
to_abort = [tid for tid in task_ids if tid in self._active]
if to_abort:
self.engine.abort_request(to_abort)
for tid in task_ids:
self._active.pop(tid, None)
def close(self) -> None:
if not hasattr(self, "engine"):
return
rids = [req.request_id for req in self._active.values()]
if rids:
self.engine.abort_request(rids)
self._active.clear()
del self.engine
gc.collect()
torch.cuda.empty_cache()
if torch.distributed.is_initialized():
torch.distributed.destroy_process_group()
_weight_loading_callback: Callable[[int, int], None] | None = None
_weight_loading_patched = False
def get_weight_loading_callback() -> Callable[[int, int], None] | None:
return _weight_loading_callback
def set_weight_loading_callback(cb: Callable[[int, int], None] | None) -> None:
global _weight_loading_callback
_weight_loading_callback = cb
_LAYER_INDEX_PATTERN = re.compile(r"\.layers\.(\d+)\.")
_n_layers: int = 1
def get_n_layers() -> int:
return _n_layers
def set_n_layers(n: int) -> None:
global _n_layers
_n_layers = n
def _wrap_weights_iterator(
original: Callable[..., Generator[tuple[str, "torch.Tensor"], None, None]],
) -> Callable[..., Generator[tuple[str, "torch.Tensor"], None, None]]: # pyright: ignore[reportUnknownParameterType]
def patched(
hf_weights_files: list[str], *args: object, **kwargs: object
) -> Generator[tuple[str, "torch.Tensor"], None, None]: # pyright: ignore[reportUnknownParameterType]
callback = get_weight_loading_callback()
if callback is not None and hf_weights_files:
total_layers = get_n_layers()
seen_layers: set[int] = set()
last_reported = 0
for name, tensor in original(hf_weights_files, *args, **kwargs): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
yield name, tensor # pyright: ignore[reportUnknownArgumentType]
match = _LAYER_INDEX_PATTERN.search(name)
if match:
seen_layers.add(int(match.group(1)))
current = len(seen_layers)
if current > last_reported:
callback(current, total_layers)
last_reported = current
callback(total_layers, total_layers)
else:
yield from original(hf_weights_files, *args, **kwargs) # pyright: ignore[reportUnknownMemberType]
return patched
def _monkey_patch_iterator(weight_utils: object, attr_name: str) -> None: # pyright: ignore[reportUnknownParameterType]
original = getattr(weight_utils, attr_name, None)
if original is None:
return
patched = _wrap_weights_iterator(original) # pyright: ignore[reportUnknownArgumentType]
setattr(weight_utils, attr_name, patched)
for mod in list(sys.modules.values()):
if mod is None or mod is weight_utils:
continue
for name in list(vars(mod)):
if vars(mod)[name] is original:
setattr(mod, name, patched)
def _patch_weight_loading_progress() -> None:
global _weight_loading_patched
if _weight_loading_patched:
return
_weight_loading_patched = True
from vllm.model_executor.model_loader import (
weight_utils, # pyright: ignore[reportMissingImports]
)
_monkey_patch_iterator(weight_utils, "safetensors_weights_iterator")
_monkey_patch_iterator(weight_utils, "fastsafetensors_weights_iterator")
import huggingface_hub # pyright: ignore[reportMissingImports]
def _noop_metadata(*_a: object, **_kw: object) -> None:
pass # pyright: ignore[reportUnknownParameterType]
original_metadata = huggingface_hub.get_safetensors_metadata # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
huggingface_hub.get_safetensors_metadata = _noop_metadata # pyright: ignore[reportAttributeAccessIssue]
for mod in list(sys.modules.values()):
if mod is None or mod is huggingface_hub:
continue
for attr in list(vars(mod)):
if vars(mod)[attr] is original_metadata:
setattr(mod, attr, _noop_metadata)
def load_vllm_engine(
model_path: str,
model_id: ModelId,
trust_remote_code: bool,
n_layers: int = 1,
on_layer_loaded: Callable[[int, int], None] | None = None,
) -> tuple[LLMEngine, ToolParser | None, KVPrefixCache]:
patch_vllm()
_patch_weight_loading_progress()
os.environ.setdefault("FASTSAFETENSORS_NOGDS", "1")
prefix_cache = KVPrefixCache(group=None)
set_prefix_cache(prefix_cache)
set_n_layers(n_layers)
engine_args = EngineArgs(
model=model_path,
served_model_name=str(model_id),
gpu_memory_utilization=0.05,
trust_remote_code=trust_remote_code,
load_format="fastsafetensors",
enable_prefix_caching=False,
attention_backend="TRITON_ATTN",
enforce_eager=True,
disable_log_stats=True,
)
set_weight_loading_callback(on_layer_loaded)
engine = LLMEngine.from_engine_args(engine_args)
tool_parser: ToolParser | None = None
tokenizer = engine.get_tokenizer()
chat_template = getattr(tokenizer, "chat_template", None)
if isinstance(chat_template, str):
tool_parser = infer_tool_parser(chat_template)
if tool_parser:
logger.info(
f"inferred tool parser: {tool_parser.start_parsing} / {tool_parser.end_parsing}"
)
logger.info(f"vLLM engine loaded for {model_id}")
return engine, tool_parser, prefix_cache
+2348
View File
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
[project]
name = "vllm-runner"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"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'",
]
[tool.uv.sources]
vllm = { git = "https://github.com/hmellor/vllm.git", branch = "transformers-v5" }
torch = [{ index = "pytorch-cu130", marker = "sys_platform == 'linux'" }]
[[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"