fix: DeepSeek V3.2 warmup crash and tool calling + add catalog cards (#1769)

## Summary

DeepSeek V3.2 (`DeepseekV32ForCausalLM`) is already supported by exo's
inference engine (architecture whitelisted in `model_cards.py`, DSML
encoding added in #1548), but **doesn't work out of the box** due to two
bugs:

### Bug 1: `warmup_inference` passes empty model ID

`warmup_inference()` in `generate.py` accepts `model_id: ModelId` as a
parameter but creates `TextGenerationTaskParams(model=ModelId(""), ...)`
instead of using it. Since `_needs_dsml_encoding()` checks
`"deepseek-v3.2" in task_params.model.lower()`, the empty string never
matches → falls back to `tokenizer.apply_chat_template()` →
**ValueError** because V3.2 has no Jinja chat template.

**Fix:** `model=ModelId("")` → `model=model_id` (one line).

### Bug 2: `_needs_dsml_encoding` limited to tool calling

`_needs_dsml_encoding()` returns `True` only when `task_params.tools` is
present or tool messages exist in `chat_template_messages`. For warmup
and regular chat requests without tools → `return False` → Jinja
fallback → **ValueError**.

Unlike V3.1 (which has a `.jinja` chat template file that transformers
picks up automatically), V3.2 **has no Jinja template at all** — it uses
Python-based DSML encoding for all message types.

**Fix:** For V3.2, always return `True` — DSML encoding handles all
message types.

### Catalog cards

Added inference model cards for:
- `mlx-community/DeepSeek-V3.2-8bit`
- `mlx-community/DeepSeek-V3.2-4bit`

Parameters taken from model `config.json` on HuggingFace, storage sizes
from HF API. Capabilities include `thinking_toggle` (related: #1456).

## Notes

- The model ID string matching approach (`"deepseek-v3.2" in
model.lower()`) is acknowledged tech debt — see #1371 for the planned
architecture-based approach.

## Test plan

- [x] Start exo with DeepSeek V3.2 model → warmup should complete
without crash
- [x] Send a regular chat message (no tools) → should get a response
- [x] Send a chat message with tools → should work as before
- [x] V3.2 cards should appear in the dashboard model catalog

---------

Co-authored-by: user <user@m1.note>
Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>
Co-authored-by: Evan <evanev7@gmail.com>
This commit is contained in:
vskiwi
2026-03-25 19:20:35 +03:00
committed by GitHub
parent 565ed41c13
commit fc1ae90111
20 changed files with 582 additions and 512 deletions
+3
View File
@@ -1,5 +1,8 @@
export NIX_CONFIG := "extra-experimental-features = nix-command flakes"
default: lint fmt
all: lint fmt check
fmt:
treefmt || nix fmt
+1 -1
View File
@@ -61,7 +61,7 @@ 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 = "fix/float32-logprobs" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-deepseek-v32-indexer" }
# 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 }
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,13 @@
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
+1 -1
View File
@@ -42,7 +42,7 @@ class MessageTooLargeError(builtins.Exception):
@typing.final
class NetworkingHandle:
def __new__(cls, identity: Keypair, bootstrap_peers: list[builtins.str], listen_port: builtins.int) -> NetworkingHandle: ...
def __new__(cls, identity: Keypair, bootstrap_peers: typing.Sequence[builtins.str], listen_port: builtins.int) -> NetworkingHandle: ...
async def gossipsub_subscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Subscribe to a `GossipSub` topic.
+1
View File
@@ -221,6 +221,7 @@ async def generate_chat_stream(
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(
+7 -4
View File
@@ -10,7 +10,6 @@ from typing import Self, cast
import anyio
from anyio import fail_after, open_process, to_thread
from anyio.streams.buffered import BufferedByteReceiveStream
from anyio.streams.text import TextReceiveStream
from loguru import logger
from pydantic import ValidationError
@@ -590,11 +589,15 @@ class InfoGatherer:
if not p.stdout:
logger.critical("MacMon closed stdout")
return
stream = TextReceiveStream(BufferedByteReceiveStream(p.stdout))
stream = BufferedByteReceiveStream(p.stdout)
while True:
with fail_after(read_timeout):
text = await stream.receive()
await self.info_sender.send(MacmonMetrics.from_raw_json(text))
data = await stream.receive_until(
delimiter=b"\n", max_bytes=8 * 1024
)
text = data.decode("utf-8", errors="replace").strip()
metrics = MacmonMetrics.from_raw_json(text)
await self.info_sender.send(metrics)
except TimeoutError:
logger.warning(
f"MacMon produced no output for {read_timeout}s, restarting"
+1 -1
View File
@@ -57,8 +57,8 @@ from mlx_lm.models.step3p5 import Model as Step35Model
from mlx_lm.models.step3p5 import Step3p5MLP as Step35MLP
from mlx_lm.models.step3p5 import Step3p5Model as Step35InnerModel
from exo.shared.logging import logger
from exo.shared.types.worker.shards import PipelineShardMetadata
from exo.worker.runner.bootstrap import logger
if TYPE_CHECKING:
from mlx_lm.models.cache import Cache
+22 -1
View File
@@ -15,7 +15,28 @@ USER_TOKEN = "<\uff5cUser\uff5c>"
ASSISTANT_TOKEN = "<\uff5cAssistant\uff5c>"
TOOL_CALLS_START = f"<{DSML_TOKEN}function_calls>"
TOOL_CALLS_END = f"</{DSML_TOKEN}function_calls>"
encode_messages = deepseek_v32.encode_messages
_ORPHAN_THINK_END = ASSISTANT_TOKEN + THINKING_END
_FIXED_THINK_BLOCK = ASSISTANT_TOKEN + THINKING_START + "\n" + THINKING_END
def encode_messages(
messages: list[dict[str, Any]],
thinking_mode: str = "thinking",
context: list[dict[str, Any]] | None = None,
drop_thinking: bool = True,
add_default_bos_token: bool = True,
tools: Any = None, # pyright: ignore[reportAny]
) -> str:
prompt: str = deepseek_v32.encode_messages(
messages,
thinking_mode=thinking_mode,
context=context,
drop_thinking=drop_thinking,
add_default_bos_token=add_default_bos_token,
tools=tools,
)
return prompt.replace(_ORPHAN_THINK_END, _FIXED_THINK_BLOCK)
_INVOKE_PATTERN = re.compile(
rf"<{re.escape(DSML_TOKEN)}invoke\s+name=\"([^\"]+)\">"
@@ -393,9 +393,8 @@ class ExoBatchGenerator:
if len(all_prompt_tokens) > 0
else 0.0
)
if (
matched_index is not None
and hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
if matched_index is not None and (
prefix_hit_length > 1000 or hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
):
self.kv_prefix_cache.update_kv_cache(
matched_index,
+8 -13
View File
@@ -486,16 +486,7 @@ def _patch_lossy_chat_template(template: str) -> str | None:
def _needs_dsml_encoding(task_params: TextGenerationTaskParams) -> bool:
if "deepseek-v3.2" not in task_params.model.lower():
return False
# Use DSML encoding when tools are provided or tool results are in the conversation
if task_params.tools:
return True
if task_params.chat_template_messages:
return any(
msg.get("role") == "tool" for msg in task_params.chat_template_messages
)
return False
return "deepseek-v3.2" in task_params.model.lower()
def apply_chat_template(
@@ -514,8 +505,6 @@ def apply_chat_template(
if task_params.chat_template_messages is not None:
# Use pre-formatted messages that preserve tool_calls, thinking, etc.
formatted_messages = list(task_params.chat_template_messages)
for msg in formatted_messages:
_normalize_tool_calls(msg)
else:
# Add system message (instructions) if present
if task_params.instructions:
@@ -541,7 +530,10 @@ def apply_chat_template(
prompt = encode_messages(
messages=formatted_messages,
thinking_mode="thinking" if task_params.enable_thinking else "chat",
# Only use chat mode if enable thinking is explicitly Fakse.
thinking_mode="chat"
if task_params.enable_thinking is False
else "thinking",
tools=task_params.tools,
)
if partial_assistant_content:
@@ -549,6 +541,9 @@ def apply_chat_template(
logger.info(prompt)
return prompt
for msg in formatted_messages:
_normalize_tool_calls(msg)
extra_kwargs: dict[str, Any] = {}
if task_params.enable_thinking is not None:
# Qwen3 and GLM use "enable_thinking"; DeepSeek uses "thinking".
@@ -195,21 +195,29 @@ class SequentialGenerator(InferenceGenerator):
assert self._active is not None
task, mlx_gen, queue, output_generator = self._active
response = None
output: list[
tuple[TaskId, GenerationResponse | ToolCallResponse | Cancelled | Finished]
] = []
try:
queue.push(next(mlx_gen))
response = next(output_generator)
response = next(mlx_gen)
queue.push(response)
# drain potentially many responses every time
while (parsed := next(output_generator, None)) is not None:
output.append((task.task_id, parsed))
except (StopIteration, PrefillCancelled):
response = Finished()
output.append((task.task_id, Finished()))
self._active = None
if self._queue:
self._start_next()
except Exception as e:
self._send_error(task, e)
self._active = None
raise
return itertools.chain(
[] if response is None else [(task.task_id, response)],
output,
map(lambda task: (task, Cancelled()), self._cancelled_tasks),
)
@@ -428,11 +436,10 @@ class BatchGenerator(InferenceGenerator):
task, queue, output_generator = self._active_tasks[uid]
queue.push(response)
# If a generator fails to parse for some reason and returns early, we should not crash
parsed = next(output_generator, None)
if parsed is not None:
while (parsed := next(output_generator, None)) is not None:
output.append((task.task_id, parsed))
# check if original response was terminal and append a Finished()
if response.finish_reason is not None:
output.append((task.task_id, Finished()))
del self._active_tasks[uid]
@@ -159,11 +159,42 @@ def parse_deepseek_v32(
# Text accumulated during a tool call block
tool_call_text = ""
def _try_parse_tool_call(
text: str, response: GenerationResponse
) -> ToolCallResponse | GenerationResponse:
parsed = parse_dsml_output(text)
if parsed is not None:
return ToolCallResponse(
tool_calls=parsed, usage=response.usage, stats=response.stats
)
logger.warning(f"DSML tool call parsing failed for: {text}")
return response.model_copy(update={"text": text})
for response in responses:
if response is None:
yield None
continue
if response.finish_reason is not None:
yield from pending_buffer
pending_buffer.clear()
if in_tool_call:
tool_call_text += response.text
yield (
_try_parse_tool_call(tool_call_text, response)
if TOOL_CALLS_END in tool_call_text
else response.model_copy(update={"text": tool_call_text})
)
elif TOOL_CALLS_START in response.text and TOOL_CALLS_END in response.text:
dsml_start = response.text.index(TOOL_CALLS_START)
before = response.text[:dsml_start]
if before:
yield response.model_copy(update={"text": before})
yield _try_parse_tool_call(response.text[dsml_start:], response)
else:
yield response
break
# ── Handle thinking tags ──
if not thinking and THINKING_START in response.text:
thinking = True
@@ -191,28 +222,7 @@ def parse_deepseek_v32(
if in_tool_call:
tool_call_text += response.text
if TOOL_CALLS_END in tool_call_text:
# Parse the accumulated DSML block
parsed = parse_dsml_output(tool_call_text)
if parsed is not None:
logger.info(f"parsed DSML tool calls: {parsed}")
yield ToolCallResponse(
tool_calls=parsed,
usage=response.usage,
stats=response.stats,
)
else:
logger.warning(
f"DSML tool call parsing failed for: {tool_call_text}"
)
yield response.model_copy(update={"text": tool_call_text})
in_tool_call = False
tool_call_text = ""
continue
# EOS reached before end marker — yield buffered text as-is
if response.finish_reason is not None:
logger.info("DSML tool call parsing interrupted by EOS")
yield response.model_copy(update={"text": tool_call_text})
yield _try_parse_tool_call(tool_call_text, response)
in_tool_call = False
tool_call_text = ""
continue
@@ -228,7 +238,8 @@ def parse_deepseek_v32(
if pre_text:
# Flush pending buffer tokens that contributed text before the marker
for buf_resp in pending_buffer:
if pre_text:
if not pre_text:
break
chunk = buf_resp.text
if len(chunk) <= len(pre_text):
yield buf_resp
@@ -242,19 +253,7 @@ def parse_deepseek_v32(
# Check if the end marker is already present (entire tool call in one token)
if TOOL_CALLS_END in tool_call_text:
parsed = parse_dsml_output(tool_call_text)
if parsed is not None:
logger.info(f"parsed DSML tool calls: {parsed}")
yield ToolCallResponse(
tool_calls=parsed,
usage=response.usage,
stats=response.stats,
)
else:
logger.warning(
f"DSML tool call parsing failed for: {tool_call_text}"
)
yield response.model_copy(update={"text": tool_call_text})
yield _try_parse_tool_call(tool_call_text, response)
tool_call_text = ""
else:
in_tool_call = True
@@ -267,15 +266,13 @@ def parse_deepseek_v32(
continue
# No partial match — flush all pending tokens and the current one
for buf_resp in pending_buffer:
yield buf_resp
pending_buffer = []
yield from pending_buffer
pending_buffer.clear()
accumulated = ""
yield response
# Flush any remaining pending buffer at generator end
for buf_resp in pending_buffer:
yield buf_resp
yield from pending_buffer
def _could_be_dsml_prefix(text: str) -> bool:
+19 -19
View File
@@ -110,13 +110,12 @@ class RunnerSupervisor:
async def run(self):
self.runner_process.start()
try:
async with self._tg as tg:
tg.start_soon(self._watch_runner)
tg.start_soon(self._forward_events)
def shutdown(self):
finally:
logger.info("Runner supervisor shutting down")
self._tg.cancel_tasks()
if not self._cancel_watch_runner.cancel_called:
self._cancel_watch_runner.cancel()
with contextlib.suppress(ClosedResourceError):
@@ -129,20 +128,27 @@ class RunnerSupervisor:
self._cancel_sender.send(CANCEL_ALL_TASKS)
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.close()
self.runner_process.join(5)
if not self.runner_process.is_alive():
logger.info("Runner process succesfully terminated")
return
# This is overkill but it's not technically bad, just unnecessary.
logger.warning("Runner process didn't shutdown succesfully, terminating")
await to_thread.run_sync(self.runner_process.join, 5)
if self.runner_process.is_alive():
logger.warning(
"Runner process didn't shutdown succesfully, terminating"
)
self.runner_process.terminate()
self.runner_process.join(1)
if not self.runner_process.is_alive():
return
self.runner_process.join(timeout=5)
# This is overkill but it's not technically bad, just unnecessary.
if self.runner_process.is_alive():
logger.critical("Runner process didn't respond to SIGTERM, killing")
self.runner_process.kill()
self.runner_process.join(timeout=5)
else:
logger.info("Runner process succesfully terminated")
self.runner_process.close()
def shutdown(self):
self._tg.cancel_tasks()
async def start_task(self, task: Task):
if task.task_id in self.pending:
@@ -218,12 +224,6 @@ class RunnerSupervisor:
for tid in self.pending:
self.pending[tid].set()
def __del__(self) -> None:
if self.runner_process.is_alive():
logger.critical("RunnerSupervisor was not stopped cleanly.")
with contextlib.suppress(ValueError):
self.runner_process.kill()
async def _watch_runner(self) -> None:
with self._cancel_watch_runner:
while True:
@@ -1,389 +0,0 @@
import copy
import gc
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any, cast
import mlx.core as mx
import pytest
from mlx_lm.tokenizer_utils import TokenizerWrapper
from exo.shared.types.common import ModelId
from exo.shared.types.mlx import KVCacheType, Model
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
from exo.worker.engines.mlx.cache import CacheSnapshot, KVPrefixCache, cache_length
from exo.worker.engines.mlx.generator.batch_generate import ExoBatchGenerator
from exo.worker.engines.mlx.generator.generate import mlx_generate
from exo.worker.engines.mlx.utils_mlx import (
apply_chat_template,
load_tokenizer_for_model_id,
)
from .test_prefix_cache_architectures import (
ARCHITECTURES,
ArchSpec,
_arch_available, # pyright: ignore[reportPrivateUsage]
_build_model, # pyright: ignore[reportPrivateUsage]
_copy_tokenizer, # pyright: ignore[reportPrivateUsage]
_find_snapshot, # pyright: ignore[reportPrivateUsage]
_reduce_config, # pyright: ignore[reportPrivateUsage]
)
def _make_task(
content: str = "Hello, what is 2+2?",
max_tokens: int = 10,
seed: int = 42,
) -> TextGenerationTaskParams:
return TextGenerationTaskParams(
model=ModelId("test"),
input=[InputMessage(role="user", content=content)],
max_output_tokens=max_tokens,
temperature=0.7,
seed=seed,
)
# ── Helpers ──────────────────────────────────────────────────────────────── #
def _collect_mlx_generate(
model: Model,
tokenizer: TokenizerWrapper,
task: TextGenerationTaskParams,
kv_prefix_cache: KVPrefixCache | None,
) -> list[int]:
"""Run mlx_generate and collect output token IDs."""
prompt = apply_chat_template(tokenizer=tokenizer, task_params=task)
tokens: list[int] = []
for resp in mlx_generate(
model=model,
tokenizer=tokenizer,
task=task,
prompt=prompt,
kv_prefix_cache=kv_prefix_cache,
group=None,
):
tokens.append(resp.token)
if resp.finish_reason is not None:
break
return tokens
def _collect_batch_generate(
model: Model,
tokenizer: TokenizerWrapper,
task_params: TextGenerationTaskParams,
kv_prefix_cache: KVPrefixCache | None,
) -> list[int]:
"""Run ExoBatchGenerator and collect raw output token IDs"""
exo_gen = ExoBatchGenerator(
model=model,
tokenizer=tokenizer,
group=None,
kv_prefix_cache=kv_prefix_cache,
)
prompt = apply_chat_template(tokenizer=tokenizer, task_params=task_params)
exo_gen.submit(task_params=task_params, prompt=prompt)
tokens: list[int] = []
while exo_gen.has_work:
results = exo_gen.step()
for _uid, response in results:
tokens.append(response.token)
exo_gen.close()
return tokens
def _assert_state_equal(sa: object, sb: object, label: str) -> None:
"""Compare two state items, handling both plain arrays and tuples of arrays (CacheList)."""
if isinstance(sa, tuple):
assert isinstance(sb, tuple), f"{label}: type mismatch"
for k, (arr_a, arr_b) in enumerate(
zip(
cast(tuple[mx.array, ...], sa),
cast(tuple[mx.array, ...], sb),
strict=True,
)
):
a_f = mx.array(arr_a).astype(mx.float32)
b_f = mx.array(arr_b).astype(mx.float32)
if a_f.size == 0:
assert b_f.size == 0, f"{label}[{k}]: size mismatch"
continue
diff = float(mx.max(mx.abs(a_f - b_f)).item())
assert diff == 0.0, f"{label}[{k}]: max diff {diff}"
else:
sa_f = mx.array(cast(mx.array, sa)).astype(mx.float32)
sb_f = mx.array(cast(mx.array, sb)).astype(mx.float32)
if sa_f.size == 0:
assert sb_f.size == 0, f"{label}: size mismatch"
return
diff = float(mx.max(mx.abs(sa_f - sb_f)).item())
assert diff == 0.0, f"{label}: max diff {diff}"
def _compare_cache_arrays(
cache_a: KVCacheType,
cache_b: KVCacheType,
label: str = "",
) -> None:
"""Assert two KV caches have identical array values."""
assert len(cache_a) == len(cache_b), (
f"{label}Cache layer count: {len(cache_a)} vs {len(cache_b)}"
)
for i, (a, b) in enumerate(zip(cache_a, cache_b, strict=True)):
assert type(a) is type(b), (
f"{label}Layer {i}: type {type(a).__name__} vs {type(b).__name__}"
)
states_a = a.state
states_b = b.state
assert len(states_a) == len(states_b), (
f"{label}Layer {i}: state count {len(states_a)} vs {len(states_b)}"
)
for j, (sa, sb) in enumerate(zip(states_a, states_b, strict=True)):
if sa is None and sb is None:
continue
assert sa is not None and sb is not None, (
f"{label}Layer {i}, state {j}: one is None"
)
_assert_state_equal(sa, sb, f"{label}Layer {i}, state {j}")
def _safe_state(cache: object) -> list[object]:
"""Safely access .state on a cache object. Returns [] if uninitialized."""
# RotatingKVCache.state crashes when keys is None (uninitialized)
if getattr(cache, "keys", _SENTINEL) is None:
return []
try:
return list(cache.state) # type: ignore[union-attr]
except (AttributeError, TypeError):
return []
_SENTINEL = object()
def _compare_snapshots(
snaps_a: list[CacheSnapshot] | None,
snaps_b: list[CacheSnapshot] | None,
label: str = "",
) -> None:
"""Assert two snapshot lists are identical."""
if snaps_a is None:
assert snaps_b is None, f"{label}One side has snapshots, other doesn't"
return
assert snaps_b is not None, f"{label}One side has snapshots, other doesn't"
assert len(snaps_a) == len(snaps_b), (
f"{label}Snapshot count: {len(snaps_a)} vs {len(snaps_b)}"
)
for k, (sa, sb) in enumerate(zip(snaps_a, snaps_b, strict=True)):
assert sa.token_count == sb.token_count, (
f"{label}Snapshot {k} token_count: {sa.token_count} vs {sb.token_count}"
)
for layer_i, (s1, s2) in enumerate(zip(sa.states, sb.states, strict=True)):
if s1 is None and s2 is None:
continue
assert s1 is not None and s2 is not None, (
f"{label}Snapshot {k}, layer {layer_i}: one state is None"
)
state_a = _safe_state(s1)
state_b = _safe_state(s2)
if not state_a and not state_b:
continue
assert len(state_a) == len(state_b), (
f"{label}Snapshot {k}, layer {layer_i}: state length mismatch"
)
for st_j, (arr_a, arr_b) in enumerate(zip(state_a, state_b, strict=True)):
if arr_a is None and arr_b is None:
continue
assert arr_a is not None and arr_b is not None
_assert_state_equal(
arr_a,
arr_b,
f"{label}Snapshot {k}, layer {layer_i}, state {st_j}",
)
# ── Test class ────────────────────────────────────────────────────────────── #
@pytest.mark.slow
class TestBatchVsGenerate:
"""Verify BatchGenerator matches mlx_generate for output tokens and prefix cache."""
@pytest.fixture(autouse=True)
def _cleanup(self):
yield
mx.clear_cache()
gc.collect()
@pytest.mark.parametrize(
"spec",
ARCHITECTURES,
ids=[a.name for a in ARCHITECTURES],
)
def test_same_output_and_cache(self, spec: ArchSpec) -> None:
if not _arch_available(spec):
pytest.skip(f"Model {spec.hub_name} not cached locally")
snapshot = _find_snapshot(spec.hub_name)
assert snapshot is not None
tmpdir = Path(tempfile.mkdtemp(prefix=f"exo_batchtest_{spec.name}_"))
try:
# Build reduced config
with open(snapshot / "config.json") as f:
cfg = cast(dict[str, Any], json.load(f))
reduced = _reduce_config(copy.deepcopy(cfg))
(tmpdir / "config.json").write_text(json.dumps(reduced))
# Copy tokenizer
tok_src = snapshot
if spec.tokenizer_hub is not None:
alt = _find_snapshot(spec.tokenizer_hub)
if alt is not None:
tok_src = alt
_copy_tokenizer(tok_src, tmpdir)
# Load tokenizer, build model with random weights
model_id = ModelId(f"mlx-community/{spec.hub_name}")
tokenizer = load_tokenizer_for_model_id(model_id, tmpdir)
mx.random.seed(0)
model = _build_model(spec.module, reduced)
task = _make_task()
# ── Run mlx_generate path ──
# Seed is set inside mlx_generate/ExoBatchGenerator.submit from task.seed
kv_mlx = KVPrefixCache(None)
mlx_tokens = _collect_mlx_generate(model, tokenizer, task, kv_mlx)
# ── Run batch generator path ──
kv_batch = KVPrefixCache(None)
batch_tokens = _collect_batch_generate(model, tokenizer, task, kv_batch)
# ── Compare output tokens ──
assert len(mlx_tokens) > 0, "mlx_generate produced no tokens"
assert len(batch_tokens) > 0, "BatchGenerator produced no tokens"
assert mlx_tokens == batch_tokens, (
f"[{spec.name}] Token mismatch:\n"
f" mlx_generate: {mlx_tokens}\n"
f" BatchGenerator: {batch_tokens}"
)
# ── Compare prefix cache KV arrays ──
assert len(kv_mlx.caches) == 1, "mlx_generate didn't save to prefix cache"
assert len(kv_batch.caches) == 1, (
"BatchGenerator didn't save to prefix cache"
)
_compare_cache_arrays(
kv_mlx.caches[0],
kv_batch.caches[0],
label=f"[{spec.name}] ",
)
# ── Compare cache lengths ──
mlx_len = cache_length(kv_mlx.caches[0])
batch_len = cache_length(kv_batch.caches[0])
assert mlx_len == batch_len, (
f"[{spec.name}] Cache length: mlx={mlx_len} vs batch={batch_len}"
)
# ── Compare snapshots ──
_compare_snapshots(
kv_mlx._snapshots[0], # pyright: ignore[reportPrivateUsage]
kv_batch._snapshots[0], # pyright: ignore[reportPrivateUsage]
label=f"[{spec.name}] ",
)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@pytest.mark.parametrize(
"spec",
ARCHITECTURES,
ids=[a.name for a in ARCHITECTURES],
)
def test_concurrent_batch_completes(self, spec: ArchSpec) -> None:
"""Two requests processed concurrently must both complete without
crashing and produce non-empty output.
Note: batch decode logits are NOT bit-exact with sequential because
Metal's matmul kernel picks different reduction tiling for B=1 vs B=2
when L=1 (decode step). This introduces sub-ULP float16 diffs in
gate_proj/down_proj/lm_head which swiglu amplifies by |up_values|.
With random weights these accumulate into argmax flips; with trained
weights the diffs are absorbed and output matches exactly (verified
with real Llama-3.2-1B-Instruct-4bit weights).
"""
if not _arch_available(spec):
pytest.skip(f"Model {spec.hub_name} not cached locally")
snapshot = _find_snapshot(spec.hub_name)
assert snapshot is not None
tmpdir = Path(tempfile.mkdtemp(prefix=f"exo_concurrent_{spec.name}_"))
try:
with open(snapshot / "config.json") as f:
cfg = cast(dict[str, Any], json.load(f))
reduced = _reduce_config(copy.deepcopy(cfg))
(tmpdir / "config.json").write_text(json.dumps(reduced))
tok_src = snapshot
if spec.tokenizer_hub is not None:
alt = _find_snapshot(spec.tokenizer_hub)
if alt is not None:
tok_src = alt
_copy_tokenizer(tok_src, tmpdir)
model_id = ModelId(f"mlx-community/{spec.hub_name}")
tokenizer = load_tokenizer_for_model_id(model_id, tmpdir)
mx.random.seed(0)
model = _build_model(spec.module, reduced)
# Two different prompts → different prompt lengths.
task_a = _make_task(content="Hello, what is 2+2?", seed=42)
task_a = task_a.model_copy(update={"temperature": 0.0})
task_b = _make_task(
content="Write a short poem about the ocean and the sky.",
seed=99,
)
task_b = task_b.model_copy(update={"temperature": 0.0})
# ── Concurrent: submit both to one ExoBatchGenerator ──
exo_gen = ExoBatchGenerator(
model=model,
tokenizer=tokenizer,
group=None,
kv_prefix_cache=None,
)
prompt_a = apply_chat_template(tokenizer=tokenizer, task_params=task_a)
prompt_b = apply_chat_template(tokenizer=tokenizer, task_params=task_b)
uid_a = exo_gen.submit(task_params=task_a, prompt=prompt_a)
uid_b = exo_gen.submit(task_params=task_b, prompt=prompt_b)
batch_tokens: dict[int, list[int]] = {uid_a: [], uid_b: []}
finished: set[int] = set()
while exo_gen.has_work:
results = exo_gen.step()
for uid, response in results:
batch_tokens[uid].append(response.token)
if response.finish_reason is not None:
finished.add(uid)
exo_gen.close()
# ── Verify both completed ──
assert len(batch_tokens[uid_a]) > 0, "No tokens for task A"
assert len(batch_tokens[uid_b]) > 0, "No tokens for task B"
assert uid_a in finished, "Task A never finished"
assert uid_b in finished, "Task B never finished"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@@ -190,7 +190,7 @@ ARCHITECTURES: list[ArchSpec] = [
def _arch_available(spec: ArchSpec) -> bool:
snap = _find_snapshot(spec.hub_name)
if snap is None:
if snap is None or not (snap / "config.json").exists():
return False
if spec.tokenizer_hub is not None:
return _find_snapshot(spec.tokenizer_hub) is not None
@@ -2,6 +2,7 @@ import json
from collections.abc import Generator
from typing import Any
from exo.shared.types.common import ModelId
from exo.shared.types.worker.runner_response import (
GenerationResponse,
ToolCallResponse,
@@ -965,3 +966,70 @@ class TestE2EFullRoundTrip:
assert "sunny" in final_text.lower()
assert "5°C" in final_text
assert "12°C" in final_text
class TestMultiTurnThinkingPrompt:
def test_no_orphan_think_end_in_multiturn(self):
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hi!"},
{"role": "assistant", "content": "Hello! How can I help you today?"},
{"role": "user", "content": "Tell me about Paris."},
]
prompt = encode_messages(messages, thinking_mode="thinking")
assistant_token = "<\uff5cAssistant\uff5c>"
parts = prompt.split(assistant_token)
for part in parts[1:]:
assert not part.startswith(THINKING_END), (
f"Orphan </think> without <think> after <Assistant>: ...{assistant_token}{part[:50]}"
)
class TestApplyChatTemplateWithToolCalls:
def test_dsml_encoding_with_tool_calls_in_history(self):
from exo.shared.types.text_generation import (
InputMessage,
TextGenerationTaskParams,
)
from exo.worker.engines.mlx.utils_mlx import apply_chat_template
chat_template_messages: list[dict[str, Any]] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Tokyo"}',
},
}
],
},
{"role": "tool", "content": "Sunny, 25°C"},
{"role": "user", "content": "Thanks!"},
]
from unittest.mock import MagicMock
tokenizer = MagicMock()
tokenizer.has_thinking = True
tokenizer.think_start = "<think>"
tokenizer.think_end = "</think>"
params = TextGenerationTaskParams(
model=ModelId("mlx-community/DeepSeek-V3.2-8bit"),
input=[InputMessage(role="user", content="Thanks!")],
instructions="You are a helpful assistant.",
enable_thinking=True,
chat_template_messages=chat_template_messages,
tools=_WEATHER_TOOLS,
)
prompt = apply_chat_template(tokenizer, params)
assert "get_weather" in prompt
assert "Tokyo" in prompt
assert "Sunny" in prompt
@@ -0,0 +1,332 @@
from collections.abc import Generator
from typing import Any
from exo.shared.types.worker.runner_response import (
FinishReason,
GenerationResponse,
ToolCallResponse,
)
from exo.worker.engines.mlx.dsml_encoding import (
DSML_TOKEN,
THINKING_END,
THINKING_START,
TOOL_CALLS_END,
TOOL_CALLS_START,
)
from exo.worker.runner.llm_inference.model_output_parsers import (
parse_deepseek_v32,
parse_thinking_models,
parse_tool_calls,
)
from exo.worker.runner.llm_inference.tool_parsers import make_mlx_parser
def _make_response(
text: str, token: int, finish_reason: FinishReason | None = None
) -> GenerationResponse:
return GenerationResponse(
text=text, token=token, finish_reason=finish_reason, usage=None
)
def _queue_source(
tokens: list[GenerationResponse],
) -> Generator[GenerationResponse | None]:
for token in tokens:
yield token
yield None
while True:
yield None
def _step_until_finish(
parser_gen: Generator[GenerationResponse | ToolCallResponse | None],
max_steps: int = 200,
) -> list[GenerationResponse | ToolCallResponse]:
results: list[GenerationResponse | ToolCallResponse] = []
for _ in range(max_steps):
try:
result = next(parser_gen)
except StopIteration:
break
if result is None:
continue
results.append(result)
if isinstance(result, GenerationResponse) and result.finish_reason is not None:
return results
if isinstance(result, ToolCallResponse):
return results
return results
def _got_finish(results: list[GenerationResponse | ToolCallResponse]) -> bool:
for r in results:
if isinstance(r, ToolCallResponse):
return True
if r.finish_reason is not None:
return True
return False
# ── parse_deepseek_v32 ──────────────────────────────────────────
class TestDeepSeekV32FinishReason:
def test_finish_reason_with_buffered_dsml_prefix(self):
tokens = [
_make_response("Hello! The answer is x", 0),
_make_response("<", 1),
_make_response("", 2, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
full_text = "".join(
r.text for r in results if isinstance(r, GenerationResponse)
)
assert "Hello" in full_text
assert "<" in full_text
def test_finish_reason_completes_tool_call_block(self):
tokens = [
_make_response(TOOL_CALLS_START, 0),
_make_response("\n", 1),
_make_response(f'<{DSML_TOKEN}invoke name="get_weather">\n', 2),
_make_response(
f'<{DSML_TOKEN}parameter name="city" string="true">Tokyo</{DSML_TOKEN}parameter>\n',
3,
),
_make_response(f"</{DSML_TOKEN}invoke>\n", 4),
_make_response(TOOL_CALLS_END, 5, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
assert tool_results[0].tool_calls[0].name == "get_weather"
def test_finish_reason_mid_tool_call_before_close(self):
tokens = [
_make_response(TOOL_CALLS_START, 0),
_make_response("\n", 1),
_make_response(
f'<{DSML_TOKEN}invoke name="get_weather">\n', 2, finish_reason="stop"
),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
def test_finish_reason_single_token_complete_dsml_block(self):
dsml_block = (
f"{TOOL_CALLS_START}\n"
f'<{DSML_TOKEN}invoke name="get_weather">\n'
f'<{DSML_TOKEN}parameter name="city" string="true">Tokyo</{DSML_TOKEN}parameter>\n'
f"</{DSML_TOKEN}invoke>\n"
f"{TOOL_CALLS_END}"
)
tokens = [_make_response(dsml_block, 0, finish_reason="stop")]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
assert tool_results[0].tool_calls[0].name == "get_weather"
def test_finish_reason_during_thinking(self):
tokens = [
_make_response(THINKING_START, 0),
_make_response("I need to think about this", 1),
_make_response(" carefully before responding", 2, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
def test_finish_reason_after_thinking_then_tool_call(self):
tokens = [
_make_response(THINKING_START, 0),
_make_response("Let me check the weather.", 1),
_make_response(THINKING_END, 2),
_make_response("\n\n", 3),
_make_response(TOOL_CALLS_START, 4),
_make_response("\n", 5),
_make_response(f'<{DSML_TOKEN}invoke name="get_weather">\n', 6),
_make_response(
f'<{DSML_TOKEN}parameter name="city" string="true">NYC</{DSML_TOKEN}parameter>\n',
7,
),
_make_response(f"</{DSML_TOKEN}invoke>\n", 8),
_make_response(TOOL_CALLS_END, 9, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
assert tool_results[0].tool_calls[0].name == "get_weather"
def test_finish_reason_normal_text_no_buffering(self):
tokens = [
_make_response("Hello", 0),
_make_response(" world", 1),
_make_response("!", 2, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
full_text = "".join(
r.text for r in results if isinstance(r, GenerationResponse)
)
assert full_text == "Hello world!"
def test_finish_reason_multiple_buffered_prefix_tokens(self):
tokens = [
_make_response("text ", 0),
_make_response("<", 1),
_make_response("not a tag", 2),
_make_response(" more<", 3),
_make_response("", 4, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
# ── parse_thinking_models ────────────────────────────────────────
class TestThinkingModelsFinishReason:
def test_finish_reason_during_thinking(self):
tokens = [
_make_response("<think>", 0),
_make_response("reasoning here", 1),
_make_response("more reasoning", 2, finish_reason="stop"),
]
results = _step_until_finish(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=False,
)
)
assert _got_finish(results)
last_gen = [
r
for r in results
if isinstance(r, GenerationResponse) and r.finish_reason is not None
]
assert len(last_gen) == 1
assert last_gen[0].is_thinking is False
def test_finish_reason_after_thinking(self):
tokens = [
_make_response("<think>", 0),
_make_response("hmm", 1),
_make_response("</think>", 2),
_make_response("The answer is 42.", 3, finish_reason="stop"),
]
results = _step_until_finish(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=False,
)
)
assert _got_finish(results)
def test_finish_reason_starts_in_thinking(self):
tokens = [
_make_response("still thinking", 0),
_make_response("</think>", 1),
_make_response("done", 2, finish_reason="stop"),
]
results = _step_until_finish(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=True,
)
)
assert _got_finish(results)
# ── parse_tool_calls (generic) ──────────────────────────────────
def _dummy_parser_fn(text: str) -> dict[str, Any]:
return {"name": "test_fn", "arguments": {"arg": text}}
_dummy_parser = make_mlx_parser("<tool_call>", "</tool_call>", _dummy_parser_fn)
class TestGenericToolCallsFinishReason:
def test_finish_reason_after_complete_tool_call(self):
tokens = [
_make_response("<tool_call>", 0),
_make_response("body", 1),
_make_response("</tool_call>", 2),
_make_response("extra text", 3, finish_reason="stop"),
]
results = _step_until_finish(
parse_tool_calls(
_queue_source(tokens),
_dummy_parser,
tools=None,
)
)
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
def test_finish_reason_mid_tool_call_unclosed(self):
tokens = [
_make_response("<tool_call>", 0),
_make_response("partial content", 1, finish_reason="stop"),
]
results = _step_until_finish(
parse_tool_calls(
_queue_source(tokens),
_dummy_parser,
tools=None,
)
)
assert _got_finish(results)
def test_finish_reason_no_tool_calls(self):
tokens = [
_make_response("Just", 0),
_make_response(" a", 1),
_make_response(" normal", 2),
_make_response(" response.", 3, finish_reason="stop"),
]
results = _step_until_finish(
parse_tool_calls(
_queue_source(tokens),
_dummy_parser,
tools=None,
)
)
assert _got_finish(results)
# ── Double parser chain (parse_thinking_models → parse_deepseek_v32) ──
class TestBatchGeneratorSingleNext:
def test_finish_reason_with_buffered_tokens_drain_loop(self):
from exo.worker.runner.llm_inference.batch_generator import GeneratorQueue
queue: GeneratorQueue[GenerationResponse] = GeneratorQueue()
parser = parse_deepseek_v32(queue.gen())
tokens = [
_make_response("Hello ", 0),
_make_response(" `<", 1),
_make_response("", 2, finish_reason="stop"),
]
collected: list[GenerationResponse | ToolCallResponse] = []
for token in tokens:
queue.push(token)
while (parsed := next(parser, None)) is not None:
collected.append(parsed)
if token.finish_reason is not None:
break
assert _got_finish(collected), (
f"No finish_reason in collected: {[(type(r).__name__, getattr(r, 'finish_reason', None) if isinstance(r, GenerationResponse) else 'tool') for r in collected]}"
)
+11 -4
View File
@@ -11,10 +11,17 @@ set -euo pipefail
exit 1
}
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null) || {
echo "No upstream"
exit 1
}
commit=$(git rev-parse HEAD)
git fetch -q origin
git branch -r --contains "$commit" | grep -qE '^\s*origin/' || {
echo "Not pushed to origin"
remote=${upstream%%/*}
remote_installable=$(git remote get-url "$remote" | sed -E "s#^(git@github.com:|https://github\.com/)([^/]+)/([^/]+)(\.git)?\$#github:\2/\3/$commit#")
git fetch -q "$remote"
git branch -r --contains "$commit" | grep -qE "^[[:space:]]*$remote/" || {
echo "Not pushed to $remote"
exit 1
}
@@ -35,7 +42,7 @@ i=0
for host; do
colour=${colours[i++ % 4]}
ssh -T -o BatchMode=yes -o ServerAliveInterval=30 "$host@$host" \
"EXO_LIBP2P_NAMESPACE=$commit /nix/var/nix/profiles/default/bin/nix run github:exo-explore/exo/$commit" |&
"EXO_LIBP2P_NAMESPACE=$commit /nix/var/nix/profiles/default/bin/nix run $remote_installable" 2>&1 |
awk -v p="${colour}[${host}]${reset}" '{ print p $0; fflush() }' &
done
Generated
+2 -2
View File
@@ -524,7 +524,7 @@ requires-dist = [
{ name = "mflux", specifier = "==0.17.2" },
{ name = "mlx", marker = "sys_platform == 'darwin'", git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks" },
{ name = "mlx", extras = ["cpu"], marker = "sys_platform == 'linux'", specifier = "==0.30.6" },
{ name = "mlx-lm", git = "https://github.com/rltakashige/mlx-lm?branch=fix%2Ffloat32-logprobs" },
{ name = "mlx-lm", git = "https://github.com/rltakashige/mlx-lm?branch=leo%2Ffix-deepseek-v32-indexer" },
{ name = "msgspec", specifier = ">=0.19.0" },
{ name = "openai-harmony", specifier = ">=0.0.8" },
{ name = "psutil", specifier = ">=7.0.0" },
@@ -1446,7 +1446,7 @@ wheels = [
[[package]]
name = "mlx-lm"
version = "0.31.2"
source = { git = "https://github.com/rltakashige/mlx-lm?branch=fix%2Ffloat32-logprobs#8e94256220f954949133e036980951681e353945" }
source = { git = "https://github.com/rltakashige/mlx-lm?branch=leo%2Ffix-deepseek-v32-indexer#d388ff77858fec3b5d2e3b1d9502a7e2878b8109" }
dependencies = [
{ name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.31.2.dev20260324+e5e64331", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#e5e64331830d9b04ae9082b843073f9c1fa7705e" }, marker = "sys_platform == 'darwin'" },