From 455759834a263faf0b4d719515074731e1b1cd6f Mon Sep 17 00:00:00 2001 From: Thomas Tupper Date: Tue, 7 Apr 2026 20:03:55 -0500 Subject: [PATCH 01/21] Add RotorQuant KV cache backend with deferred prefill on Metal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-MLX port of IsoQuant 3-bit (block-diagonal quaternion rotations + Lloyd-Max centroids) with deferred prefill — a contribution that does not exist in any upstream project, since the llama.cpp fork ships the deferred-prefill flush CUDA-only. GQA-native, no fallback for grouped-query models. Also adds the "Everything Different About Skulk" living section to README.md and website/docs/everything-different.md so the divergence from upstream exo lives in one canonical place going forward. Tracked by #102. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 86 ++- .../src/components/layout/SettingsPanel.tsx | 8 +- src/exo/store/config.py | 8 +- src/exo/worker/engines/mlx/cache.py | 29 + src/exo/worker/engines/mlx/constants.py | 16 + .../worker/engines/mlx/rotorquant/__init__.py | 30 + .../worker/engines/mlx/rotorquant/cache.py | 545 ++++++++++++++++++ .../engines/mlx/rotorquant/quantizer.py | 146 +++++ .../worker/engines/mlx/rotorquant/rotation.py | 104 ++++ .../worker/engines/mlx/rotorquant/tables.py | 102 ++++ .../engines/mlx/rotorquant/tests/__init__.py | 0 .../mlx/rotorquant/tests/test_cache.py | 120 ++++ .../rotorquant/tests/test_iso_quantizer.py | 118 ++++ src/exo/worker/runner/llm_inference/runner.py | 2 + website/docs/everything-different.md | 95 +++ website/docs/intro.md | 1 + website/docs/kv-cache-backends.md | 29 +- website/sidebars.ts | 5 + 18 files changed, 1436 insertions(+), 8 deletions(-) create mode 100644 src/exo/worker/engines/mlx/rotorquant/__init__.py create mode 100644 src/exo/worker/engines/mlx/rotorquant/cache.py create mode 100644 src/exo/worker/engines/mlx/rotorquant/quantizer.py create mode 100644 src/exo/worker/engines/mlx/rotorquant/rotation.py create mode 100644 src/exo/worker/engines/mlx/rotorquant/tables.py create mode 100644 src/exo/worker/engines/mlx/rotorquant/tests/__init__.py create mode 100644 src/exo/worker/engines/mlx/rotorquant/tests/test_cache.py create mode 100644 src/exo/worker/engines/mlx/rotorquant/tests/test_iso_quantizer.py create mode 100644 website/docs/everything-different.md diff --git a/README.md b/README.md index 56e8008b5..8bb5646d4 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,90 @@ a more modern dashboard, richer API workflows, sophisticated cache quantization, - Form a small cluster of Macs and split larger models across them. - Use a central model store so the cluster downloads once and stages locally. - Talk to the cluster through OpenAI Chat Completions, OpenAI Responses, Claude Messages, or Ollama-compatible APIs. +- Push KV cache memory down hard with rotation-based 3-bit quantization (RotorQuant, OptiQ, TurboQuant) and pick a backend per workload from the dashboard. +- Use a model-aware reasoning contract that handles toggleable and non-toggleable thinking models without baking assumptions into client code. - Experiment with advanced placement modes, RDMA, and KV cache backends when you are ready. - Run non-chat workloads such as embeddings and other specialized model flows. - Build TTS-oriented and other API-driven workflows on top of the cluster. - Actually use your cluster for real inference workloads instead of treating it as a demo. +## Everything Different About Skulk + +This is the running list of where Skulk diverges from upstream [exo](https://github.com/exo-explore/exo). It is a living section — every meaningful change should land here when it ships, so anyone evaluating Skulk can see the surface area at a glance. + +### Inference and KV cache + +- **RotorQuant KV cache backend** — pure-MLX port of IsoQuant 3-bit (block-diagonal quaternion rotations + Lloyd-Max centroids) with **deferred prefill on Metal**, a contribution that does not exist in any upstream project (the llama.cpp fork ships it CUDA-only). GQA-native; no fallback for grouped-query models. See [docs/kv-cache-backends.md](docs/kv-cache-backends.md). +- **TurboQuant native and adaptive backends** — randomized Hadamard rotation + Lloyd-Max centroids, with an adaptive variant that keeps edge attention layers in fp16 for accuracy. +- **OptiQ KV cache integration** — wraps `mlx-optiq`'s rotated-space attention path so the rotation cost stays out of the per-token loop on supported (non-GQA) models. +- **OptiQ mixed-precision weight quantization pipeline** — async wrapper around `mlx-optiq`'s sensitivity analysis and KL-divergence per-layer bit allocation, exposed as a model-store optimization job. +- **KV prefix cache with snapshot/restore** — LRU-evicted prompt-prefix cache that snapshots SSM and rotating-window cache states so prefix matches are reusable across conversation turns even for hybrid Mamba/Transformer architectures. +- **Pipeline-parallel prefill for short prompts** — pipelined models now route every prefill through the pipeline path, fixing prior warmup hangs on Gemma-class models. +- **Force-sequential fallback** — quantized backends transparently fall back to a sequential generator when batch/history mode is incompatible with their cache layout. + +### Model capability system + +- **Two-layer capability model** — declarative `ModelCard` (with optional `reasoning`, `modalities`, `tooling`, and `runtime` sections) plus a normalized `ResolvedCapabilityProfile` derived from the card and conservative family defaults. This is the source of truth for prompt rendering, output parsing, tool-call handling, and the `/v1/models` `resolved_capabilities` field. +- **Phase 2 thinking contract** — `enable_thinking`, `reasoning_effort`, and the dashboard thinking toggle are all driven by `supports_thinking_toggle`, so non-toggleable reasoning models behave correctly without leaking model-specific quirks into client code. +- **Output parser selection** — model cards declare `output_parser` (`generic`, `gemma4`, `gpt_oss`, `deepseek_v32`, etc.), so reasoning markers are normalized into structured `reasoning_content` per family. +- **Model store metadata pipeline** — capability resolution feeds `/v1/models` so dashboards and clients can discover thinking, multimodal, and tool support without hardcoding model lists. + +### API surface + +- **Claude Messages API** — `/v1/messages` adapter, including streaming, tool use, image inputs, and capability-aware thinking controls. +- **Ollama compatibility** — both `/api/chat` and `/api/generate`, with adapter-side reasoning normalization. +- **OpenAI Responses API** — `/v1/responses` adapter alongside chat completions. +- **Embeddings endpoint** for non-chat workloads. +- **Model store endpoints** — search, add, download, capability resolution, optimization jobs, and registry management, all exposed under stable URLs and documented in the OpenAPI spec. +- **Cluster-wide config endpoints** — `GET`/`POST` config that gossipsubs to every node and writes back to `skulk.yaml`. +- **Tracing, downloads, instance previews, and placement endpoints** — distributed-system observability and pre-launch placement inspection that upstream does not expose. + +### Dashboard + +- **React dashboard (default)** — replaces upstream's Svelte UI with a typed React + styled-components app that ships with the binary. The legacy Svelte dashboard is kept only as a fallback in the repo. +- **Cluster topology view** with live device icons, GPU stats, network mesh visualization, and connection status banners. +- **Placement preview / placement manager** for inspecting and choosing valid placements before launching. +- **Model store browser** with HuggingFace search, family sidebar, model filters, capability badges, recent models, and per-model launch controls. +- **Reasoning-aware chat UI** that splits inline `` and Gemma `<|channel>` markers into a dedicated thinking panel and merges them with `reasoning_content` deltas from the API. +- **Image attachments and multimodal chat affordances** for vision models. +- **Cluster-wide settings panel** that writes to `skulk.yaml` and syncs across nodes via gossipsub. +- **Light and dark themes** with first-class theme tokens, screenshots in both modes for documentation work. + +### Centralized logging and observability + +- **Structured JSON stdout** when `logging.enabled` is set, configurable from the dashboard Settings panel and synced cluster-wide. +- **Vector + VictoriaLogs + Grafana stack** — local Vector log shipper on each node, central VictoriaLogs storage, ready-made Grafana dashboards. Stack definition lives in `deployment/logging/`. +- **Distributed tracing** opt-in via `EXO_TRACING_ENABLED`. + +### Model store + +- **Centralized model store host** — one node downloads, the rest of the cluster stages over the LAN. +- **Persistent registry** with capability resolution and download tracking. +- **Custom model card support** — add your own model with `POST /models/add`. +- **Image and embedding model cards** behind feature flags. +- **Optimization job pipeline** for mlx-optiq mixed-precision weight quantization. + +### Cluster operation + +- **Cluster-wide settings sync** for KV cache backend, logging, model store host, HF token, and other inference toggles. +- **Bootstrap peer config from `skulk.yaml`, env, or CLI** for fixed-topology clusters. +- **Election (bully algorithm) + master/worker split** for indexing events and broadcasting state. +- **`SKULK_*` environment variables** alongside the legacy `EXO_*` set, so new options can land without colliding with upstream. +- **`skulk.yaml`** as the canonical config file, with `exo.yaml` kept for backwards compatibility. + +### Build, type system, and dev workflow + +- **Strict basedpyright** type checking — zero-error policy for new code. +- **Ruff** linting and **`nix fmt`** formatting in CI. +- **Nix flake** for reproducible toolchain setup. +- **Docusaurus docs site** with auto-generated OpenAPI per-endpoint pages and TypeDoc HTML reference for the dashboard, both built from source. +- **Pre-commit checklist** documented in `CLAUDE.md` and enforced in CI. + +### Hardware and platform + +- **Apple Silicon as the primary target**, including RDMA over Thunderbolt 5 on supported hardware and matched macOS versions. +- **Linux supported** (CPU-oriented in this fork; GPU work happens on Apple Silicon). + ## Prerequisites ### macOS @@ -106,7 +185,9 @@ Important behavior: - **Placement previews**: inspect valid placements before launching a model. - **Thinking-aware chat UI**: chat with compatible models and surface reasoning content. - **Alternative API compatibility**: OpenAI Chat Completions, OpenAI Responses, Claude Messages, and Ollama. -- **Experimental inference tuning**: OptiQ and other KV cache backends for long-context and memory experiments. +- **Rotation-based KV cache backends**: RotorQuant (IsoQuant 3-bit + deferred prefill), OptiQ, TurboQuant, and TurboQuant Adaptive — pick per workload from the dashboard. +- **Capability-driven thinking contract**: model cards declare reasoning support; the API and dashboard route accordingly. +- **Experimental inference tuning**: long-context and memory experiments via the KV cache backends above. ## Dashboard @@ -373,6 +454,8 @@ uv run exo --bootstrap-peers /ip4/192.168.1.20/tcp/5678/p2p/12D3KooW... | `EXO_NO_BATCH` | Force sequential generation | `false` | | `EXO_OPTIQ_BITS` | Bit width for `optiq` | `4` | | `EXO_OPTIQ_FP16_LAYERS` | Edge FP16 layers for `optiq` | `4` | +| `SKULK_ROTORQUANT_FP16_LAYERS` | Edge FP16 layers for `rotorquant_adaptive` | `4` | +| `SKULK_ROTORQUANT_DEFER_PREFILL` | Set to `0` to disable deferred prefill (debugging only) | `1` | | `EXO_BOOTSTRAP_PEERS` | Comma-separated static peers to dial on startup | None | | `HF_TOKEN` | Hugging Face token | None | @@ -382,6 +465,7 @@ Examples: EXO_OFFLINE=true uv run exo EXO_ENABLE_IMAGE_MODELS=true uv run exo EXO_KV_CACHE_BACKEND=optiq EXO_OPTIQ_BITS=4 EXO_OPTIQ_FP16_LAYERS=4 uv run exo +SKULK_KV_CACHE_BACKEND=rotorquant_adaptive SKULK_ROTORQUANT_FP16_LAYERS=4 uv run exo ``` ## RDMA on macOS diff --git a/dashboard-react/src/components/layout/SettingsPanel.tsx b/dashboard-react/src/components/layout/SettingsPanel.tsx index 6b080cf94..e177ffe7b 100644 --- a/dashboard-react/src/components/layout/SettingsPanel.tsx +++ b/dashboard-react/src/components/layout/SettingsPanel.tsx @@ -449,9 +449,11 @@ export function SettingsPanel({ open, onClose }: SettingsPanelProps) { filled content={ `• Default — No cache quantization. Best baseline quality, highest memory use.\n` + - `• OptiQ — Rotation-based quantization via mlx-optiq. Best long-context quality.\n` + + `• RotorQuant Adaptive — IsoQuant 3-bit with deferred prefill, FP16 edge layers. Recommended.\n` + + `• RotorQuant — IsoQuant 3-bit on all KV layers. Most aggressive compression with deferred prefill.\n` + + `• OptiQ — Rotation-based quantization via mlx-optiq. Good long-context quality, no GQA support.\n` + `• TurboQuant Adaptive — Quantizes middle KV layers, keeps edge layers in FP16. Proven stable.\n` + - `• TurboQuant — Quantizes all KV layers. Most aggressive compression, higher quality risk.\n` + + `• TurboQuant — Quantizes all KV layers. Most aggressive non-rotorquant compression.\n` + `• MLX Quantized — MLX's built-in cache quantization.\n\n` + `Takes effect on next model launch. Incompatible models fall back to Default automatically.` } @@ -459,6 +461,8 @@ export function SettingsPanel({ open, onClose }: SettingsPanelProps) { setKvBackend(e.target.value)} disabled={!!envOverride}> - - + {kvBackend === 'rotorquant_adaptive' ? ( + + ) : null} + {kvBackend === 'rotorquant' ? ( + + ) : null} @@ -471,7 +473,7 @@ export function SettingsPanel({ open, onClose }: SettingsPanelProps) { {envOverride ? ( Overridden by SKULK_KV_CACHE_BACKEND environment variable. Remove the env var to configure here. ) : ( - Changes take effect on the next model launch. OptiQ falls back to Default for unsupported architectures; other backends will error on incompatible models. + Changes take effect on the next model launch. RotorQuant is experimental and intentionally unavailable from normal settings. )} diff --git a/docs/kv-cache-backends.md b/docs/kv-cache-backends.md index 1c21b8644..af7356af1 100644 --- a/docs/kv-cache-backends.md +++ b/docs/kv-cache-backends.md @@ -11,6 +11,8 @@ Skulk includes several opt-in KV cache backends for MLX text generation. These b - `turboquant`: correctness-first TurboQuant-inspired KV cache for standard `KVCache` layers - `turboquant_adaptive`: keeps outer KV layers in FP16 and applies TurboQuant to middle KV layers - `optiq`: **[NEW]** rotation-based KV cache via [mlx-optiq](https://mlx-optiq.pages.dev/) — uses randomized orthogonal rotations with Lloyd-Max quantization and rotated-space attention for superior long-context quality +- `rotorquant`: **experimental, gated** pure-MLX IsoQuant-style storage/dequant cache. This is not the fused RotorQuant+QJL implementation from the RotorQuant paper. +- `rotorquant_adaptive`: **experimental, gated** as above with FP16 protection on the first/last N attention layers. If `SKULK_KV_CACHE_BACKEND` is unset, or is set to `default`, Skulk behaves as before. @@ -39,6 +41,23 @@ uv run skulk This mode keeps the first and last 4 KV layers in normal FP16-style cache and applies TurboQuant only to the middle KV layers. Proven stable across most models. +### RotorQuant / IsoQuant (experimental only) + +The `rotorquant` names are intentionally gated behind an explicit opt-in: + +```bash +SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT=1 \ +SKULK_KV_CACHE_BACKEND=rotorquant_adaptive \ +SKULK_ROTORQUANT_FP16_LAYERS=4 \ +uv run skulk +``` + +This backend is a pure-MLX IsoQuant-style cache that compresses storage and +then returns fully dequantized fp16 K/V to normal MLX attention. It does not +implement RotorQuant's fused Metal/CUDA attention path or QJL residual +correction, so it should not be used as the default distributed inference +baseline. + ## Available Environment Variables | Variable | Backends | Default | Description | @@ -50,6 +69,9 @@ This mode keeps the first and last 4 KV layers in normal FP16-style cache and ap | `SKULK_TQ_K_BITS` | `turboquant`, `turboquant_adaptive` | `3` | Key quantization bits | | `SKULK_TQ_V_BITS` | `turboquant`, `turboquant_adaptive` | `4` | Value quantization bits | | `SKULK_TQ_FP16_LAYERS` | `turboquant_adaptive` | `4` | Edge layers kept in FP16 | +| `SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT` | `rotorquant`, `rotorquant_adaptive` | `0` | Required opt-in for experimental RotorQuant/IsoQuant backends | +| `SKULK_ROTORQUANT_FP16_LAYERS` | `rotorquant_adaptive` | `4` | Edge layers kept in FP16 | +| `SKULK_ROTORQUANT_DEFER_PREFILL` | `rotorquant`, `rotorquant_adaptive` | `1` | Set to `0` to disable deferred prefill while debugging | ## Invocation Examples @@ -86,10 +108,12 @@ SKULK_KV_CACHE_BACKEND=turboquant_adaptive SKULK_TQ_K_BITS=3 SKULK_TQ_V_BITS=4 S | `turboquant_adaptive` | Low | Good | Moderate | Proven stable, Hadamard-based | | `turboquant` | Lowest | Variable | Moderate | Most aggressive compression | | `mlx_quantized` | Low | Good | Moderate | MLX built-in quantization | +| `rotorquant_adaptive` | Low | Experimental | Experimental | Gated pure-MLX IsoQuant storage/dequant cache | +| `rotorquant` | Lowest | Experimental | Experimental | Gated pure-MLX IsoQuant storage/dequant cache | ## Supported Cache Layouts -All quantized backends (optiq, turboquant, mlx_quantized) compress only standard `KVCache` entries and preserve these cache types unchanged: +All quantized backends (optiq, turboquant, mlx_quantized, and the gated rotorquant variants) compress only standard `KVCache` entries and preserve these cache types unchanged: - `ArraysCache` - `RotatingKVCache` @@ -105,6 +129,7 @@ Mixed cache layouts are supported: - All quantized KV cache backends force sequential generation (no batch/history mode) - The optiq backend requires `mlx-optiq` to be installed (`pip install mlx-optiq`) - The optiq backend's `patch_attention()` monkey-patches MLX's SDPA — avoid switching between optiq and other backends within the same process lifetime without a restart +- The rotorquant backends are disabled unless `SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT=1` is set. If selected without the gate, Skulk falls back to `default`. ## About mlx-optiq diff --git a/src/exo/api/main.py b/src/exo/api/main.py index 7e68b1f7f..af74fcb9a 100644 --- a/src/exo/api/main.py +++ b/src/exo/api/main.py @@ -204,6 +204,7 @@ from exo.worker.engines.mlx.constants import ( DEFAULT_KV_CACHE_BACKEND, VALID_KV_CACHE_BACKENDS, + resolve_kv_cache_backend, ) if TYPE_CHECKING: @@ -1197,8 +1198,7 @@ async def _send_text_generation_with_images( ) for idx, h in cached_hashes.items(): _log_image_transport( - f"TextGeneration cached image {idx}: " - f"b64_sha256={h[:12]}..." + f"TextGeneration cached image {idx}: b64_sha256={h[:12]}..." ) if not new_images: @@ -2214,9 +2214,7 @@ async def get_models(self, status: str | None = Query(default=None)) -> ModelLis downloaded_model_ids.add(dl.shard_metadata.model_card.model_id) cards = [c for c in cards if c.model_id in downloaded_model_ids] - return ModelList( - data=[self._model_list_entry(card) for card in cards] - ) + return ModelList(data=[self._model_list_entry(card) for card in cards]) 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.""" @@ -2580,7 +2578,7 @@ def _effective_kv_cache_backend(self) -> str: if configured_backend not in VALID_KV_CACHE_BACKENDS: return DEFAULT_KV_CACHE_BACKEND - return configured_backend + return resolve_kv_cache_backend(configured_backend) async def get_config(self) -> JSONResponse: if not self._config_path.exists(): @@ -2678,7 +2676,9 @@ async def update_config(self, request: Request) -> JSONResponse: log_on = bool(logging_cfg_update.get("enabled", False)) and bool( logging_cfg_update.get("ingest_url") ) - set_structured_stdout(log_on, ingest_url=str(logging_cfg_update.get("ingest_url", ""))) + set_structured_stdout( + log_on, ingest_url=str(logging_cfg_update.get("ingest_url", "")) + ) # model_store changes still require restart; inference-only changes don't has_store_changes = "model_store" in config_data return JSONResponse( diff --git a/src/exo/api/tests/test_config_api.py b/src/exo/api/tests/test_config_api.py index 161ac5208..34e177c11 100644 --- a/src/exo/api/tests/test_config_api.py +++ b/src/exo/api/tests/test_config_api.py @@ -60,7 +60,9 @@ def test_get_config_treats_blank_skulk_kv_backend_as_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: config_path = tmp_path / "exo.yaml" - config_path.write_text("inference:\n kv_cache_backend: default\n", encoding="utf-8") + config_path.write_text( + "inference:\n kv_cache_backend: default\n", encoding="utf-8" + ) monkeypatch.setenv("SKULK_KV_CACHE_BACKEND", "") monkeypatch.setenv("EXO_KV_CACHE_BACKEND", "optiq") @@ -79,7 +81,9 @@ def test_get_config_treats_invalid_skulk_kv_backend_as_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: config_path = tmp_path / "exo.yaml" - config_path.write_text("inference:\n kv_cache_backend: default\n", encoding="utf-8") + config_path.write_text( + "inference:\n kv_cache_backend: default\n", encoding="utf-8" + ) monkeypatch.setenv("SKULK_KV_CACHE_BACKEND", "typo-backend") api = _build_api() @@ -91,3 +95,24 @@ def test_get_config_treats_invalid_skulk_kv_backend_as_default( assert response.status_code == 200 data: dict[str, Any] = response.json() assert data["effective"]["kv_cache_backend"] == "default" + + +def test_get_config_treats_ungated_rotorquant_as_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "exo.yaml" + config_path.write_text( + "inference:\n kv_cache_backend: default\n", encoding="utf-8" + ) + monkeypatch.setenv("SKULK_KV_CACHE_BACKEND", "rotorquant_adaptive") + monkeypatch.setenv("SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT", "") + + api = _build_api() + api._config_path = config_path # pyright: ignore[reportPrivateUsage] + client = TestClient(api.app) + + response = client.get("/config") + + assert response.status_code == 200 + data: dict[str, Any] = response.json() + assert data["effective"]["kv_cache_backend"] == "default" diff --git a/src/exo/worker/engines/mlx/cache.py b/src/exo/worker/engines/mlx/cache.py index 78da25272..8fc68c44c 100644 --- a/src/exo/worker/engines/mlx/cache.py +++ b/src/exo/worker/engines/mlx/cache.py @@ -20,6 +20,7 @@ DEFAULT_KV_CACHE_BACKEND, DEFAULT_TURBOQUANT_K_BITS, DEFAULT_TURBOQUANT_V_BITS, + EXPERIMENTAL_ROTORQUANT_BACKENDS, KV_CACHE_BITS, OPTIQ_BITS, OPTIQ_FP16_LAYERS, @@ -30,6 +31,8 @@ TURBOQUANT_V_BITS, VALID_KV_CACHE_BACKENDS, KVCacheBackend, + experimental_rotorquant_enabled, + resolve_kv_cache_backend, ) from exo.worker.engines.mlx.rotorquant import ( make_rotorquant_adaptive_cache, @@ -654,18 +657,35 @@ def get_kv_cache_backend() -> KVCacheBackend: it dynamically (instead of the frozen import-time constant) ensures that runtime updates are always visible to the runner. """ - backend = cast( - KVCacheBackend, + configured_backend = ( preferred_env_value( - "SKULK_KV_CACHE_BACKEND", - "EXO_KV_CACHE_BACKEND", - DEFAULT_KV_CACHE_BACKEND, + "SKULK_KV_CACHE_BACKEND", "EXO_KV_CACHE_BACKEND", DEFAULT_KV_CACHE_BACKEND ) - or DEFAULT_KV_CACHE_BACKEND, + or DEFAULT_KV_CACHE_BACKEND ) + backend = resolve_kv_cache_backend(configured_backend) + + if configured_backend not in VALID_KV_CACHE_BACKENDS: + logger.warning( + f"Unknown KV_CACHE_BACKEND={configured_backend!r}; " + f"falling back to {DEFAULT_KV_CACHE_BACKEND!r}" + ) + return backend + if backend not in VALID_KV_CACHE_BACKENDS: logger.warning( f"Unknown KV_CACHE_BACKEND={backend!r}; falling back to {DEFAULT_KV_CACHE_BACKEND!r}" ) return DEFAULT_KV_CACHE_BACKEND + + if ( + configured_backend in EXPERIMENTAL_ROTORQUANT_BACKENDS + and not experimental_rotorquant_enabled() + ): + logger.warning( + f"KV_CACHE_BACKEND={configured_backend!r} requested, but RotorQuant is " + "an experimental pure-MLX IsoQuant storage/dequant backend. Falling " + "back to 'default'. Set SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT=1 to " + "force this backend for isolated testing." + ) return backend diff --git a/src/exo/worker/engines/mlx/constants.py b/src/exo/worker/engines/mlx/constants.py index 49b4c58e9..ee80c4621 100644 --- a/src/exo/worker/engines/mlx/constants.py +++ b/src/exo/worker/engines/mlx/constants.py @@ -1,5 +1,5 @@ import os -from typing import Literal, cast +from typing import Literal from exo.shared.constants import preferred_env_value @@ -36,15 +36,51 @@ "rotorquant", "rotorquant_adaptive", ) +EXPERIMENTAL_ROTORQUANT_BACKENDS: tuple[KVCacheBackend, ...] = ( + "rotorquant", + "rotorquant_adaptive", +) + + +def experimental_rotorquant_enabled() -> bool: + """Return whether the pure-MLX RotorQuant/IsoQuant backend is enabled. + + The backend is intentionally gated because it is an experimental + storage/dequant cache, not the fused RotorQuant implementation described + in the paper. Keeping this behind an explicit flag prevents model-card or + dashboard config from putting unstable Metal cache code on the normal + inference path. + """ + value = preferred_env_value( + "SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT", + "EXO_ENABLE_EXPERIMENTAL_ROTORQUANT", + "", + ) + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def resolve_kv_cache_backend(raw_backend: str | None) -> KVCacheBackend: + """Normalize a configured KV cache backend into a safe runtime backend.""" + backend = raw_backend or DEFAULT_KV_CACHE_BACKEND + if backend not in VALID_KV_CACHE_BACKENDS: + return DEFAULT_KV_CACHE_BACKEND + + resolved: KVCacheBackend = backend + if ( + resolved in EXPERIMENTAL_ROTORQUANT_BACKENDS + and not experimental_rotorquant_enabled() + ): + return DEFAULT_KV_CACHE_BACKEND + + return resolved + + _kv_cache_backend_value = preferred_env_value( "SKULK_KV_CACHE_BACKEND", "EXO_KV_CACHE_BACKEND", DEFAULT_KV_CACHE_BACKEND, ) -KV_CACHE_BACKEND: KVCacheBackend = cast( - KVCacheBackend, - _kv_cache_backend_value if _kv_cache_backend_value else DEFAULT_KV_CACHE_BACKEND, -) +KV_CACHE_BACKEND: KVCacheBackend = resolve_kv_cache_backend(_kv_cache_backend_value) TURBOQUANT_K_BITS: int | None = ( int(os.environ.get("SKULK_TQ_K_BITS", os.environ.get("EXO_TQ_K_BITS", ""))) if os.environ.get("SKULK_TQ_K_BITS", os.environ.get("EXO_TQ_K_BITS")) @@ -61,19 +97,19 @@ OPTIQ_BITS: int = int(os.environ.get("EXO_OPTIQ_BITS", "4")) OPTIQ_FP16_LAYERS: int = int(os.environ.get("EXO_OPTIQ_FP16_LAYERS", "4")) ROTORQUANT_FP16_LAYERS: int = int( - os.environ.get("SKULK_ROTORQUANT_FP16_LAYERS", os.environ.get("EXO_ROTORQUANT_FP16_LAYERS", "4")) + os.environ.get( + "SKULK_ROTORQUANT_FP16_LAYERS", + os.environ.get("EXO_ROTORQUANT_FP16_LAYERS", "4"), + ) ) # Deferred prefill keeps K/V in fp16 during prompt processing and flushes # the buffer to compressed storage on the first decode token. It is the # load-bearing accuracy improvement of this backend; only disable for # debugging. -ROTORQUANT_DEFER_PREFILL: bool = ( - os.environ.get( - "SKULK_ROTORQUANT_DEFER_PREFILL", - os.environ.get("EXO_ROTORQUANT_DEFER_PREFILL", "1"), - ) - not in ("0", "false", "False", "") -) +ROTORQUANT_DEFER_PREFILL: bool = os.environ.get( + "SKULK_ROTORQUANT_DEFER_PREFILL", + os.environ.get("EXO_ROTORQUANT_DEFER_PREFILL", "1"), +) not in ("0", "false", "False", "") DEFAULT_TOP_LOGPROBS: int = 5 diff --git a/src/exo/worker/engines/mlx/rotorquant/__init__.py b/src/exo/worker/engines/mlx/rotorquant/__init__.py index 9b81f5f46..608c8c48a 100644 --- a/src/exo/worker/engines/mlx/rotorquant/__init__.py +++ b/src/exo/worker/engines/mlx/rotorquant/__init__.py @@ -1,7 +1,10 @@ -"""RotorQuant KV cache backend (IsoQuant variant). +"""Experimental RotorQuant-named KV cache backend (IsoQuant variant). Pure-MLX port of the IsoQuant 3-bit KV cache compression from johndpope/llama-cpp-turboquant (MIT) and scrya-com/rotorquant (MIT). +This is not the fused RotorQuant+QJL attention implementation described in +the RotorQuant paper; it is a storage/dequant cache used for isolated MLX +experiments. Key properties vs the older TurboQuant native backend: - Block-diagonal 4D quaternion rotations instead of randomized Hadamard diff --git a/src/exo/worker/engines/mlx/rotorquant/cache.py b/src/exo/worker/engines/mlx/rotorquant/cache.py index d80d3d69a..cc4a09d3f 100644 --- a/src/exo/worker/engines/mlx/rotorquant/cache.py +++ b/src/exo/worker/engines/mlx/rotorquant/cache.py @@ -1,16 +1,16 @@ -"""RotorQuant KV cache class with optional deferred prefill. +"""Experimental pure-MLX IsoQuant KV cache with optional deferred prefill. -The deferred-prefill path is the load-bearing accuracy improvement that -the upstream llama.cpp fork only ships on CUDA (``#ifdef GGML_USE_CUDA`` -in ``llama-context.cpp:1690``). On Metal/MLX it has not existed before -this port. +This module intentionally does not implement the fused RotorQuant+QJL +attention path from the RotorQuant paper. It stores compressed K/V indices +and norms, then materializes dequantized fp16 K/V for normal MLX attention. +That makes it useful for isolated cache experiments, but not a production +substitute for the upstream fused llama.cpp path. The idea: while the engine is processing a prompt (``update_and_fetch`` called with ``num_steps > 1``), keep K and V in fp16. Quantization only happens once, on the first decode-shaped call (``num_steps == 1``). -This avoids compounding centroid-roundtrip errors through every -prefill attention chain and matches the published 5.3× prefill / PPL -6.91 numbers from the llama.cpp fork. +This avoids compounding centroid-roundtrip errors through every prefill +attention chain. """ from collections.abc import Sequence diff --git a/src/exo/worker/tests/unittests/test_mlx/test_kv_prefix_cache.py b/src/exo/worker/tests/unittests/test_mlx/test_kv_prefix_cache.py index 5f73a8177..d6738f757 100644 --- a/src/exo/worker/tests/unittests/test_mlx/test_kv_prefix_cache.py +++ b/src/exo/worker/tests/unittests/test_mlx/test_kv_prefix_cache.py @@ -113,6 +113,26 @@ def test_unknown_backend_falls_back_to_default(self): with patch.dict(os.environ, {"SKULK_KV_CACHE_BACKEND": "mystery"}): assert get_kv_cache_backend() == "default" + def test_rotorquant_backend_requires_experimental_gate(self): + with patch.dict( + os.environ, + { + "SKULK_KV_CACHE_BACKEND": "rotorquant_adaptive", + "SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT": "", + }, + ): + assert get_kv_cache_backend() == "default" + + def test_rotorquant_backend_can_be_explicitly_enabled(self): + with patch.dict( + os.environ, + { + "SKULK_KV_CACHE_BACKEND": "rotorquant", + "SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT": "1", + }, + ): + assert get_kv_cache_backend() == "rotorquant" + def test_make_kv_cache_default_backend(self): model = cast(Model, type("FakeModel", (), {"layers": [object(), object()]})()) with patch.dict(os.environ, {"SKULK_KV_CACHE_BACKEND": "default"}): diff --git a/website/docs/everything-different.md b/website/docs/everything-different.md index fbf0fdb35..adab01a3a 100644 --- a/website/docs/everything-different.md +++ b/website/docs/everything-different.md @@ -36,7 +36,7 @@ If you are about to land a feature or fix that adds, removes, or materially chan - **OpenAI Responses API** — `/v1/responses` adapter alongside chat completions. - **Embeddings endpoint** for non-chat workloads. - **Model store endpoints** — search, add, download, capability resolution, optimization jobs, and registry management, all exposed under stable URLs and documented in the OpenAPI spec. -- **Cluster-wide config endpoints** — `GET`/`POST` config that gossipsubs to every node and writes back to `skulk.yaml`. The KV cache backend selection (including the new `rotorquant` and `rotorquant_adaptive` values) goes through this same endpoint, so the dashboard Settings panel can switch backends cluster-wide without env vars. +- **Cluster-wide config endpoints** — `GET`/`POST` config that gossipsubs to every node and writes back to `skulk.yaml`. Stable KV cache backend selection goes through this same endpoint, so the dashboard Settings panel can switch supported backends cluster-wide without env vars. Experimental `rotorquant` values remain env-gated and fall back to `default` unless explicitly enabled. - **Tracing, downloads, instance previews, and placement endpoints** — distributed-system observability and pre-launch placement inspection that upstream does not expose. ## Dashboard diff --git a/website/docs/kv-cache-backends.md b/website/docs/kv-cache-backends.md index abb059d4e..7956666ff 100644 --- a/website/docs/kv-cache-backends.md +++ b/website/docs/kv-cache-backends.md @@ -15,27 +15,26 @@ Skulk includes several opt-in KV cache backends for MLX text generation. These b - `turboquant`: correctness-first TurboQuant-inspired KV cache for standard `KVCache` layers - `turboquant_adaptive`: keeps outer KV layers in FP16 and applies TurboQuant to middle KV layers - `optiq`: rotation-based KV cache via [mlx-optiq](https://mlx-optiq.pages.dev/) — uses randomized orthogonal rotations with Lloyd-Max quantization and rotated-space attention. Falls back to default for GQA models. -- `rotorquant`: **[NEW]** pure-MLX port of IsoQuant 3-bit from [scrya-com/rotorquant](https://github.com/scrya-com/rotorquant) and [johndpope/llama-cpp-turboquant](https://github.com/johndpope/llama-cpp-turboquant). Block-diagonal quaternion rotations + deferred prefill. GQA-native. -- `rotorquant_adaptive`: **[NEW]** as above with FP16 protection on the first/last N attention layers. Recommended starting point. +- `rotorquant`: **experimental, gated** pure-MLX IsoQuant-style storage/dequant cache. This is not the fused RotorQuant+QJL implementation from the RotorQuant paper. +- `rotorquant_adaptive`: **experimental, gated** as above with FP16 protection on the first/last N attention layers. If `SKULK_KV_CACHE_BACKEND` is unset, or is set to `default`, Skulk behaves as before. ## Recommended Settings -### RotorQuant Adaptive (recommended starting point) +### Default Baseline (recommended while debugging) ```bash -SKULK_KV_CACHE_BACKEND=rotorquant_adaptive \ -SKULK_ROTORQUANT_FP16_LAYERS=4 \ -uv run skulk +SKULK_KV_CACHE_BACKEND=default \ +SKULK_MLX_HANG_DEBUG=1 \ +SKULK_MLX_HANG_DEBUG_INTERVAL_SECONDS=5 \ +uv run skulk -vv ``` -The rotorquant backend implements IsoQuant 3-bit compression with two key properties: - -- **Block-diagonal quaternion rotations**: each 4D group of a head dimension is rotated by a fixed unit quaternion, costing `O(d)` per token instead of the `O(d log d)` randomized Hadamard used by the legacy turboquant backend or the `O(d²)` random orthogonal matrix used by mlx-optiq. The quaternion table and 3-bit Lloyd-Max centroids are vendored verbatim from the llama.cpp fork so the math agrees with the upstream C reference. -- **Deferred prefill**: K and V are kept in fp16 throughout prompt processing and quantized once on the first decode token. This eliminates the compounding centroid-roundtrip error that quantizing-on-insert introduces during prefill, and matches the published 5.3× prefill / PPL 6.91 numbers from the upstream llama.cpp fork. The deferred-prefill flush is the unique contribution of this MLX port — the upstream fork only ships it on CUDA. - -GQA models work natively (no fallback) because compression is per-(kv_head, token) and Q heads fan out at SDPA. The head dimension must be a multiple of 128 (the IsoQuant block size); for example, 128- and 256-d heads work directly, while 64-d heads do not satisfy this requirement and will fall back to the default backend. +Use the default backend first when validating distributed pipeline behavior. It +removes KV-cache compression from the failure surface so hangs can be attributed +to pipeline scheduling, task agreement, or the model path rather than an +experimental cache implementation. ### mlx-optiq (best quality) @@ -60,6 +59,23 @@ uv run skulk This mode keeps the first and last 4 KV layers in normal FP16-style cache and applies TurboQuant only to the middle KV layers. Proven stable across most models. +### RotorQuant / IsoQuant (experimental only) + +The `rotorquant` names are intentionally gated behind an explicit opt-in: + +```bash +SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT=1 \ +SKULK_KV_CACHE_BACKEND=rotorquant_adaptive \ +SKULK_ROTORQUANT_FP16_LAYERS=4 \ +uv run skulk +``` + +This backend is a pure-MLX IsoQuant-style cache that compresses storage and +then returns fully dequantized fp16 K/V to normal MLX attention. It does not +implement RotorQuant's fused Metal/CUDA attention path or QJL residual +correction, so it should not be used as the default distributed inference +baseline. + ## Available Environment Variables | Variable | Backends | Default | Description | @@ -71,8 +87,9 @@ This mode keeps the first and last 4 KV layers in normal FP16-style cache and ap | `SKULK_TQ_K_BITS` | `turboquant`, `turboquant_adaptive` | `3` | Key quantization bits | | `SKULK_TQ_V_BITS` | `turboquant`, `turboquant_adaptive` | `4` | Value quantization bits | | `SKULK_TQ_FP16_LAYERS` | `turboquant_adaptive` | `4` | Edge layers kept in FP16 | +| `SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT` | `rotorquant`, `rotorquant_adaptive` | `0` | Required opt-in for experimental RotorQuant/IsoQuant backends | | `SKULK_ROTORQUANT_FP16_LAYERS` | `rotorquant_adaptive` | `4` | Edge layers kept in FP16 | -| `SKULK_ROTORQUANT_DEFER_PREFILL` | `rotorquant`, `rotorquant_adaptive` | `1` | Set to `0` to disable deferred prefill (debugging only) | +| `SKULK_ROTORQUANT_DEFER_PREFILL` | `rotorquant`, `rotorquant_adaptive` | `1` | Set to `0` to disable deferred prefill while debugging | ## Invocation Examples @@ -105,16 +122,16 @@ SKULK_KV_CACHE_BACKEND=turboquant_adaptive SKULK_TQ_K_BITS=3 SKULK_TQ_V_BITS=4 S | Backend | Memory | Quality | Speed | Notes | |---------|--------|---------|-------|-------| | `default` | Highest | Baseline | Fastest | No quantization | -| `rotorquant_adaptive` | Low | Best quantized | Near-baseline | IsoQuant 3-bit + deferred prefill, GQA-native | -| `rotorquant` | Lowest | Good | Near-baseline | All layers IsoQuant 3-bit + deferred prefill | | `optiq` | Low | Good | Near-baseline | Rotation-based, no GQA support | | `turboquant_adaptive` | Low | Good | Moderate | Proven stable, Hadamard-based | | `turboquant` | Low | Variable | Moderate | All layers, Hadamard-based | | `mlx_quantized` | Low | Good | Moderate | MLX built-in quantization | +| `rotorquant_adaptive` | Low | Experimental | Experimental | Gated pure-MLX IsoQuant storage/dequant cache | +| `rotorquant` | Lowest | Experimental | Experimental | Gated pure-MLX IsoQuant storage/dequant cache | ## Supported Cache Layouts -All quantized backends (rotorquant, optiq, turboquant, mlx_quantized) compress only standard `KVCache` entries and preserve these cache types unchanged: +All quantized backends (optiq, turboquant, mlx_quantized, and the gated rotorquant variants) compress only standard `KVCache` entries and preserve these cache types unchanged: - `ArraysCache` - `RotatingKVCache` @@ -130,6 +147,7 @@ Mixed cache layouts are supported: - All quantized KV cache backends force sequential generation (no batch/history mode) - The optiq backend requires `mlx-optiq` to be installed (`pip install mlx-optiq`) - The optiq backend's `patch_attention()` monkey-patches MLX's SDPA — avoid switching between optiq and other backends within the same process lifetime without a restart +- The rotorquant backends are disabled unless `SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT=1` is set. If selected without the gate, Skulk falls back to `default`. ## About mlx-optiq From d612d1477575a6d5861517d0a9d16348861915c0 Mon Sep 17 00:00:00 2001 From: Thomas Tupper Date: Fri, 10 Apr 2026 01:44:44 -0500 Subject: [PATCH 19/21] Restore Gemma 4 live prompt boundary --- src/exo/worker/engines/mlx/gemma4_prompt.py | 58 ++++++++++++------- .../worker/engines/mlx/generator/generate.py | 1 + src/exo/worker/engines/mlx/utils_mlx.py | 2 + .../unittests/test_mlx/test_gemma4_prompt.py | 15 ++++- .../unittests/test_mlx/test_warmup_request.py | 52 +++++++++++++---- 5 files changed, 95 insertions(+), 33 deletions(-) diff --git a/src/exo/worker/engines/mlx/gemma4_prompt.py b/src/exo/worker/engines/mlx/gemma4_prompt.py index 1cf44ce41..20e4270d3 100644 --- a/src/exo/worker/engines/mlx/gemma4_prompt.py +++ b/src/exo/worker/engines/mlx/gemma4_prompt.py @@ -2,13 +2,18 @@ This module follows the Gemma 4 chat structure used by the reference Hugging Face template and Ollama's dedicated Gemma 4 renderer, while preserving one -intentional divergence: when thinking is disabled we omit the empty synthetic -thought-channel suffix because that shape wedges our distributed MLX warmup -path. We keep a dedicated renderer so multimodal prompts stay under explicit -repo control instead of relying on generic tokenizer chat templating. +intentional warmup-only divergence: distributed warmup can suppress the empty +synthetic thought-channel suffix because that shape has wedged our distributed +MLX warmup path. Real inference keeps the reference suffix so generation begins +at the expected visible assistant-content boundary. We keep a dedicated renderer +so multimodal prompts stay under explicit repo control instead of relying on +generic tokenizer chat templating. """ -from typing import Any +from collections.abc import Mapping, Sequence +from typing import TypeAlias, cast + +Gemma4Message: TypeAlias = Mapping[str, object] def strip_gemma4_thinking(text: str) -> str: @@ -28,7 +33,7 @@ def strip_gemma4_thinking(text: str) -> str: return "".join(result).strip() -def _render_gemma4_content(content: Any, role: str) -> str: +def _render_gemma4_content(content: object, role: str) -> str: """Render one Gemma 4 message body.""" if isinstance(content, str): return strip_gemma4_thinking(content) if role == "model" else content.strip() @@ -36,14 +41,18 @@ def _render_gemma4_content(content: Any, role: str) -> str: if not isinstance(content, list): return str(content).strip() + content_items = cast(Sequence[object], content) parts: list[str] = [] - for item in content: - if not isinstance(item, dict): + for item in content_items: + if not isinstance(item, Mapping): continue - item_type = str(item.get("type", "")) + item_mapping = cast(Mapping[str, object], item) + item_type = str(item_mapping.get("type", "")) if item_type == "text": - text = str(item.get("text", "")) - parts.append(strip_gemma4_thinking(text) if role == "model" else text.strip()) + text = str(item_mapping.get("text", "")) + parts.append( + strip_gemma4_thinking(text) if role == "model" else text.strip() + ) elif item_type == "image": parts.append("\n\n<|image|>\n\n") elif item_type == "audio": @@ -54,10 +63,11 @@ def _render_gemma4_content(content: Any, role: str) -> str: def render_gemma4_prompt( - messages: list[dict[str, Any]], + messages: Sequence[Gemma4Message], *, add_generation_prompt: bool, enable_thinking: bool | None = None, + suppress_empty_thought_channel: bool = False, ) -> str: """Render a Gemma 4 prompt matching the reference chat template. @@ -78,24 +88,32 @@ def render_gemma4_prompt( if enable_thinking: prompt_parts.append("<|think|>") if has_system_message: - prompt_parts.append(_render_gemma4_content(messages[0].get("content", ""), "system")) + prompt_parts.append( + _render_gemma4_content(messages[0].get("content", ""), "system") + ) loop_messages = messages[1:] prompt_parts.append("\n") for message in loop_messages: - role = "model" if str(message.get("role", "user")) == "assistant" else str( - message.get("role", "user") + role = ( + "model" + if str(message.get("role", "user")) == "assistant" + else str(message.get("role", "user")) ) prompt_parts.append(f"<|turn>{role}\n") prompt_parts.append(_render_gemma4_content(message.get("content", ""), role)) prompt_parts.append("\n") if add_generation_prompt: - # Gemma's published chat template appends an empty thought channel even - # when thinking is disabled. We intentionally omit that suffix here - # because it appears to trigger a pipeline warmup wedge in our MLX - # distributed path, and real traffic succeeds without this synthetic - # prefix. prompt_parts.append("<|turn>model\n") + if not enable_thinking and not suppress_empty_thought_channel: + # Gemma 4 expects the assistant turn to pass through the channel + # marker state. For normal inference we match the official + # template by adding an empty thought channel so generation begins + # in visible assistant content instead of at a raw ``<|channel>`` + # boundary. Distributed warmup can suppress this suffix because it + # only validates prefill plus one decode step and historically + # wedged on richer synthetic prompt shapes. + prompt_parts.append("<|channel>thought\n") return "".join(prompt_parts) diff --git a/src/exo/worker/engines/mlx/generator/generate.py b/src/exo/worker/engines/mlx/generator/generate.py index f9d600e37..5f38acd9d 100644 --- a/src/exo/worker/engines/mlx/generator/generate.py +++ b/src/exo/worker/engines/mlx/generator/generate.py @@ -687,6 +687,7 @@ def warmup_inference( tokenizer=tokenizer, task_params=warmup_task_params, model_card=model_card, + suppress_empty_gemma4_thought_channel=True, ) logger.info( "Warmup prompt prepared " diff --git a/src/exo/worker/engines/mlx/utils_mlx.py b/src/exo/worker/engines/mlx/utils_mlx.py index 7e135b4f2..06fc8399b 100644 --- a/src/exo/worker/engines/mlx/utils_mlx.py +++ b/src/exo/worker/engines/mlx/utils_mlx.py @@ -841,6 +841,7 @@ def apply_chat_template( tokenizer: TokenizerWrapper, task_params: TextGenerationTaskParams, model_card: ModelCard | None = None, + suppress_empty_gemma4_thought_channel: bool = False, ) -> str: """Convert TextGenerationTaskParams to a chat template prompt. @@ -902,6 +903,7 @@ def apply_chat_template( formatted_messages, add_generation_prompt=True, enable_thinking=task_params.enable_thinking, + suppress_empty_thought_channel=suppress_empty_gemma4_thought_channel, ) if partial_assistant_content: prompt += partial_assistant_content diff --git a/src/exo/worker/tests/unittests/test_mlx/test_gemma4_prompt.py b/src/exo/worker/tests/unittests/test_mlx/test_gemma4_prompt.py index 6bb0331c5..bdc9a97fb 100644 --- a/src/exo/worker/tests/unittests/test_mlx/test_gemma4_prompt.py +++ b/src/exo/worker/tests/unittests/test_mlx/test_gemma4_prompt.py @@ -1,7 +1,7 @@ from exo.worker.engines.mlx.gemma4_prompt import render_gemma4_prompt -def test_render_gemma4_prompt_does_not_append_empty_thought_channel_when_thinking_disabled(): +def test_render_gemma4_prompt_appends_empty_thought_channel_when_thinking_disabled(): prompt = render_gemma4_prompt( [ {"role": "system", "content": "You are helpful."}, @@ -20,7 +20,20 @@ def test_render_gemma4_prompt_does_not_append_empty_thought_channel_when_thinkin "Hello" "\n" "<|turn>model\n" + "<|channel>thought\n" + "" ) + + +def test_render_gemma4_prompt_can_suppress_empty_thought_channel_for_warmup(): + prompt = render_gemma4_prompt( + [{"role": "user", "content": "Hello"}], + add_generation_prompt=True, + enable_thinking=False, + suppress_empty_thought_channel=True, + ) + + assert prompt.endswith("<|turn>model\n") assert "<|channel>thought" not in prompt assert "" not in prompt diff --git a/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py b/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py index 31c2e1653..3f7db19f2 100644 --- a/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py +++ b/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py @@ -40,10 +40,17 @@ def test_warmup_inference_uses_safe_default_user_content_without_instructions( captured: dict[str, object] = {} def fake_apply_chat_template( - *, tokenizer: object, task_params: object, model_card: object + *, + tokenizer: object, + task_params: object, + model_card: object, + suppress_empty_gemma4_thought_channel: bool = False, ): del tokenizer, model_card captured["task_params"] = task_params + captured["suppress_empty_gemma4_thought_channel"] = ( + suppress_empty_gemma4_thought_channel + ) return "warmup prompt" def fake_mx_barrier(_group: object) -> None: @@ -95,6 +102,7 @@ def fake_log_request_shape( assert task_params.top_p == 1.0 assert task_params.top_k == 0 assert task_params.max_output_tokens == 1024 + assert captured["suppress_empty_gemma4_thought_channel"] is True first_message = task_params.input[0] assert first_message.content == "hello" assert captured["label"] == "warmup" @@ -112,9 +120,13 @@ def test_warmup_inference_ignores_repeat_count_override_for_pipeline_groups( captured: dict[str, object] = {} def fake_apply_chat_template( - *, tokenizer: object, task_params: object, model_card: object + *, + tokenizer: object, + task_params: object, + model_card: object, + suppress_empty_gemma4_thought_channel: bool = False, ): - del tokenizer, model_card + del tokenizer, model_card, suppress_empty_gemma4_thought_channel captured["task_params"] = task_params return "warmup prompt" @@ -155,9 +167,13 @@ def test_warmup_inference_ignores_instruction_override_for_pipeline_groups( captured: dict[str, object] = {} def fake_apply_chat_template( - *, tokenizer: object, task_params: object, model_card: object + *, + tokenizer: object, + task_params: object, + model_card: object, + suppress_empty_gemma4_thought_channel: bool = False, ): - del tokenizer, model_card + del tokenizer, model_card, suppress_empty_gemma4_thought_channel captured["task_params"] = task_params return "warmup prompt" @@ -198,9 +214,13 @@ def test_warmup_inference_honors_repeat_and_instruction_overrides_for_single_nod captured: dict[str, object] = {} def fake_apply_chat_template( - *, tokenizer: object, task_params: object, model_card: object + *, + tokenizer: object, + task_params: object, + model_card: object, + suppress_empty_gemma4_thought_channel: bool = False, ): - del tokenizer, model_card + del tokenizer, model_card, suppress_empty_gemma4_thought_channel captured["task_params"] = task_params return "warmup prompt" @@ -246,9 +266,13 @@ def test_warmup_inference_stops_after_first_generated_token( generated_tokens = 0 def fake_apply_chat_template( - *, tokenizer: object, task_params: object, model_card: object + *, + tokenizer: object, + task_params: object, + model_card: object, + suppress_empty_gemma4_thought_channel: bool = False, ): - del tokenizer, task_params, model_card + del tokenizer, task_params, model_card, suppress_empty_gemma4_thought_channel return "warmup prompt" def fake_mx_barrier(_group: object) -> None: @@ -292,7 +316,7 @@ def test_warmup_helpers_prefer_blank_skulk_values_over_legacy_env( monkeypatch.setenv("EXO_DEBUG_WARMUP_INCLUDE_INSTRUCTIONS", "1") assert generate_mod._warmup_repeat_count() == 1 # pyright: ignore[reportPrivateUsage] - assert generate_mod._warmup_instructions(cast(object, _SingleNodeGroup())) is None # pyright: ignore[reportPrivateUsage] + assert generate_mod._warmup_instructions(cast(object, _SingleNodeGroup())) is None # pyright: ignore[reportPrivateUsage, reportArgumentType] def test_warmup_inference_enforces_minimum_cancel_check_interval_on_slow_start( @@ -301,9 +325,13 @@ def test_warmup_inference_enforces_minimum_cancel_check_interval_on_slow_start( monotonic_values = iter([100.0, 112.0]) def fake_apply_chat_template( - *, tokenizer: object, task_params: object, model_card: object + *, + tokenizer: object, + task_params: object, + model_card: object, + suppress_empty_gemma4_thought_channel: bool = False, ): - del tokenizer, task_params, model_card + del tokenizer, task_params, model_card, suppress_empty_gemma4_thought_channel return "warmup prompt" def fake_mx_barrier(_group: object) -> None: From a50a53785e9dca39d675178146f69be1fad944c5 Mon Sep 17 00:00:00 2001 From: Thomas Tupper Date: Fri, 10 Apr 2026 01:52:22 -0500 Subject: [PATCH 20/21] Let warmup finish one-token generation --- src/exo/worker/engines/mlx/generator/generate.py | 9 ++++----- .../unittests/test_mlx/test_warmup_request.py | 15 +++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/exo/worker/engines/mlx/generator/generate.py b/src/exo/worker/engines/mlx/generator/generate.py index 5f38acd9d..ea1930d4c 100644 --- a/src/exo/worker/engines/mlx/generator/generate.py +++ b/src/exo/worker/engines/mlx/generator/generate.py @@ -675,7 +675,10 @@ def warmup_inference( content=_warmup_user_content(group), ) ], - max_output_tokens=1024, + # Warmup is a prefill + one-token decode sanity check. Asking the + # generator for exactly one token lets it take its normal terminal + # path instead of aborting a larger generation mid-stream. + max_output_tokens=1, enable_thinking=False, temperature=0.0 if is_distributed else 1.0, top_p=1.0 if is_distributed else 0.95, @@ -722,10 +725,6 @@ def warmup_inference( group=group, ): tokens_generated += 1 - # Warmup is only meant to validate prefill plus the first decode - # step. Stopping after the first token keeps runner readiness - # bounded even if the model would otherwise continue sampling. - break # The single-token warmup path intentionally samples only the first decode # step, so cold-start compile/prefill latency can dominate the elapsed diff --git a/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py b/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py index 3f7db19f2..9ae003ac7 100644 --- a/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py +++ b/src/exo/worker/tests/unittests/test_mlx/test_warmup_request.py @@ -101,7 +101,7 @@ def fake_log_request_shape( assert task_params.temperature == 0.0 assert task_params.top_p == 1.0 assert task_params.top_k == 0 - assert task_params.max_output_tokens == 1024 + assert task_params.max_output_tokens == 1 assert captured["suppress_empty_gemma4_thought_channel"] is True first_message = task_params.input[0] assert first_message.content == "hello" @@ -260,10 +260,11 @@ def fake_all_sum(array: object, *, group: object, stream: object = None): ) -def test_warmup_inference_stops_after_first_generated_token( +def test_warmup_inference_requests_one_token_and_finishes_normally( monkeypatch: pytest.MonkeyPatch, ) -> None: generated_tokens = 0 + captured: dict[str, object] = {} def fake_apply_chat_template( *, @@ -278,11 +279,12 @@ def fake_apply_chat_template( def fake_mx_barrier(_group: object) -> None: return None - def fake_mlx_generate(**_kwargs: object): + def fake_mlx_generate(**kwargs: object): nonlocal generated_tokens - for token in ("first", "second", "third"): - generated_tokens += 1 - yield token + task = cast(TextGenerationTaskParams, kwargs["task"]) + captured["max_output_tokens"] = task.max_output_tokens + generated_tokens += 1 + yield "first" def fake_all_sum(array: object, *, group: object, stream: object = None): del group, stream @@ -302,6 +304,7 @@ def fake_all_sum(array: object, *, group: object, stream: object = None): model_card=None, ) + assert captured["max_output_tokens"] == 1 assert generated_tokens == 1 assert check_every == 100 From 856b30f7a8599f9044092fe716ff0ca8bff48913 Mon Sep 17 00:00:00 2001 From: Thomas Tupper Date: Fri, 10 Apr 2026 02:30:51 -0500 Subject: [PATCH 21/21] Force sequential generation for Gemma 4 --- README.md | 2 +- docs/kv-cache-backends.md | 1 + .../runner/llm_inference/batch_generator.py | 1 + src/exo/worker/runner/llm_inference/runner.py | 54 +++++++++++++++++-- .../test_turboquant_batch_guard.py | 53 +++++++++++++++++- website/docs/kv-cache-backends.md | 1 + 6 files changed, 106 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d0d484ec0..3b5c9ceb3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ This is the running list of where Skulk diverges from upstream [exo](https://git - **OptiQ mixed-precision weight quantization pipeline** — async wrapper around `mlx-optiq`'s sensitivity analysis and KL-divergence per-layer bit allocation, exposed as a model-store optimization job. - **KV prefix cache with snapshot/restore** — LRU-evicted prompt-prefix cache that snapshots SSM and rotating-window cache states so prefix matches are reusable across conversation turns even for hybrid Mamba/Transformer architectures. - **Pipeline-parallel prefill for short prompts** — pipelined models now route every prefill through the pipeline path, fixing prior warmup hangs on Gemma-class models. -- **Force-sequential fallback** — quantized backends transparently fall back to a sequential generator when batch/history mode is incompatible with their cache layout. +- **Force-sequential fallback** — quantized backends and Gemma 4 transparently fall back to a sequential generator when batch/history mode is incompatible with their cache layout. ### Model capability system diff --git a/docs/kv-cache-backends.md b/docs/kv-cache-backends.md index af7356af1..d2fc3520d 100644 --- a/docs/kv-cache-backends.md +++ b/docs/kv-cache-backends.md @@ -127,6 +127,7 @@ Mixed cache layouts are supported: ## Current Limitations - All quantized KV cache backends force sequential generation (no batch/history mode) +- Gemma 4 text generation also forces sequential generation for now because distributed BatchGenerator mode can produce degenerate repetition with its sliding-window cache layout - The optiq backend requires `mlx-optiq` to be installed (`pip install mlx-optiq`) - The optiq backend's `patch_attention()` monkey-patches MLX's SDPA — avoid switching between optiq and other backends within the same process lifetime without a restart - The rotorquant backends are disabled unless `SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT=1` is set. If selected without the gate, Skulk falls back to `default`. diff --git a/src/exo/worker/runner/llm_inference/batch_generator.py b/src/exo/worker/runner/llm_inference/batch_generator.py index 5673ef175..6065f75b2 100644 --- a/src/exo/worker/runner/llm_inference/batch_generator.py +++ b/src/exo/worker/runner/llm_inference/batch_generator.py @@ -62,6 +62,7 @@ def gen(self) -> Generator[T | None]: class InferenceGenerator(ABC): + group: mx.distributed.Group | None _cancelled_tasks: set[TaskId] def should_cancel(self, task_id: TaskId) -> bool: diff --git a/src/exo/worker/runner/llm_inference/runner.py b/src/exo/worker/runner/llm_inference/runner.py index 2e7822fcb..080cb0338 100644 --- a/src/exo/worker/runner/llm_inference/runner.py +++ b/src/exo/worker/runner/llm_inference/runner.py @@ -7,7 +7,13 @@ from anyio import WouldBlock from mlx_lm.tokenizer_utils import TokenizerWrapper -from exo.shared.models.model_cards import ModelCard, ModelTask +from exo.shared.models.model_cards import ( + ModelCard, + ModelTask, + OutputParserType, + PromptRendererType, + ToolCallFormat, +) from exo.shared.types.chunks import ( ErrorChunk, TokenChunk, @@ -91,6 +97,28 @@ def _should_skip_llm_warmup(group_size: int) -> bool: return True +def _is_gemma4_model(model_id: ModelId, model_card: ModelCard | None) -> bool: + """Return whether a model should be treated as a Gemma 4 runtime.""" + normalized_model_id = str(model_id).lower().replace("_", "-") + if "gemma-4" in normalized_model_id or "gemma4" in normalized_model_id: + return True + if model_card is None: + return False + + if model_card.vision is not None and model_card.vision.model_type == "gemma4": + return True + + runtime = model_card.runtime + if runtime is not None and ( + runtime.prompt_renderer == PromptRendererType.Gemma4 + or runtime.output_parser == OutputParserType.Gemma4 + ): + return True + + tooling = model_card.tooling + return tooling is not None and tooling.tool_call_format == ToolCallFormat.Gemma4 + + class ExitCode(str, Enum): AllTasksComplete = "AllTasksComplete" Shutdown = "Shutdown" @@ -484,7 +512,7 @@ def build( kv_backend = get_kv_cache_backend() # TODO: Remove this forced sequential fallback once quantized KV cache # backends support BatchGenerator history merge/extract semantics. - force_sequential = kv_backend in ( + force_sequential_for_kv_backend = kv_backend in ( "mlx_quantized", "turboquant", "turboquant_adaptive", @@ -492,14 +520,32 @@ def build( "rotorquant", "rotorquant_adaptive", ) - if os.environ.get("EXO_NO_BATCH") or force_sequential: - if force_sequential and not os.environ.get("EXO_NO_BATCH"): + # Gemma 4 uses sliding-window RotatingKVCache entries. In distributed + # pipeline mode, the MLX BatchGenerator path currently produces + # degenerate token repetition after a valid prefill, while the + # sequential path is stable with the same prompt and default KV cache. + force_sequential_for_gemma4 = _is_gemma4_model(self.model_id, self.model_card) + no_batch_requested = os.environ.get("SKULK_NO_BATCH") or os.environ.get( + "EXO_NO_BATCH" + ) + if ( + no_batch_requested + or force_sequential_for_kv_backend + or force_sequential_for_gemma4 + ): + if force_sequential_for_kv_backend and not no_batch_requested: logger.warning( "Quantized KV cache backend does not yet support " "batch/history mode; forcing SequentialGenerator " f"(kv_backend={kv_backend})" ) logger.info(f"using SequentialGenerator (kv_backend={kv_backend})") + elif force_sequential_for_gemma4 and not no_batch_requested: + logger.warning( + "Gemma 4 is not compatible with distributed BatchGenerator " + "mode yet; forcing SequentialGenerator" + ) + logger.info("using SequentialGenerator (model_family=gemma4)") else: logger.info("using SequentialGenerator (batching disabled)") return SequentialGenerator( diff --git a/src/exo/worker/tests/unittests/test_runner/test_turboquant_batch_guard.py b/src/exo/worker/tests/unittests/test_runner/test_turboquant_batch_guard.py index a21cfa01f..cd470959a 100644 --- a/src/exo/worker/tests/unittests/test_runner/test_turboquant_batch_guard.py +++ b/src/exo/worker/tests/unittests/test_runner/test_turboquant_batch_guard.py @@ -4,8 +4,15 @@ import pytest from mlx_lm.tokenizer_utils import TokenizerWrapper +from exo.shared.models.model_cards import ( + ModelCard, + ModelTask, + PromptRendererType, + RuntimeCapabilityCardConfig, +) from exo.shared.types.common import ModelId from exo.shared.types.events import Event +from exo.shared.types.memory import Memory from exo.shared.types.mlx import Model from exo.shared.types.tasks import TaskId from exo.utils.channels import mp_channel @@ -29,7 +36,14 @@ class _FakeModel: @pytest.mark.parametrize( "kv_backend", - ["mlx_quantized", "turboquant", "turboquant_adaptive"], + [ + "mlx_quantized", + "turboquant", + "turboquant_adaptive", + "optiq", + "rotorquant", + "rotorquant_adaptive", + ], ) def test_builder_forces_sequential_for_quantized_kv_backends( monkeypatch: pytest.MonkeyPatch, @@ -56,3 +70,40 @@ def test_builder_forces_sequential_for_quantized_kv_backends( assert isinstance(generator, SequentialGenerator) assert not isinstance(generator, BatchGenerator) + + +def test_builder_forces_sequential_for_gemma4_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, cancel_recv = mp_channel[TaskId]() + event_send, _ = mp_channel[Event]() + + monkeypatch.setattr( + "exo.worker.runner.llm_inference.runner.get_kv_cache_backend", + lambda: "default", + ) + + builder = Builder( + model_id=ModelId("local/custom-gemma-runtime"), + event_sender=event_send, + cancel_receiver=cancel_recv, + inference_model=cast(Model, cast(object, _FakeModel())), + tokenizer=cast(TokenizerWrapper, cast(object, _FakeTokenizer())), + group=cast(mx.distributed.Group | None, None), + model_card=ModelCard( + model_id=ModelId("local/custom-gemma-runtime"), + storage_size=Memory(), + n_layers=1, + hidden_size=1, + supports_tensor=False, + tasks=[ModelTask.TextGeneration], + runtime=RuntimeCapabilityCardConfig( + prompt_renderer=PromptRendererType.Gemma4 + ), + ), + ) + + generator = builder.build() + + assert isinstance(generator, SequentialGenerator) + assert not isinstance(generator, BatchGenerator) diff --git a/website/docs/kv-cache-backends.md b/website/docs/kv-cache-backends.md index 7956666ff..1636711d9 100644 --- a/website/docs/kv-cache-backends.md +++ b/website/docs/kv-cache-backends.md @@ -145,6 +145,7 @@ Mixed cache layouts are supported: ## Current Limitations - All quantized KV cache backends force sequential generation (no batch/history mode) +- Gemma 4 text generation also forces sequential generation for now because distributed BatchGenerator mode can produce degenerate repetition with its sliding-window cache layout - The optiq backend requires `mlx-optiq` to be installed (`pip install mlx-optiq`) - The optiq backend's `patch_attention()` monkey-patches MLX's SDPA — avoid switching between optiq and other backends within the same process lifetime without a restart - The rotorquant backends are disabled unless `SKULK_ENABLE_EXPERIMENTAL_ROTORQUANT=1` is set. If selected without the gate, Skulk falls back to `default`.