From 5e031ee552528ce60aeec0a087bf93266756a811 Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 08:30:56 -0500
Subject: [PATCH 01/11] Support Apple M6 and 32 GiB mixed-precision inference
Add the M6 GPU profile and automatic mixed INT3/INT4 selection below 48 GiB with a 128K context default. Upgrade the bundled runtime to Python 3.14.10, pinned MLX HEAD, and nanobind 3.1.0, adapting thread-local random-state restoration and CI Metal compiler setup.
Keep native-reference parity failures visible while exercising the remaining decoder checks.
---
.github/workflows/release.yml | 10 +-
.github/workflows/test.yml | 5 +
Makefile | 10 +-
README.md | 14 ++-
cli/engine.go | 4 +-
cli/manifest.go | 24 +++++
cli/memory_test.go | 65 +++++++++++++
cli/platform.go | 14 ++-
cli/rec_test.go | 2 +-
engine/hatch_build.py | 5 +-
engine/pyproject.toml | 13 ++-
engine/src/rec_mlx/checkpoint.py | 53 +++++++++--
engine/src/rec_mlx/engine.py | 4 +-
engine/src/rec_mlx/kernels/device.py | 4 +-
engine/src/rec_mlx/kernels/masked_sampling.py | 4 +-
engine/src/rec_mlx/kernels/megakernel.py | 1 -
engine/src/rec_mlx/random_state.py | 12 +++
engine/tests/test_checkpoint.py | 92 +++++++++++++++++++
engine/tests/test_device.py | 29 ++++++
engine/tests/test_kernel_routing.py | 2 +-
engine/tests/test_random_state.py | 30 ++++++
engine/tests/validate_resident.py | 59 ++++++++++--
packaging/build-mlx-runtime.py | 20 ++--
packaging/flint-mixed-3-4bit-mlx.json | 82 +++++++++++++++++
packaging/package.py | 21 ++++-
tests/build_smoke_runtime.py | 2 +-
tests/package_test.py | 14 +++
27 files changed, 536 insertions(+), 59 deletions(-)
create mode 100644 cli/memory_test.go
create mode 100644 engine/src/rec_mlx/random_state.py
create mode 100644 engine/tests/test_checkpoint.py
create mode 100644 engine/tests/test_device.py
create mode 100644 engine/tests/test_random_state.py
create mode 100644 packaging/flint-mixed-3-4bit-mlx.json
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7c30da3..de7cf11 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,10 +17,14 @@ jobs:
- name: Build and package
run: |
version=${GITHUB_REF_NAME#v}
- uv python install 3.12.12
- python_root="$(uv python dir)/cpython-3.12.12-macos-aarch64-none"
- "$python_root/bin/python3.12" packaging/build-mlx-runtime.py --python-root "$python_root" --output .build/bundle-mlx
+ if ! xcrun -sdk macosx metal --version > /dev/null 2>&1; then
+ xcodebuild -downloadComponent MetalToolchain
+ fi
+ uv python install 3.14.10
+ python_root="$(uv python dir)/cpython-3.14.10-macos-aarch64-none"
+ "$python_root/bin/python3.14" packaging/build-mlx-runtime.py --python-root "$python_root" --output .build/bundle-mlx
python3 packaging/package.py --runtime-dir .build/bundle-mlx --manifest packaging/flint-int4-mlx.json \
+ --low-memory-manifest packaging/flint-mixed-3-4bit-mlx.json \
--version "$version" --output dist/rec-darwin-arm64
test "$(dist/rec-darwin-arm64 --version)" = "rec $version"
- name: Publish
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5027de7..3614b85 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -41,4 +41,9 @@ jobs:
with:
node-version: '22'
- *cache-clients
+ - name: Ensure Metal compiler
+ run: |
+ if ! xcrun -sdk macosx metal --version > /dev/null 2>&1; then
+ xcodebuild -downloadComponent MetalToolchain
+ fi
- run: make test-engine
diff --git a/Makefile b/Makefile
index 5a88593..fb878d1 100644
--- a/Makefile
+++ b/Makefile
@@ -20,16 +20,16 @@ test-cli:
python3 tests/installer_test.py
python3 tests/package_test.py
-PYTHON_ROOT = $(shell uv python dir)/cpython-3.12.12-macos-aarch64-none
+PYTHON_ROOT = $(shell uv python dir)/cpython-3.14.10-macos-aarch64-none
test-engine:
- cd engine && uv venv --allow-existing --python 3.12 && uv pip install '.[validation]'
+ cd engine && uv venv --allow-existing --python 3.14.10 && uv pip install '.[validation]'
cd engine && .venv/bin/pytest -v -rs
- uv python install 3.12.12
+ uv python install 3.14.10
rm -rf .build/bundle-mlx .build/smoke
- $(PYTHON_ROOT)/bin/python3.12 packaging/build-mlx-runtime.py --python-root $(PYTHON_ROOT) --output .build/bundle-mlx
+ $(PYTHON_ROOT)/bin/python3.14 packaging/build-mlx-runtime.py --python-root $(PYTHON_ROOT) --output .build/bundle-mlx
python3 tests/build_smoke_runtime.py --runtime .build/bundle-mlx --output .build/smoke
python3 packaging/package.py --runtime-dir .build/smoke/runtime --manifest .build/smoke/smoke-mlx.json --output .build/rec-smoke
python3 tests/smoke_test.py .build/rec-smoke
go build -o .build/rec ./cli
- python3 tests/protocol_test.py .build/bundle-mlx/python/bin/python3.12 .build/rec
+ python3 tests/protocol_test.py .build/bundle-mlx/python/bin/python3.14 .build/rec
diff --git a/README.md b/README.md
index 0e08a2a..8d4a202 100644
--- a/README.md
+++ b/README.md
@@ -26,11 +26,16 @@ excluded.
## Requirements
-- Apple Silicon Mac with an M4 Pro, M4 Max, M5 Pro, or M5 Max
-- At least 48 GB unified memory
+- Apple Silicon Mac with an M4 Pro, M4 Max, M5 Pro, M5 Max, or M6
+- At least 32 GB unified memory
- About 30 GB free disk space
- [Node.js 22](https://nodejs.org/en/download) and npm for pi
+On Macs with 32 GB (or less than 48 GB), packaged builds select the community
+[Flint mixed 3/4-bit checkpoint](https://huggingface.co/mlx-community/flint-mixed-3-4bit)
+(17.8 GB), with a 131,072-token default context. Macs with 48 GB or more use the
+official INT4 checkpoint. The mixed checkpoint is an unofficial community conversion.
+Use `rec doctor` to see the selected revision and context.
## Install
@@ -141,3 +146,8 @@ Run `rec COMMAND --help` for a command's options.
- Community guidelines: [Code of conduct](CODE_OF_CONDUCT.md).
- Security vulnerabilities: [Private reporting instructions](SECURITY.md).
- License: [Apache 2.0](LICENSE).
+
+## Runtime build
+
+The bundled runtime uses CPython 3.14.10, nanobind 3.1.0, and MLX built from
+GitHub commit `fc344c986c4169d99f76b5d95ae6b4a58a359a63` (HEAD when upgraded).
diff --git a/cli/engine.go b/cli/engine.go
index aaff247..4a2638b 100644
--- a/cli/engine.go
+++ b/cli/engine.go
@@ -15,10 +15,10 @@ func launchCommand(p Paths, m Manifest, runtimeDir, model string, port int) (str
}
func checkAppleChip(name string) error {
- for _, chip := range []string{"Apple M4 Pro", "Apple M4 Max", "Apple M5 Pro", "Apple M5 Max"} {
+ for _, chip := range []string{"Apple M4 Pro", "Apple M4 Max", "Apple M5 Pro", "Apple M5 Max", "Apple M6"} {
if name == chip {
return nil
}
}
- return fmt.Errorf("Flint requires an Apple M4 Pro, M4 Max, M5 Pro or M5 Max; detected %s", name)
+ return fmt.Errorf("Flint requires an Apple M4 Pro, M4 Max, M5 Pro, M5 Max or M6; detected %s", name)
}
diff --git a/cli/manifest.go b/cli/manifest.go
index c193378..8de616e 100644
--- a/cli/manifest.go
+++ b/cli/manifest.go
@@ -149,12 +149,36 @@ func loadManifest(path string) (Manifest, error) {
return Manifest{}, err
}
m, err := decodeManifest(data)
+ if err == nil && path == "" {
+ lowData, lowErr := assets.ReadFile("assets/manifest-low-memory.json")
+ if lowErr == nil {
+ low, decodeErr := decodeManifest(lowData)
+ if decodeErr != nil {
+ return Manifest{}, decodeErr
+ }
+ ram, memoryErr := physicalMemory()
+ if memoryErr != nil {
+ return Manifest{}, memoryErr
+ }
+ m = manifestForMemory(m, low, ram)
+ } else if !os.IsNotExist(lowErr) {
+ return Manifest{}, lowErr
+ }
+ }
if err == nil && (m.OS != runtime.GOOS || m.Arch != runtime.GOARCH) {
err = fmt.Errorf("bundle targets %s/%s, this machine is %s/%s", m.OS, m.Arch, runtime.GOOS, runtime.GOARCH)
}
return m, err
}
+// Select the smaller checkpoint below 48 GiB. Explicit --manifest bypasses this.
+func manifestForMemory(primary, low Manifest, ram int64) Manifest {
+ if ram < 48*1024*1024*1024 {
+ return low
+ }
+ return primary
+}
+
func digest(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
func modelDigest(m Manifest) string { b, _ := json.Marshal(m.Files); return digest(b) }
diff --git a/cli/memory_test.go b/cli/memory_test.go
new file mode 100644
index 0000000..adc6945
--- /dev/null
+++ b/cli/memory_test.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestManifestForMemory(t *testing.T) {
+ primary := Manifest{Revision: "int4", Context: 262144, Entry: "flint-int4.json"}
+ low := Manifest{Revision: "mixed", Context: 131072, Entry: "config.json"}
+ for _, gib := range []int64{16, 24, 32, 36, 47, 48, 64, 128} {
+ want := primary
+ if gib < 48 {
+ want = low
+ }
+ got := manifestForMemory(primary, low, gib<<30)
+ if got.Revision != want.Revision || got.Context != want.Context || got.Entry != want.Entry {
+ t.Fatalf("%d GiB: got %+v, want %+v", gib, got, want)
+ }
+ }
+}
+
+func TestEmbeddedLowMemoryManifest(t *testing.T) {
+ data, err := assets.ReadFile("assets/manifest-low-memory.json")
+ if os.IsNotExist(err) {
+ t.Skip("no alternate profile packaged")
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ low, err := decodeManifest(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err = assets.ReadFile("assets/manifest.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ primary, err := decodeManifest(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Run("explicit profile", func(t *testing.T) {
+ if runtime.GOOS != primary.OS || runtime.GOARCH != primary.Arch {
+ t.Skip("explicit profiles require a matching host target")
+ }
+ // An explicit manifest must retain the requested profile on a 32 GiB Mac.
+ path := filepath.Join(t.TempDir(), "manifest.json")
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ explicit, err := loadManifest(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if explicit.Revision != primary.Revision {
+ t.Fatal("explicit profile was replaced")
+ }
+ })
+ if low.RuntimeSHA != primary.RuntimeSHA || low.MinRAM != 32<<30 || low.Context != 131072 || low.Entry != "config.json" {
+ t.Fatalf("invalid low-memory profile: %+v", low)
+ }
+}
diff --git a/cli/platform.go b/cli/platform.go
index 3d50b83..465055a 100644
--- a/cli/platform.go
+++ b/cli/platform.go
@@ -21,11 +21,7 @@ func preflight(m Manifest) error {
if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" {
return fmt.Errorf("rec requires an Apple Silicon Mac")
}
- s, err := command("/usr/sbin/sysctl", "-n", "hw.memsize")
- if err != nil {
- return err
- }
- ram, err := strconv.ParseInt(s, 10, 64)
+ ram, err := physicalMemory()
if err != nil {
return err
}
@@ -41,3 +37,11 @@ func preflight(m Manifest) error {
}
return checkAppleChip(chip)
}
+
+func physicalMemory() (int64, error) {
+ s, err := command("/usr/sbin/sysctl", "-n", "hw.memsize")
+ if err != nil {
+ return 0, err
+ }
+ return strconv.ParseInt(s, 10, 64)
+}
diff --git a/cli/rec_test.go b/cli/rec_test.go
index 1caade0..06caef5 100644
--- a/cli/rec_test.go
+++ b/cli/rec_test.go
@@ -356,7 +356,7 @@ func TestManifestSchemaAndPaths(t *testing.T) {
}
func TestAppleChipAllowlist(t *testing.T) {
- for chip, ok := range map[string]bool{"Apple M4 Pro": true, "Apple M5 Pro": true, "Apple M5 Max": true, "Apple M4 Max": true, "Apple M4": false, "Apple M1 Pro": false} {
+ for chip, ok := range map[string]bool{"Apple M4 Pro": true, "Apple M5 Pro": true, "Apple M5 Max": true, "Apple M4 Max": true, "Apple M6": true, "Apple M6 Pro": false, "Apple M4": false, "Apple M1 Pro": false} {
if (checkAppleChip(chip) == nil) != ok {
t.Fatalf("%s: want supported=%t", chip, ok)
}
diff --git a/engine/hatch_build.py b/engine/hatch_build.py
index 73c7e0b..7d94c69 100644
--- a/engine/hatch_build.py
+++ b/engine/hatch_build.py
@@ -1,5 +1,6 @@
import subprocess
import sys
+import sysconfig
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
@@ -12,4 +13,6 @@ def initialize(self, version, build_data):
subprocess.run([sys.executable, str(Path(self.root) / "native/build.py")], check=True)
build_data["pure_python"] = False
build_data["infer_tag"] = True
- build_data["artifacts"] = ["src/rec_mlx/_grammar_native*.so"]
+ build_data["artifacts"] = [
+ "src/rec_mlx/_grammar_native" + sysconfig.get_config_var("EXT_SUFFIX")
+ ]
diff --git a/engine/pyproject.toml b/engine/pyproject.toml
index 59b1dde..66b6c96 100644
--- a/engine/pyproject.toml
+++ b/engine/pyproject.toml
@@ -1,14 +1,18 @@
[build-system]
-requires = ["hatchling>=1.27,<2", "mlx==0.32.0", "nanobind==2.13.0"]
+requires = [
+ "hatchling>=1.27,<2",
+ "mlx @ git+https://github.com/ml-explore/mlx.git@fc344c986c4169d99f76b5d95ae6b4a58a359a63",
+ "nanobind==3.1.0",
+]
build-backend = "hatchling.build"
[project]
name = "rec-mlx"
version = "0.1.0"
description = "Flint inference engine for Apple Silicon"
-requires-python = ">=3.12"
+requires-python = ">=3.14.10"
dependencies = [
- "mlx==0.32.0",
+ "mlx @ git+https://github.com/ml-explore/mlx.git@fc344c986c4169d99f76b5d95ae6b4a58a359a63",
"mlx-lm==0.31.3",
"transformers==5.16.1",
"xgrammar==0.2.1",
@@ -20,6 +24,9 @@ validation = ["pytest==8.4.2"]
[project.scripts]
rec-mlx = "rec_mlx.cli:main"
+[tool.hatch.metadata]
+allow-direct-references = true
+
[tool.hatch.build.targets.wheel]
packages = ["src/rec_mlx"]
diff --git a/engine/src/rec_mlx/checkpoint.py b/engine/src/rec_mlx/checkpoint.py
index 9f341c9..16e0757 100644
--- a/engine/src/rec_mlx/checkpoint.py
+++ b/engine/src/rec_mlx/checkpoint.py
@@ -4,8 +4,31 @@
def validate(directory: Path, manifest_path: Path) -> dict:
manifest = json.loads(manifest_path.read_text())
- if manifest["weight_format"] != "mlx-affine-int4" or manifest["kv_format"] != "bf16":
- raise ValueError("This engine requires affine INT4 weights and BF16 KV")
+ config = json.loads((directory / "config.json").read_text())
+ # Community checkpoints use a Hugging Face config and shard index rather
+ # than Recursive's separate profile. The CLI pins and verifies both files.
+ if manifest_path.name == "config.json":
+ index = json.loads((directory / "model.safetensors.index.json").read_text())
+ manifest = {
+ "model_id": "flint-mixed-3-4bit",
+ "weight_format": "mlx-affine-int3-int4",
+ "kv_format": "bf16",
+ "max_context": config["max_position_embeddings"],
+ "checkpoint_files": {
+ name: None
+ for name in {
+ "config.json",
+ "tokenizer.json",
+ "tokenizer_config.json",
+ "special_tokens_map.json",
+ "model.safetensors.index.json",
+ *index["weight_map"].values(),
+ }
+ },
+ }
+ formats = {"mlx-affine-int4": 4, "mlx-affine-int3-int4": 3}
+ if manifest["weight_format"] not in formats or manifest["kv_format"] != "bf16":
+ raise ValueError("This engine requires affine INT4 or mixed INT3/INT4 weights and BF16 KV")
files = manifest["checkpoint_files"]
required = {"config.json", "tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"}
shards = {name for name in files if name.startswith("model-") and name.endswith(".safetensors")}
@@ -20,12 +43,28 @@ def validate(directory: Path, manifest_path: Path) -> dict:
raise ValueError(f"Checkpoint filename must be a basename: {name}")
if not (directory / name).is_file():
raise ValueError(f"Checkpoint is missing a manifest file: {name}")
- config = json.loads((directory / "config.json").read_text())
if config["sliding_window"] != 513:
raise ValueError("Flint requires sliding_window=513, including the current token")
export = config["mlx_export"]
- if (export["bits"], export["routed_expert_bits"], export["embedding_bits"]) != (4, 4, 8):
- raise ValueError("Expected INT4 projections/experts and INT8 embeddings")
- if config["quantization"] != {"group_size": 64, "bits": 4}:
- raise ValueError("Expected group-64 INT4 quantization")
+ expert_bits = formats[manifest["weight_format"]]
+ if (export["bits"], export["routed_expert_bits"], export["embedding_bits"]) != (
+ 4,
+ expert_bits,
+ 8,
+ ):
+ raise ValueError("Expected INT4 projections, INT3 or INT4 experts, and INT8 embeddings")
+ expected = {"group_size": 64, "bits": 4}
+ if expert_bits == 3:
+ expected["model.embed_tokens"] = {"group_size": 64, "bits": 8}
+ for layer in range(config["num_hidden_layers"]):
+ if layer not in config["mlp_only_layers"]:
+ for projection in ("gate_proj", "up_proj", "down_proj"):
+ expected[f"model.layers.{layer}.mlp.switch_mlp.{projection}"] = {
+ "group_size": 64,
+ "bits": 3,
+ }
+ if config["quantization"] != expected:
+ raise ValueError("Unexpected group-64 affine quantization layout")
+ if "quantization_config" in config and config["quantization_config"] != expected:
+ raise ValueError("Conflicting quantization configurations")
return manifest
diff --git a/engine/src/rec_mlx/engine.py b/engine/src/rec_mlx/engine.py
index 6ba06f1..474498b 100644
--- a/engine/src/rec_mlx/engine.py
+++ b/engine/src/rec_mlx/engine.py
@@ -57,8 +57,8 @@ def __init__(
if platform.system() != "Darwin" or platform.machine() != "arm64":
raise RuntimeError("Flint requires an Apple Silicon Mac")
memory = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True))
- if memory < 48 * 1024**3:
- raise RuntimeError("This build targets Macs with at least 48 GiB of memory")
+ if memory < 32 * 1024**3:
+ raise RuntimeError("This build targets Macs with at least 32 GiB of memory")
import mlx.core as mx
device = mx.device_info()
diff --git a/engine/src/rec_mlx/kernels/device.py b/engine/src/rec_mlx/kernels/device.py
index 0cd8358..f7cb56e 100644
--- a/engine/src/rec_mlx/kernels/device.py
+++ b/engine/src/rec_mlx/kernels/device.py
@@ -18,6 +18,7 @@ def profile_for_device(info):
("applegpu_g16s", "Apple M4 Max"): "M4 Max",
("applegpu_g17s", "Apple M5 Pro"): "M5 Pro",
("applegpu_g17s", "Apple M5 Max"): "M5 Max",
+ ("applegpu_g18g", "Apple M6"): "M6",
}
architecture = info["architecture"]
identity = (architecture, info["device_name"])
@@ -36,6 +37,7 @@ def profile_for_device(info):
chip=chips[identity],
architecture=architecture,
prefill_chunk_size=4096 if chips[identity] == "M5 Pro" else 2048,
+ # Keep M6 on the generic expert path until its specialized shader is validated.
# The specialized M4 resident shader fails scalar/packet parity.
- specialize_mixed_experts=chips[identity] not in ("M4 Pro", "M4 Max"),
+ specialize_mixed_experts=chips[identity] not in ("M4 Pro", "M4 Max", "M6"),
)
diff --git a/engine/src/rec_mlx/kernels/masked_sampling.py b/engine/src/rec_mlx/kernels/masked_sampling.py
index daa9370..07db7b5 100644
--- a/engine/src/rec_mlx/kernels/masked_sampling.py
+++ b/engine/src/rec_mlx/kernels/masked_sampling.py
@@ -3,6 +3,8 @@
import mlx.core as mx
from mlx_lm.sample_utils import make_sampler as native_sampler
+from ..random_state import restore_random_key
+
class FilteredResidentSampler:
def __init__(self, module, engine, specification):
@@ -205,7 +207,7 @@ def block(
if logprobs is not None:
logprobs.extend(out[7 if presence_penalty else 6][:produced].tolist())
# Restore the exact scalar sampler's key when EOS truncates the packet.
- mx.random.state[0] = out[5][produced - 1]
+ restore_random_key(out[5][produced - 1])
if presence_penalty:
state["presence_mask"] = out[6]
state["position"] += produced
diff --git a/engine/src/rec_mlx/kernels/megakernel.py b/engine/src/rec_mlx/kernels/megakernel.py
index d833fac..4e3ba48 100644
--- a/engine/src/rec_mlx/kernels/megakernel.py
+++ b/engine/src/rec_mlx/kernels/megakernel.py
@@ -988,7 +988,6 @@ def __init__(self, model, workers=40, mlp_workers=32, head_workers=32):
self.team_mlp_workers = mlp_workers
self.workers = workers
assert model.config["quantization"]["group_size"] == 64
- assert mx.device_info()["architecture"] in ("applegpu_g16s", "applegpu_g17s")
assert len(model.layers) == 49 and model.config["hidden_size"] == 2048
assert tuple(
model.config[key]
diff --git a/engine/src/rec_mlx/random_state.py b/engine/src/rec_mlx/random_state.py
new file mode 100644
index 0000000..fbe7244
--- /dev/null
+++ b/engine/src/rec_mlx/random_state.py
@@ -0,0 +1,12 @@
+import mlx.core as mx
+
+
+def restore_random_key(key):
+ """Restore a saved key through MLX's thread-local public seed API.
+
+ MLX HEAD exposes random.state as a read-only sentinel. A key is exactly
+ the two uint32 words of a uint64 seed, so reseeding restores all its bits
+ without mutating an array that another saved state may still reference.
+ """
+ high, low = key.tolist()
+ mx.random.seed((high << 32) | low)
diff --git a/engine/tests/test_checkpoint.py b/engine/tests/test_checkpoint.py
new file mode 100644
index 0000000..9efe866
--- /dev/null
+++ b/engine/tests/test_checkpoint.py
@@ -0,0 +1,92 @@
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from rec_mlx.checkpoint import validate
+
+
+class CheckpointTests(unittest.TestCase):
+ def fixture(self, directory, bits):
+ quantization = {"group_size": 64, "bits": 4}
+ if bits == 3:
+ quantization["model.embed_tokens"] = {"group_size": 64, "bits": 8}
+ for projection in ("gate_proj", "up_proj", "down_proj"):
+ quantization[f"model.layers.1.mlp.switch_mlp.{projection}"] = {
+ "group_size": 64,
+ "bits": 3,
+ }
+ config = {
+ "sliding_window": 513,
+ "max_position_embeddings": 262144,
+ "num_hidden_layers": 2,
+ "mlp_only_layers": [0],
+ "mlx_export": {"bits": 4, "routed_expert_bits": bits, "embedding_bits": 8},
+ "quantization": quantization,
+ "quantization_config": quantization,
+ }
+ (directory / "config.json").write_text(json.dumps(config))
+ names = [
+ "config.json",
+ "tokenizer.json",
+ "tokenizer_config.json",
+ "special_tokens_map.json",
+ "model-00001.safetensors",
+ ]
+ for name in names[1:]:
+ (directory / name).write_text("{}")
+ (directory / "model.safetensors.index.json").write_text(
+ json.dumps(
+ {
+ "weight_map": {"model.weight": "model-00001.safetensors"},
+ }
+ )
+ )
+ profile = directory / "flint-int4.json"
+ profile.write_text(
+ json.dumps(
+ {
+ "weight_format": "mlx-affine-int4",
+ "kv_format": "bf16",
+ "checkpoint_files": dict.fromkeys(names),
+ "max_context": 262144,
+ }
+ )
+ )
+ return config, profile if bits == 4 else directory / "config.json"
+
+ def test_both_checkpoint_formats(self):
+ for bits, expected in ((4, "mlx-affine-int4"), (3, "mlx-affine-int3-int4")):
+ with self.subTest(bits=bits), tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ _, profile = self.fixture(directory, bits)
+ identity = validate(directory, profile)
+ self.assertEqual(identity["weight_format"], expected)
+ self.assertEqual(identity["max_context"], 262144)
+ if bits == 3:
+ self.assertEqual(identity["model_id"], "flint-mixed-3-4bit")
+
+ def test_mixed_rejects_wrong_layout(self):
+ for mutation in ("expert_bits", "group_size", "output_bits", "conflicting_config"):
+ with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ config, profile = self.fixture(directory, 3)
+ if mutation == "expert_bits":
+ config["mlx_export"]["routed_expert_bits"] = 2
+ elif mutation == "group_size":
+ config["quantization"]["group_size"] = 128
+ elif mutation == "output_bits":
+ config["quantization"]["lm_head"] = {"bits": 8, "group_size": 64}
+ else:
+ config["quantization_config"] = {"bits": 2, "group_size": 64}
+ profile.write_text(json.dumps(config))
+ with self.assertRaises(ValueError):
+ validate(directory, profile)
+
+ def test_extra_shard_rejected(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ _, profile = self.fixture(directory, 3)
+ (directory / "model-extra.safetensors").touch()
+ with self.assertRaisesRegex(ValueError, "shards"):
+ validate(directory, profile)
diff --git a/engine/tests/test_device.py b/engine/tests/test_device.py
new file mode 100644
index 0000000..ed54e86
--- /dev/null
+++ b/engine/tests/test_device.py
@@ -0,0 +1,29 @@
+import unittest
+
+from rec_mlx.kernels.device import profile_for_device
+
+
+class DeviceProfileTests(unittest.TestCase):
+ def test_m6_uses_generic_expert_path(self):
+ profile = profile_for_device({"architecture": "applegpu_g18g", "device_name": "Apple M6"})
+ self.assertEqual(profile.chip, "M6")
+ self.assertEqual(profile.architecture, "applegpu_g18g")
+ self.assertFalse(profile.specialize_mixed_experts)
+
+ def test_m6_requires_matching_architecture(self):
+ with self.assertRaises(ValueError):
+ profile_for_device({"architecture": "applegpu_g17s", "device_name": "Apple M6"})
+
+ def test_existing_profiles_are_preserved(self):
+ for chip, architecture, workers, specialized in (
+ ("M4 Pro", "applegpu_g16s", 36, False),
+ ("M4 Max", "applegpu_g16s", 36, False),
+ ("M5 Pro", "applegpu_g17s", 36, True),
+ ("M5 Max", "applegpu_g17s", 76, True),
+ ):
+ with self.subTest(chip=chip):
+ profile = profile_for_device(
+ {"architecture": architecture, "device_name": "Apple " + chip}
+ )
+ self.assertEqual(profile.workers, workers)
+ self.assertEqual(profile.specialize_mixed_experts, specialized)
diff --git a/engine/tests/test_kernel_routing.py b/engine/tests/test_kernel_routing.py
index 884394e..9352c36 100644
--- a/engine/tests/test_kernel_routing.py
+++ b/engine/tests/test_kernel_routing.py
@@ -28,7 +28,7 @@ def selected_weights(probabilities, bias):
@unittest.skipUnless(mx.metal.is_available(), "requires Apple Metal")
class RoutingTests(unittest.TestCase):
@unittest.skipUnless(
- mx.metal.is_available() and resident_device(), "requires M4 Pro, M5 Pro or M5 Max"
+ mx.metal.is_available() and resident_device(), "requires a supported resident kernel device"
)
def test_scores_survive_attention_scratch_reuse(self):
probabilities = (mx.arange(256).astype(mx.float32) + 1) / 1024
diff --git a/engine/tests/test_random_state.py b/engine/tests/test_random_state.py
new file mode 100644
index 0000000..b22200e
--- /dev/null
+++ b/engine/tests/test_random_state.py
@@ -0,0 +1,30 @@
+import unittest
+from functools import partial
+
+import mlx.core as mx
+
+from rec_mlx.random_state import restore_random_key
+
+
+@partial(mx.compile, inputs=mx.random.state, outputs=mx.random.state)
+def compiled_draw():
+ return mx.random.uniform(shape=(32,))
+
+
+class RandomStateTests(unittest.TestCase):
+ def test_restore_replays_eager_and_compiled_draws(self):
+ for seed in (42, 0xFEDCBA9876543210):
+ for draw in (lambda: mx.random.uniform(shape=(32,)), compiled_draw):
+ with self.subTest(seed=seed, draw=draw):
+ mx.random.seed(seed)
+ initial = mx.random.state[0]
+ initial_words = initial.tolist()
+ expected = draw()
+ final = mx.random.state[0]
+ final_words = final.tolist()
+ restore_random_key(initial)
+ actual = draw()
+ self.assertTrue(mx.array_equal(actual, expected).item())
+ self.assertEqual(mx.random.state[0].tolist(), final_words)
+ self.assertEqual(initial.tolist(), initial_words)
+ self.assertEqual(final.tolist(), final_words)
diff --git a/engine/tests/validate_resident.py b/engine/tests/validate_resident.py
index bfed5c5..954b5b1 100644
--- a/engine/tests/validate_resident.py
+++ b/engine/tests/validate_resident.py
@@ -15,6 +15,7 @@
from rec_mlx.kernels import build_decoder, megakernel
from rec_mlx.kernels.masked_sampling import FilteredResidentSampler
from rec_mlx.quantized import QATModel
+from rec_mlx.random_state import restore_random_key
def validate_stopping(engine, resident, specification, tokenizer, prompt):
@@ -58,7 +59,7 @@ def pick(logits, index):
for token in tokens[:begin]:
reference.forward_token(token)
candidate.forward_token(token)
- mx.random.state[0] = keys[begin]
+ restore_random_key(keys[begin])
expected = [
pick(reference.forward_token(tokens[begin + i]), begin + i + 1)
for i in range(stop)
@@ -67,7 +68,7 @@ def pick(logits, index):
model.eos_tokens = [expected[-1]]
# Each compiled graph must capture this fixture's EOS, not a previous one.
resident.filtered = FilteredResidentSampler(resident.module, engine, specification)
- mx.random.state[0] = keys[begin]
+ restore_random_key(keys[begin])
actual = resident.block(candidate._state, tokens[begin], 8, 43 + begin, **settings)
assert actual == expected and len(actual) == stop, "EOS truncation mismatch"
assert mx.array_equal(mx.random.state[0], expected_key).item(), "EOS RNG mismatch"
@@ -117,7 +118,13 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", type=Path, required=True, help="Flint checkpoint directory")
parser.add_argument("--output", type=Path, required=True, help="JSON file for the results")
+ parser.add_argument(
+ "--collect-native-differences",
+ action="store_true",
+ help="Run remaining checks and report every native parity failure before failing",
+ )
args = parser.parse_args()
+ native_differences = []
mx.set_cache_limit(256 * 1024**2)
model = QATModel(args.model)
specification = {}
@@ -227,7 +234,7 @@ def choose(logits, index):
count = min((1, 3, 8)[packets % 3], steps - position)
packet_input = pending
expected, logits, traces = [], [], []
- mx.random.state[0] = scalar_key
+ restore_random_key(scalar_key)
for offset in range(count):
value, trace = engine.forward(reference._state, pending)
if temperature == 0 and position + offset < 8:
@@ -237,12 +244,39 @@ def choose(logits, index):
h = layer(h, None, cache)
native_trace.append(h.reshape(-1))
native_logits = model.native.lm_head(model.native.model.norm(h))
- assert mx.array_equal(mx.stack(native_trace), trace).item(), (
- "Native layer mismatch"
- )
- assert mx.array_equal(native_logits.reshape(-1), value).item(), (
- "Native logits mismatch"
- )
+ stacked = mx.stack(native_trace)
+ same_layers = mx.array_equal(stacked, trace).item()
+ same_logits = mx.array_equal(native_logits.reshape(-1), value).item()
+ if not same_layers or not same_logits:
+ native_differences.append(
+ dict(
+ context=context,
+ seed=seed,
+ position=position + offset,
+ presence_penalty=penalty,
+ layers_equal=same_layers,
+ logits_equal=same_logits,
+ max_layer_error=float(
+ mx.max(
+ mx.abs(
+ stacked.astype(mx.float32)
+ - trace.astype(mx.float32)
+ )
+ ).item()
+ ),
+ max_logit_error=float(
+ mx.max(
+ mx.abs(
+ native_logits.reshape(-1).astype(mx.float32)
+ - value
+ )
+ ).item()
+ ),
+ )
+ )
+ if not args.collect_native_differences:
+ assert same_layers, "Native layer mismatch"
+ assert same_logits, "Native logits mismatch"
native_checks += 1
pending = choose(value, position + offset + 1)
seen.add(pending)
@@ -252,7 +286,7 @@ def choose(logits, index):
if pending in model.eos_tokens:
break
scalar_key = mx.random.state[0]
- mx.random.state[0] = packet_key
+ restore_random_key(packet_key)
actual = resident.block(
candidate._state,
packet_input,
@@ -390,9 +424,14 @@ def choose(logits, index):
cases=results,
stopping=stopping,
performance=performance,
+ native_differences=native_differences,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2) + "\n")
+ if native_differences:
+ raise AssertionError(
+ f"{len(native_differences)} native-reference parity checks failed; see {args.output}"
+ )
if __name__ == "__main__":
diff --git a/packaging/build-mlx-runtime.py b/packaging/build-mlx-runtime.py
index 13234d3..944f3dd 100644
--- a/packaging/build-mlx-runtime.py
+++ b/packaging/build-mlx-runtime.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Build the relocatable MLX runtime: standalone CPython 3.12 with the rec-mlx engine.
+"""Build the relocatable MLX runtime: standalone CPython 3.14.10 with the rec-mlx engine.
Run on an Apple Silicon Mac. Use a standalone CPython such as uv's managed Python,
not a system Python or a virtualenv."""
@@ -22,27 +22,29 @@ def main():
"--python-root",
type=Path,
required=True,
- help="standalone CPython 3.12 installation to copy",
+ help="standalone CPython 3.14.10 installation to copy",
)
parser.add_argument("--output", type=Path, required=True, help="new directory for the runtime")
parser.add_argument("--uv", default="uv", help="uv executable (default: %(default)s)")
args = parser.parse_args()
if args.output.exists():
parser.error("output must be a new directory")
- source_python = args.python_root / "bin/python3.12"
+ source_python = args.python_root / "bin/python3.14"
information = json.loads(
subprocess.check_output(
[
str(source_python),
"-I",
"-c",
- "import json,platform,sys; print(json.dumps([platform.system(),platform.machine(),sys.prefix,sys.base_prefix]))",
+ "import json,platform,sys; print(json.dumps([platform.system(),platform.machine(),sys.prefix,sys.base_prefix,list(sys.version_info[:3])]))",
],
text=True,
)
)
if information[:2] != ["Darwin", "arm64"] or information[2] != information[3]:
- parser.error("build on Darwin ARM64 with standalone CPython 3.12, not a virtualenv")
+ parser.error("build on Darwin ARM64 with standalone CPython 3.14.10, not a virtualenv")
+ if information[4] != [3, 14, 10]:
+ parser.error("runtime requires CPython 3.14.10")
args.output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=args.output.parent, prefix=".mlx-build-") as td:
wheels = Path(td) / "wheels"
@@ -69,7 +71,7 @@ def main():
symlinks=False,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
)
- python = stage / "python/bin/python3.12"
+ python = stage / "python/bin/python3.14"
subprocess.run(
[
args.uv,
@@ -78,15 +80,13 @@ def main():
"--python",
str(python),
"--break-system-packages",
- "--only-binary",
- ":all:",
str(wheel),
],
check=True,
)
launcher = stage / "rec-mlx"
launcher.write_text(
- '#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "$root/python/bin/python3.12" -I -B -m rec_mlx.cli "$@"\n'
+ '#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "$root/python/bin/python3.14" -I -B -m rec_mlx.cli "$@"\n'
)
launcher.chmod(0o755)
(stage / "build.json").write_text(
@@ -106,7 +106,7 @@ def main():
subprocess.run([str(relocated.resolve() / "rec-mlx"), "--help"], check=True)
subprocess.run(
[
- str(relocated.resolve() / "python/bin/python3.12"),
+ str(relocated.resolve() / "python/bin/python3.14"),
"-I",
"-B",
"-c",
diff --git a/packaging/flint-mixed-3-4bit-mlx.json b/packaging/flint-mixed-3-4bit-mlx.json
new file mode 100644
index 0000000..e2b5e2a
--- /dev/null
+++ b/packaging/flint-mixed-3-4bit-mlx.json
@@ -0,0 +1,82 @@
+{
+ "schema": 1,
+ "model": "flint",
+ "revision": "flint-mixed-3-4bit-b1c305855ae7",
+ "development": false,
+ "engine": "mlx",
+ "os": "darwin",
+ "arch": "arm64",
+ "backend": "metal",
+ "context": 131072,
+ "max_context": 262144,
+ "min_ram_bytes": 34359738368,
+ "entry": "config.json",
+ "files": [
+ {
+ "name": "chat_template.jinja",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/chat_template.jinja",
+ "size": 2328,
+ "sha256": "7940f4ffa3275c36caa4a582f845aaad79da11018b3961e8fbcad6ae29733466"
+ },
+ {
+ "name": "config.json",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/config.json",
+ "size": 30533,
+ "sha256": "4d13bc57eeaf5c24abcb56b9fcd1a15af2420157b8e80ab57dcf988de06c2f70"
+ },
+ {
+ "name": "generation_config.json",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/generation_config.json",
+ "size": 202,
+ "sha256": "a499c71895e008abd572558a5ad5904feaed92cf2a3b6e2cb0e9cabf03b96ed3"
+ },
+ {
+ "name": "model-00001-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/model-00001-of-00004.safetensors",
+ "size": 5297553268,
+ "sha256": "d05c6bcf0836f5f80ac7f74f70cfd679a42876d5637e3c998a990bd561add84d"
+ },
+ {
+ "name": "model-00002-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/model-00002-of-00004.safetensors",
+ "size": 5363650068,
+ "sha256": "944ab77d484cb3b2e8f464bd98c1e377441ed3aeaecbadaf0f28b16729922ff9"
+ },
+ {
+ "name": "model-00003-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/model-00003-of-00004.safetensors",
+ "size": 5294980456,
+ "sha256": "33dc84f510cd4f83003e602920221833ce219adccc819b9d5369a13b8120b531"
+ },
+ {
+ "name": "model-00004-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/model-00004-of-00004.safetensors",
+ "size": 1881556490,
+ "sha256": "adc1057c1e9e9cfa1a73655c20136bcfb9078dbbf90a21d4a69df3ee30276417"
+ },
+ {
+ "name": "model.safetensors.index.json",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/model.safetensors.index.json",
+ "size": 159409,
+ "sha256": "d2013f38b5be074831214ff03f9717a1027a16b08ddcbc95944fd06769248910"
+ },
+ {
+ "name": "special_tokens_map.json",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/special_tokens_map.json",
+ "size": 20192,
+ "sha256": "be37b302338dda189bc70bfbe902d8faebeb5986d60c1342713439eefc4ca182"
+ },
+ {
+ "name": "tokenizer.json",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/tokenizer.json",
+ "size": 10879128,
+ "sha256": "a263cce0b7e2b03f7ee07ce809783ef4b47f160c3f268a4ea1524f638da45b68"
+ },
+ {
+ "name": "tokenizer_config.json",
+ "url": "https://huggingface.co/mlx-community/flint-mixed-3-4bit/resolve/b1c305855ae7c3464813815be4dbd544b22c36aa/tokenizer_config.json",
+ "size": 30439,
+ "sha256": "159ac88217e90ffd0a4a294726048bb302829e82e1958dc85bb203aa79138fbf"
+ }
+ ]
+}
diff --git a/packaging/package.py b/packaging/package.py
index 78adce8..a39484a 100644
--- a/packaging/package.py
+++ b/packaging/package.py
@@ -65,6 +65,9 @@ def main():
required=True,
help="model manifest, such as packaging/flint-int4-mlx.json",
)
+ p.add_argument(
+ "--low-memory-manifest", type=Path, help="alternate manifest for Macs below 48 GiB"
+ )
p.add_argument(
"--version",
default="0.1.0-dev",
@@ -78,6 +81,14 @@ def main():
m, archive = payload(a.runtime_dir, a.manifest)
except ValueError as error:
p.error(str(error))
+ low = None
+ if a.low_memory_manifest:
+ low = json.loads(a.low_memory_manifest.read_text())
+ for key in ("engine", "os", "arch", "backend", "model"):
+ if low[key] != m[key]:
+ p.error(f"low-memory manifest disagrees on {key}")
+ low["runtime_files"] = m["runtime_files"]
+ low["runtime_sha256"] = m["runtime_sha256"]
root = Path(__file__).resolve().parents[1]
output = a.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
@@ -91,9 +102,9 @@ def main():
assets.mkdir()
(assets / "manifest.json").write_text(json.dumps(m, indent=2) + "\n")
(assets / "runtime.tar.gz").write_bytes(archive)
- subprocess.run(
- ["go", "test", "./cli", "-run", "TestEmbeddedManifest"], cwd=build, check=True
- )
+ if low is not None:
+ (assets / "manifest-low-memory.json").write_text(json.dumps(low, indent=2) + "\n")
+ subprocess.run(["go", "test", "./cli", "-run", "TestEmbedded"], cwd=build, check=True)
env = dict(os.environ, CGO_ENABLED="0", GOOS=m["os"], GOARCH=m["arch"])
staged = build / "rec"
subprocess.run(
@@ -116,6 +127,10 @@ def main():
data = output.read_bytes()
output.with_name(output.name + ".meta").write_text(f"{sha(data)} {len(data)}\n")
output.with_name(output.name + ".manifest.json").write_text(json.dumps(m, indent=2) + "\n")
+ if low is not None:
+ output.with_name(output.name + ".low-memory.manifest.json").write_text(
+ json.dumps(low, indent=2) + "\n"
+ )
print(output)
diff --git a/tests/build_smoke_runtime.py b/tests/build_smoke_runtime.py
index 47e76e6..cf4dbb5 100755
--- a/tests/build_smoke_runtime.py
+++ b/tests/build_smoke_runtime.py
@@ -39,7 +39,7 @@ def main():
launcher = runtime / "rec-mlx"
launcher.write_text(
'#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
- 'exec "$root/python/bin/python3.12" -I -B "$root/smoke_server.py" "$@"\n'
+ 'exec "$root/python/bin/python3.14" -I -B "$root/smoke_server.py" "$@"\n'
)
launcher.chmod(0o755)
shutil.copy2(root / "tests/data/smoke-mlx.json", args.output / "smoke-mlx.json")
diff --git a/tests/package_test.py b/tests/package_test.py
index 6d9f18b..87d14b5 100644
--- a/tests/package_test.py
+++ b/tests/package_test.py
@@ -34,7 +34,21 @@
str(output),
]
subprocess.run([*base, "--runtime-dir", str(runtime)], check=True)
+ subprocess.run(
+ [
+ *base,
+ "--runtime-dir",
+ str(runtime),
+ "--low-memory-manifest",
+ str(root / "packaging/flint-mixed-3-4bit-mlx.json"),
+ ],
+ check=True,
+ )
descriptor = json.loads(output.with_name(output.name + ".manifest.json").read_text())
+ low = json.loads(output.with_name(output.name + ".low-memory.manifest.json").read_text())
+ assert low["entry"] == "config.json" and low["context"] == 131072
+ assert low["runtime_sha256"] == descriptor["runtime_sha256"]
+ assert low["runtime_files"] == descriptor["runtime_files"]
assert {f["name"] for f in descriptor["runtime_files"]} == {
"rec-mlx",
"python/bin/README",
From 40f2d63411b1751b61ebac4445aef0803b7c3f72 Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 08:49:02 -0500
Subject: [PATCH 02/11] Restore GPU architecture guard and explain checkpoint
and device tests
---
engine/src/rec_mlx/kernels/megakernel.py | 6 ++++++
engine/tests/test_checkpoint.py | 5 +++++
engine/tests/test_device.py | 3 +++
3 files changed, 14 insertions(+)
diff --git a/engine/src/rec_mlx/kernels/megakernel.py b/engine/src/rec_mlx/kernels/megakernel.py
index 4e3ba48..53007e7 100644
--- a/engine/src/rec_mlx/kernels/megakernel.py
+++ b/engine/src/rec_mlx/kernels/megakernel.py
@@ -984,6 +984,12 @@ class FullModel:
def __init__(self, model, workers=40, mlp_workers=32, head_workers=32):
from .device import profile_for_device
+ assert mx.device_info()["architecture"] in (
+ "applegpu_g16s",
+ "applegpu_g17s",
+ "applegpu_g18s",
+ "applegpu_g18g",
+ )
self.device_profile = profile_for_device(mx.device_info())
self.team_mlp_workers = mlp_workers
self.workers = workers
diff --git a/engine/tests/test_checkpoint.py b/engine/tests/test_checkpoint.py
index 9efe866..980cee0 100644
--- a/engine/tests/test_checkpoint.py
+++ b/engine/tests/test_checkpoint.py
@@ -8,8 +8,10 @@
class CheckpointTests(unittest.TestCase):
def fixture(self, directory, bits):
+ # Minimal metadata and placeholder shards exercise validation without loading model weights.
quantization = {"group_size": 64, "bits": 4}
if bits == 3:
+ # Mixed exports override embeddings to 8 bits and routed experts to 3 bits.
quantization["model.embed_tokens"] = {"group_size": 64, "bits": 8}
for projection in ("gate_proj", "up_proj", "down_proj"):
quantization[f"model.layers.1.mlp.switch_mlp.{projection}"] = {
@@ -56,6 +58,7 @@ def fixture(self, directory, bits):
return config, profile if bits == 4 else directory / "config.json"
def test_both_checkpoint_formats(self):
+ # Official INT4 uses a manifest; mixed exports describe their layout in config.json.
for bits, expected in ((4, "mlx-affine-int4"), (3, "mlx-affine-int3-int4")):
with self.subTest(bits=bits), tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
@@ -67,6 +70,7 @@ def test_both_checkpoint_formats(self):
self.assertEqual(identity["model_id"], "flint-mixed-3-4bit")
def test_mixed_rejects_wrong_layout(self):
+ # Reject metadata that disagrees with the quantization layout expected by the kernels.
for mutation in ("expert_bits", "group_size", "output_bits", "conflicting_config"):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
@@ -84,6 +88,7 @@ def test_mixed_rejects_wrong_layout(self):
validate(directory, profile)
def test_extra_shard_rejected(self):
+ # An unindexed shard can indicate a stale or accidentally combined checkpoint.
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
_, profile = self.fixture(directory, 3)
diff --git a/engine/tests/test_device.py b/engine/tests/test_device.py
index ed54e86..aafed8e 100644
--- a/engine/tests/test_device.py
+++ b/engine/tests/test_device.py
@@ -5,16 +5,19 @@
class DeviceProfileTests(unittest.TestCase):
def test_m6_uses_generic_expert_path(self):
+ # Verify the detected M6 keeps the generic expert path selected by its profile.
profile = profile_for_device({"architecture": "applegpu_g18g", "device_name": "Apple M6"})
self.assertEqual(profile.chip, "M6")
self.assertEqual(profile.architecture, "applegpu_g18g")
self.assertFalse(profile.specialize_mixed_experts)
def test_m6_requires_matching_architecture(self):
+ # A recognized marketing name must not admit an unrelated GPU architecture.
with self.assertRaises(ValueError):
profile_for_device({"architecture": "applegpu_g17s", "device_name": "Apple M6"})
def test_existing_profiles_are_preserved(self):
+ # Adding M6 support must preserve M4/M5 worker counts and specialization choices.
for chip, architecture, workers, specialized in (
("M4 Pro", "applegpu_g16s", 36, False),
("M4 Max", "applegpu_g16s", 36, False),
From a0c5177441157a3038ffdd03a3dc0fbb7ce3a71a Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 09:04:43 -0500
Subject: [PATCH 03/11] Give M6 a dedicated measured device profile
---
engine/M6_TUNING.md | 24 ++++++++++++++++++++++++
engine/src/rec_mlx/kernels/device.py | 15 +++++++++++++--
2 files changed, 37 insertions(+), 2 deletions(-)
create mode 100644 engine/M6_TUNING.md
diff --git a/engine/M6_TUNING.md b/engine/M6_TUNING.md
new file mode 100644
index 0000000..a237088
--- /dev/null
+++ b/engine/M6_TUNING.md
@@ -0,0 +1,24 @@
+# M6 schedule experiments
+
+M6 uses a dedicated device profile: 36 attention workers, 32 expert workers, 32 output-head workers, 2048-token prefill, and generic mixed experts. No tested candidate showed a consistent improvement sufficient to replace it.
+
+Apple documents 12 GPU cores versus 10 on base M5, up to 170 GB/s versus 153 GB/s bandwidth, and updated Dynamic Caching: https://www.apple.com/newsroom/2026/08/apple-introduces-m6-and-m5-ultra-for-a-big-leap-in-performance-and-ai-compute/
+
+This motivated occupancy and chunk-size experiments. These scalar Metal kernels do not automatically use the Neural Engine or GPU Neural Accelerators.
+
+Local M6 32 GiB, mixed 3/4-bit Flint, MLX HEAD, temperature 1/top-p .95/top-k 20, 128 generated tokens after 16-token warmup, cold prompt cache. One measured run per candidate; baseline repeated at the end. This is exploratory tuning, not statistically established speedup evidence.
+
+| Configuration (attention/expert/head; chunk; specialized) | 34 tokens | 4095 tokens | 8192 tokens |
+|---|---:|---:|---:|
+| 36/32/32; 2048; off | 85.1 | 75.4 | 70.6 |
+| 48/32/32; 2048; off | 84.2 | 74.4 | 68.0 |
+| 36/32/16; 2048; off | 80.6 | 74.9 | 67.6 |
+| 36/32/32; 1024; off | 81.9 | 75.5 | 69.9 |
+| 32/32/32; 2048; off | 84.2 | 73.8 | 67.1 |
+| 40/32/32; 2048; off | 83.3 | 73.0 | 67.3 |
+| 32/32/32; 2048; on | 75.2 | 75.0 | 69.8 |
+| Baseline repeat | 84.6 | 73.9 | 67.9 |
+
+Throughput is tokens/second. Baseline drift at 8192 tokens (70.6 to 67.9) means small differences are inconclusive. A 24-expert-worker trial was rejected by the existing 32/64-worker constraint.
+
+The earlier 4096-token/M5-specialization experiment passed 61 scalar/packet, 9 EOS and 3 cancellation cases but retained 72 independent native-reference failures. This tuning does not resolve that limitation. Candidate schedules above received generation/performance checks; they are not promoted as numerically validated alternatives.
diff --git a/engine/src/rec_mlx/kernels/device.py b/engine/src/rec_mlx/kernels/device.py
index f7cb56e..a32fdad 100644
--- a/engine/src/rec_mlx/kernels/device.py
+++ b/engine/src/rec_mlx/kernels/device.py
@@ -24,6 +24,18 @@ def profile_for_device(info):
identity = (architecture, info["device_name"])
if identity not in chips:
raise ValueError(f"Unsupported resident kernel device: {identity}")
+ if chips[identity] == "M6":
+ # M6 has its own schedule: larger attention teams and 4096-token
+ # prefill chunks regress this 32 GiB device in local Flint benchmarks.
+ return DeviceProfile(
+ chip="M6",
+ architecture=architecture,
+ workers=36,
+ mlp_workers=32,
+ head_workers=32,
+ prefill_chunk_size=2048,
+ specialize_mixed_experts=False,
+ )
if chips[identity] == "M5 Max":
return DeviceProfile(
chip=chips[identity],
@@ -37,7 +49,6 @@ def profile_for_device(info):
chip=chips[identity],
architecture=architecture,
prefill_chunk_size=4096 if chips[identity] == "M5 Pro" else 2048,
- # Keep M6 on the generic expert path until its specialized shader is validated.
# The specialized M4 resident shader fails scalar/packet parity.
- specialize_mixed_experts=chips[identity] not in ("M4 Pro", "M4 Max", "M6"),
+ specialize_mixed_experts=chips[identity] not in ("M4 Pro", "M4 Max"),
)
From 33e0800558f739116bff920f8df795416fd3f4a4 Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 18:57:56 -0500
Subject: [PATCH 04/11] Include prefill throughput logging from siilats/rec#3
---
engine/src/rec_mlx/engine.py | 12 ++++++++++++
engine/src/rec_mlx/prefix.py | 8 --------
engine/tests/test_prefix_rollback.py | 6 +-----
3 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/engine/src/rec_mlx/engine.py b/engine/src/rec_mlx/engine.py
index 474498b..f2901b0 100644
--- a/engine/src/rec_mlx/engine.py
+++ b/engine/src/rec_mlx/engine.py
@@ -1,5 +1,6 @@
import hashlib
import json
+import logging
import platform
import resource
import subprocess
@@ -274,6 +275,17 @@ def _generate_tokens(self, ids, sampling, *, should_stop, grammar=None, return_l
top_k=sampling.top_k,
)
prefill_end = time.perf_counter()
+ new_tokens = len(ids) - session.cached_tokens
+ elapsed = prefill_end - start
+ speed = f"{new_tokens / elapsed:.0f} t/s" if new_tokens and elapsed > 0 else "n/a t/s"
+ logging.info(
+ "Prefill: prompt=%d common_prefix=%d reused=%d new=%d %s",
+ len(ids),
+ session.common_prefix_tokens,
+ session.cached_tokens,
+ new_tokens,
+ speed,
+ )
prefill_peak = mx.get_peak_memory()
first = None
packet = []
diff --git a/engine/src/rec_mlx/prefix.py b/engine/src/rec_mlx/prefix.py
index 4e292ed..927e563 100644
--- a/engine/src/rec_mlx/prefix.py
+++ b/engine/src/rec_mlx/prefix.py
@@ -107,12 +107,4 @@ def select_prefix(prefix, tokens):
prefix.checkpoints = [
item for item in prefix.checkpoints if item.position < checkpoint.position
]
- cached = 0 if prefix is None else len(prefix.tokens)
- logging.info(
- "Prefill: prompt=%d common_prefix=%d reused=%d new=%d",
- len(tokens),
- common,
- cached,
- len(tokens) - cached,
- )
return prefix, common
diff --git a/engine/tests/test_prefix_rollback.py b/engine/tests/test_prefix_rollback.py
index ee6e8d5..b259843 100644
--- a/engine/tests/test_prefix_rollback.py
+++ b/engine/tests/test_prefix_rollback.py
@@ -110,7 +110,7 @@ def test_generation_selects_prefix_once_before_cancelled_prefill(self):
with (
patch("rec_mlx.prefix.select_prefix", wraps=select_prefix) as select,
patch("rec_mlx.quantized.select_prefix", select),
- self.assertLogs(level="INFO") as logs,
+ self.assertNoLogs(level="INFO"),
):
self.assertEqual(
list(engine.generate_tokens(tokens, settings, should_stop=lambda: True)), []
@@ -119,10 +119,6 @@ def test_generation_selects_prefix_once_before_cancelled_prefill(self):
self.assertEqual(session.common_prefix_tokens, 9)
self.assertIs(engine._prompt_cache, prefix)
self.assertEqual(prefix.tokens, list(range(8)))
- self.assertEqual(
- logs.output,
- ["INFO:root:Prefill: prompt=11 common_prefix=9 reused=8 new=3"],
- )
if __name__ == "__main__":
From 05334a3e61b9c80c27dcee101c04e2f106fc434d Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 19:59:59 -0500
Subject: [PATCH 05/11] Restore Flint mixed profile to 256K default context
---
cli/memory_test.go | 2 +-
packaging/flint-mixed-3-4bit-mlx.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/cli/memory_test.go b/cli/memory_test.go
index adc6945..26cab2f 100644
--- a/cli/memory_test.go
+++ b/cli/memory_test.go
@@ -59,7 +59,7 @@ func TestEmbeddedLowMemoryManifest(t *testing.T) {
t.Fatal("explicit profile was replaced")
}
})
- if low.RuntimeSHA != primary.RuntimeSHA || low.MinRAM != 32<<30 || low.Context != 131072 || low.Entry != "config.json" {
+ if low.RuntimeSHA != primary.RuntimeSHA || low.MinRAM != 32<<30 || low.Context != 262144 || low.Entry != "config.json" {
t.Fatalf("invalid low-memory profile: %+v", low)
}
}
diff --git a/packaging/flint-mixed-3-4bit-mlx.json b/packaging/flint-mixed-3-4bit-mlx.json
index e2b5e2a..b3ceca6 100644
--- a/packaging/flint-mixed-3-4bit-mlx.json
+++ b/packaging/flint-mixed-3-4bit-mlx.json
@@ -7,7 +7,7 @@
"os": "darwin",
"arch": "arm64",
"backend": "metal",
- "context": 131072,
+ "context": 262144,
"max_context": 262144,
"min_ram_bytes": 34359738368,
"entry": "config.json",
From c0e8f20b953c8827e335d1c2a744c0b59a94aa48 Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 20:36:19 -0500
Subject: [PATCH 06/11] Document prefill tokens-per-second display
---
README.md | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/README.md b/README.md
index 8d4a202..274e583 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,15 @@ rec serve
Keep this terminal open. When `flint is ready.` appears, connect a client from
another terminal.
+After each prompt finishes processing, the server logs prefill throughput:
+
+```text
+Prefill: prompt=74051 common_prefix=62433 reused=62433 new=11618 200 t/s
+```
+
+The rate is newly processed prompt tokens divided by prefill time. Fully cached
+prompts report `n/a t/s`. This measures prefill, rather than generated-token speed.
+
To use your own install of pi or OpenCode, run:
```bash
From 9f99b0c4d4ed8de1cba09f9f6b3d195841d4b5db Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 20:50:26 -0500
Subject: [PATCH 07/11] Assert deterministic prefill throughput output
---
engine/tests/test_prefix_rollback.py | 43 ++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/engine/tests/test_prefix_rollback.py b/engine/tests/test_prefix_rollback.py
index b259843..b3d6386 100644
--- a/engine/tests/test_prefix_rollback.py
+++ b/engine/tests/test_prefix_rollback.py
@@ -120,6 +120,49 @@ def test_generation_selects_prefix_once_before_cancelled_prefill(self):
self.assertIs(engine._prompt_cache, prefix)
self.assertEqual(prefix.tokens, list(range(8)))
+ @unittest.skipUnless(mx.metal.is_available(), "requires Apple Metal")
+ def test_completed_prefill_logs_tokens_per_second(self):
+ # Freeze the existing prefill timer: three uncached tokens / 0.5 s = 6 t/s.
+ # No decoding is needed to check the completed-prefill diagnostic.
+ for cached, elapsed, expected in (
+ (8, 0.5, "6 t/s"),
+ (11, 0.5, "n/a t/s"),
+ (8, 0.0, "n/a t/s"),
+ ):
+ with self.subTest(cached=cached, elapsed=elapsed):
+ session = SimpleNamespace(
+ prefill=lambda *args, **kwargs: mx.array([[0., 1.]]),
+ cached_tokens=cached,
+ common_prefix_tokens=9,
+ prompt_cache=SimpleNamespace(layers=[], checkpoint_bytes=0),
+ _state={"kv": SimpleNamespace(nbytes=0), "capacity": 0,
+ "growth_count": 0, "growth_seconds": 0.},
+ )
+ engine = Engine.__new__(Engine)
+ engine.mx = mx
+ engine.runtime = SimpleNamespace(
+ make_session=lambda **kwargs: session, metadata=[],
+ device_profile=SimpleNamespace(chip="M6"),
+ )
+ engine.context_limit = 32
+ engine.eos = {0}
+ engine.tokenizer = None
+ engine._prompt_cache = None
+ engine._prefix_preparation = None
+ with (
+ patch("rec_mlx.engine.StreamingText"),
+ patch("rec_mlx.engine.time.perf_counter",
+ side_effect=[10., 10.+elapsed, 10.+elapsed]),
+ self.assertLogs(level="INFO") as logs,
+ ):
+ list(engine._generate_tokens(list(range(11)), Sampling(max_tokens=1),
+ should_stop=lambda: True))
+ self.assertEqual(
+ logs.output,
+ [f"INFO:root:Prefill: prompt=11 common_prefix=9 reused={cached} "
+ f"new={11-cached} {expected}"],
+ )
+
if __name__ == "__main__":
unittest.main()
From d535e124624b4ca2e507e16b6a5456c543ce254b Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 20:53:56 -0500
Subject: [PATCH 08/11] Revert "Assert deterministic prefill throughput output"
This reverts commit 9f99b0c4d4ed8de1cba09f9f6b3d195841d4b5db.
---
engine/tests/test_prefix_rollback.py | 43 ----------------------------
1 file changed, 43 deletions(-)
diff --git a/engine/tests/test_prefix_rollback.py b/engine/tests/test_prefix_rollback.py
index b3d6386..b259843 100644
--- a/engine/tests/test_prefix_rollback.py
+++ b/engine/tests/test_prefix_rollback.py
@@ -120,49 +120,6 @@ def test_generation_selects_prefix_once_before_cancelled_prefill(self):
self.assertIs(engine._prompt_cache, prefix)
self.assertEqual(prefix.tokens, list(range(8)))
- @unittest.skipUnless(mx.metal.is_available(), "requires Apple Metal")
- def test_completed_prefill_logs_tokens_per_second(self):
- # Freeze the existing prefill timer: three uncached tokens / 0.5 s = 6 t/s.
- # No decoding is needed to check the completed-prefill diagnostic.
- for cached, elapsed, expected in (
- (8, 0.5, "6 t/s"),
- (11, 0.5, "n/a t/s"),
- (8, 0.0, "n/a t/s"),
- ):
- with self.subTest(cached=cached, elapsed=elapsed):
- session = SimpleNamespace(
- prefill=lambda *args, **kwargs: mx.array([[0., 1.]]),
- cached_tokens=cached,
- common_prefix_tokens=9,
- prompt_cache=SimpleNamespace(layers=[], checkpoint_bytes=0),
- _state={"kv": SimpleNamespace(nbytes=0), "capacity": 0,
- "growth_count": 0, "growth_seconds": 0.},
- )
- engine = Engine.__new__(Engine)
- engine.mx = mx
- engine.runtime = SimpleNamespace(
- make_session=lambda **kwargs: session, metadata=[],
- device_profile=SimpleNamespace(chip="M6"),
- )
- engine.context_limit = 32
- engine.eos = {0}
- engine.tokenizer = None
- engine._prompt_cache = None
- engine._prefix_preparation = None
- with (
- patch("rec_mlx.engine.StreamingText"),
- patch("rec_mlx.engine.time.perf_counter",
- side_effect=[10., 10.+elapsed, 10.+elapsed]),
- self.assertLogs(level="INFO") as logs,
- ):
- list(engine._generate_tokens(list(range(11)), Sampling(max_tokens=1),
- should_stop=lambda: True))
- self.assertEqual(
- logs.output,
- [f"INFO:root:Prefill: prompt=11 common_prefix=9 reused={cached} "
- f"new={11-cached} {expected}"],
- )
-
if __name__ == "__main__":
unittest.main()
From dfedc7f62f0d5c23437104905a9a82dad93d0ae1 Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Sat, 7 Nov 2026 20:56:22 -0500
Subject: [PATCH 09/11] Keep the original prefill assertion as a prefix check
---
engine/src/rec_mlx/engine.py | 8 ++++++++
engine/tests/test_generated_prefix.py | 3 +++
engine/tests/test_prefix_rollback.py | 8 +++++++-
3 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/engine/src/rec_mlx/engine.py b/engine/src/rec_mlx/engine.py
index f2901b0..da70433 100644
--- a/engine/src/rec_mlx/engine.py
+++ b/engine/src/rec_mlx/engine.py
@@ -252,6 +252,14 @@ def _generate_tokens(self, ids, sampling, *, should_stop, grammar=None, return_l
try:
logits = session.prefill(ids, prefix=prefix, should_stop=should_stop)
except PrefillCancelled:
+ # Retain the prefix diagnostic on cancellation; no completed rate exists.
+ logging.info(
+ "Prefill: prompt=%d common_prefix=%d reused=%d new=%d n/a t/s (cancelled)",
+ len(ids),
+ session.common_prefix_tokens,
+ session.cached_tokens,
+ len(ids) - session.cached_tokens,
+ )
if preparation is not None and prefix is preparation.cache:
# Keep the original input anchor until a new request completes prefill.
self._prompt_cache = preparation.origin
diff --git a/engine/tests/test_generated_prefix.py b/engine/tests/test_generated_prefix.py
index d0dd7cf..96eba22 100644
--- a/engine/tests/test_generated_prefix.py
+++ b/engine/tests/test_generated_prefix.py
@@ -135,6 +135,9 @@ def test_cancelled_prefill_keeps_original_anchor_for_edited_history(self):
prepared = engine._prefix_preparation.cache
class CancelledSession:
+ common_prefix_tokens = 0
+ cached_tokens = 0
+
def prefill(self, tokens, *, prefix, should_stop):
self.prompt_cache = prefix
raise PrefillCancelled()
diff --git a/engine/tests/test_prefix_rollback.py b/engine/tests/test_prefix_rollback.py
index b259843..1f9b625 100644
--- a/engine/tests/test_prefix_rollback.py
+++ b/engine/tests/test_prefix_rollback.py
@@ -110,13 +110,19 @@ def test_generation_selects_prefix_once_before_cancelled_prefill(self):
with (
patch("rec_mlx.prefix.select_prefix", wraps=select_prefix) as select,
patch("rec_mlx.quantized.select_prefix", select),
- self.assertNoLogs(level="INFO"),
+ self.assertLogs(level="INFO") as logs,
):
self.assertEqual(
list(engine.generate_tokens(tokens, settings, should_stop=lambda: True)), []
)
self.assertEqual(select.call_count, 1)
self.assertEqual(session.common_prefix_tokens, 9)
+ self.assertEqual(len(logs.output), 1)
+ self.assertTrue(
+ logs.output[0].startswith(
+ "INFO:root:Prefill: prompt=11 common_prefix=9 reused=8 new=3"
+ )
+ )
self.assertIs(engine._prompt_cache, prefix)
self.assertEqual(prefix.tokens, list(range(8)))
From aee98778b5b68275066c0412f3312098d0edb026 Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Mon, 9 Nov 2026 00:05:38 -0500
Subject: [PATCH 10/11] Add selectable oQ3e quant with native MLX prefix
caching
---
README.md | 32 ++++++++-
cli/cli.go | 4 ++
cli/help.go | 1 +
cli/manifest.go | 24 +++++++
cli/memory_test.go | 15 ++++
engine/src/rec_mlx/checkpoint.py | 20 ++++--
engine/src/rec_mlx/engine.py | 14 +++-
engine/src/rec_mlx/native_decode.py | 102 ++++++++++++++++++++++++++++
engine/src/rec_mlx/quantized.py | 19 ++++--
engine/tests/test_checkpoint.py | 16 +++++
engine/tests/test_native_decode.py | 50 ++++++++++++++
packaging/flint-oq3e-mlx.json | 64 +++++++++++++++++
packaging/package.py | 20 +++++-
tests/package_test.py | 6 +-
14 files changed, 373 insertions(+), 14 deletions(-)
create mode 100644 engine/src/rec_mlx/native_decode.py
create mode 100644 engine/tests/test_native_decode.py
create mode 100644 packaging/flint-oq3e-mlx.json
diff --git a/README.md b/README.md
index 274e583..f5752be 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ excluded.
On Macs with 32 GB (or less than 48 GB), packaged builds select the community
[Flint mixed 3/4-bit checkpoint](https://huggingface.co/mlx-community/flint-mixed-3-4bit)
-(17.8 GB), with a 131,072-token default context. Macs with 48 GB or more use the
+(17.8 GB), with a 262,144-token default context. Macs with 48 GB or more use the
official INT4 checkpoint. The mixed checkpoint is an unofficial community conversion.
Use `rec doctor` to see the selected revision and context.
@@ -160,3 +160,33 @@ Run `rec COMMAND --help` for a command's options.
The bundled runtime uses CPython 3.14.10, nanobind 3.1.0, and MLX built from
GitHub commit `fc344c986c4169d99f76b5d95ae6b4a58a359a63` (HEAD when upgraded).
+
+### Choose the Flint quantization
+
+This development binary includes two small-memory checkpoints:
+
+```sh
+rec serve --quant 3/4
+rec serve --quant oq3e
+```
+
+Stop the existing server before switching (`rec stop`). `--quant` also works
+with `rec pull` and `rec doctor`; `auto` preserves RAM-based selection. Explicit
+`--manifest` and explicit `--quant` are mutually exclusive.
+
+`3/4` selects `mlx-community/flint-mixed-3-4bit` with the existing optimized
+Rec kernels. `oq3e` selects pinned `mlx-community/flint-oQ3e` weights, preserving
+their per-module 3/6/8-bit affine quantization through native MLX operations.
+The oQ3e path reuses a matching prompt prefix using isolated native MLX KV
+snapshots and BF16 KV. It is not the resident fused decode path, so performance
+differs. Both small-memory profiles default to 262,144 total tokens. Override
+with `--context TOKENS`; the architectural maximum does not guarantee available RAM.
+
+Prefix reuse is automatic for oQ3e. A snapshot immediately before the previous
+prompt's final token can be reused when it matches the beginning of the next
+request. Different prefixes recompute safely; this is a single in-memory prefix,
+not a disk cache or arbitrary branching cache. Decode runs on separate cache
+objects, including sliding-window offsets, so it cannot mutate the saved prefix.
+The log and API usage report reused/cached prompt tokens. On one 252-token M6
+smoke prompt, reuse was 251 tokens: 0.029 s prefill versus 0.255 s uncached after
+warm-up. Deterministic cached and uncached output token IDs matched exactly.
diff --git a/cli/cli.go b/cli/cli.go
index 49e42a6..cbf01e4 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -90,6 +90,7 @@ func run(args []string) error {
}
flags := flag.NewFlagSet(command, flag.ContinueOnError)
manifest := flags.String("manifest", "", "")
+ quant := flags.String("quant", "auto", "")
runtimeDir := flags.String("runtime-dir", "", "")
var contextTokens int
port := 11500
@@ -108,6 +109,9 @@ func run(args []string) error {
return fmt.Errorf("port must be between 1 and 65535")
}
m, e := loadManifest(*manifest)
+ if e == nil {
+ m, e = selectQuant(m, *quant, *manifest)
+ }
if e == nil && command == "serve" {
flags.Visit(func(f *flag.Flag) {
if f.Name == "context" {
diff --git a/cli/help.go b/cli/help.go
index 2bc4474..5079caf 100644
--- a/cli/help.go
+++ b/cli/help.go
@@ -15,6 +15,7 @@ type commandHelp struct {
}
var developmentOptions = [][2]string{
+ {"--quant PROFILE", "Checkpoint: auto, 3/4, or oq3e (native MLX)"},
{"--manifest FILE", "Use this manifest instead of the bundled one"},
{"--runtime-dir DIR", "Use this runtime instead of the bundled one; needs --manifest"},
}
diff --git a/cli/manifest.go b/cli/manifest.go
index 8de616e..3314af4 100644
--- a/cli/manifest.go
+++ b/cli/manifest.go
@@ -215,3 +215,27 @@ func verifyFileInto(path string, f File, progress io.Writer) error {
}
return nil
}
+
+// Explicit profiles share the packaged engine but retain separate model hashes.
+func selectQuant(current Manifest, quant, manifest string) (Manifest, error) {
+ if quant == "auto" {
+ return current, nil
+ }
+ if manifest != "" {
+ return Manifest{}, fmt.Errorf("--quant cannot be combined with --manifest")
+ }
+ name := ""
+ switch quant {
+ case "3/4", "mixed-3-4":
+ name = "manifest-low-memory.json"
+ case "oq3e":
+ name = "manifest-oq3e.json"
+ default:
+ return Manifest{}, fmt.Errorf("unknown quant %q; choose auto, 3/4, or oq3e", quant)
+ }
+ data, err := assets.ReadFile("assets/" + name)
+ if err != nil {
+ return Manifest{}, fmt.Errorf("quant %s is not included in this binary; rebuild the package", quant)
+ }
+ return decodeManifest(data)
+}
diff --git a/cli/memory_test.go b/cli/memory_test.go
index 26cab2f..28d7327 100644
--- a/cli/memory_test.go
+++ b/cli/memory_test.go
@@ -63,3 +63,18 @@ func TestEmbeddedLowMemoryManifest(t *testing.T) {
t.Fatalf("invalid low-memory profile: %+v", low)
}
}
+
+func TestQuantSelectionErrors(t *testing.T) {
+ for _, quant := range []string{"bad", "4bit"} {
+ if _, err := selectQuant(Manifest{}, quant, ""); err == nil {
+ t.Fatalf("accepted %q", quant)
+ }
+ }
+ if _, err := selectQuant(Manifest{}, "oq3e", "custom.json"); err == nil {
+ t.Fatal("accepted conflicting manifest")
+ }
+ m, err := selectQuant(Manifest{Revision: "test"}, "auto", "custom.json")
+ if err != nil || m.Revision != "test" {
+ t.Fatal("auto changed explicit manifest")
+ }
+}
diff --git a/engine/src/rec_mlx/checkpoint.py b/engine/src/rec_mlx/checkpoint.py
index 16e0757..bc41b5b 100644
--- a/engine/src/rec_mlx/checkpoint.py
+++ b/engine/src/rec_mlx/checkpoint.py
@@ -10,8 +10,8 @@ def validate(directory: Path, manifest_path: Path) -> dict:
if manifest_path.name == "config.json":
index = json.loads((directory / "model.safetensors.index.json").read_text())
manifest = {
- "model_id": "flint-mixed-3-4bit",
- "weight_format": "mlx-affine-int3-int4",
+ "model_id": "flint-oq3e" if "mlx_export" not in config else "flint-mixed-3-4bit",
+ "weight_format": "mlx-affine-oq3e" if "mlx_export" not in config else "mlx-affine-int3-int4",
"kv_format": "bf16",
"max_context": config["max_position_embeddings"],
"checkpoint_files": {
@@ -20,17 +20,19 @@ def validate(directory: Path, manifest_path: Path) -> dict:
"config.json",
"tokenizer.json",
"tokenizer_config.json",
- "special_tokens_map.json",
+ *( ["special_tokens_map.json"] if (directory / "special_tokens_map.json").exists() or "mlx_export" in config else [] ),
"model.safetensors.index.json",
*index["weight_map"].values(),
}
},
}
- formats = {"mlx-affine-int4": 4, "mlx-affine-int3-int4": 3}
+ formats = {"mlx-affine-oq3e": 3, "mlx-affine-int4": 4, "mlx-affine-int3-int4": 3}
if manifest["weight_format"] not in formats or manifest["kv_format"] != "bf16":
raise ValueError("This engine requires affine INT4 or mixed INT3/INT4 weights and BF16 KV")
files = manifest["checkpoint_files"]
- required = {"config.json", "tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"}
+ required = {"config.json", "tokenizer.json", "tokenizer_config.json"}
+ if manifest["weight_format"] != "mlx-affine-oq3e":
+ required.add("special_tokens_map.json")
shards = {name for name in files if name.startswith("model-") and name.endswith(".safetensors")}
if not required.issubset(files) or not shards:
raise ValueError("Manifest must include configuration, tokenizer and weight shards")
@@ -45,6 +47,14 @@ def validate(directory: Path, manifest_path: Path) -> dict:
raise ValueError(f"Checkpoint is missing a manifest file: {name}")
if config["sliding_window"] != 513:
raise ValueError("Flint requires sliding_window=513, including the current token")
+ if manifest["weight_format"] == "mlx-affine-oq3e":
+ q = config["quantization"]
+ if (q.get("bits"), q.get("group_size"), q.get("mode")) != (3, 64, "affine"):
+ raise ValueError("oQ3e requires group-64 affine 3-bit defaults")
+ for name, spec in q.items():
+ if isinstance(spec, dict) and (spec.get("bits") not in (3, 6, 8) or spec.get("group_size") not in (64, 128) or spec.get("mode", "affine") != "affine"):
+ raise ValueError(f"Unsupported oQ3e tensor quantization: {name}")
+ return manifest
export = config["mlx_export"]
expert_bits = formats[manifest["weight_format"]]
if (export["bits"], export["routed_expert_bits"], export["embedding_bits"]) != (
diff --git a/engine/src/rec_mlx/engine.py b/engine/src/rec_mlx/engine.py
index da70433..7a7a654 100644
--- a/engine/src/rec_mlx/engine.py
+++ b/engine/src/rec_mlx/engine.py
@@ -88,7 +88,13 @@ def __init__(
mx.set_cache_limit(0)
mx.clear_cache()
mx.reset_peak_memory()
- self.runtime, self.resident_sampler = build_decoder(QATModel(directory), optimized=True)
+ self.native_decode = self.identity["weight_format"] == "mlx-affine-oq3e"
+ if self.native_decode:
+ from types import SimpleNamespace
+ self.runtime = SimpleNamespace(model=QATModel(directory), prefill_chunk_size=512, device_profile=profile)
+ self.resident_sampler = None
+ else:
+ self.runtime, self.resident_sampler = build_decoder(QATModel(directory), optimized=True)
# Packing replaces native weights with lazy views. Materialize them on their creation
# stream before HTTP threads use the native prefill model.
mx.eval(self.runtime.model.native.parameters())
@@ -157,7 +163,11 @@ def generate_tokens(
try:
with grammar.session() if grammar is not None else nullcontext(None) as constraint:
- yield from self._generate_tokens(
+ decoder = self._generate_tokens
+ if getattr(self, "native_decode", False):
+ from .native_decode import generate
+ decoder = lambda *a, **kw: generate(self, *a, **kw)
+ yield from decoder(
ids,
sampling,
should_stop=should_stop,
diff --git a/engine/src/rec_mlx/native_decode.py b/engine/src/rec_mlx/native_decode.py
new file mode 100644
index 0000000..8d1df8e
--- /dev/null
+++ b/engine/src/rec_mlx/native_decode.py
@@ -0,0 +1,102 @@
+"""Native MLX decoder for oQ3e's per-module 3/6/8-bit affine layout.
+
+No weight requantization and no fused INT4 projection assumptions. Prefixes
+are retained as isolated snapshots on this experimental path; KV stays BF16.
+"""
+import logging
+import time
+from mlx_lm.models.cache import KVCache, RotatingKVCache
+from mlx_lm.sample_utils import make_sampler
+from .detokenize import StreamingText
+
+
+def clone_cache(cache, mx):
+ """Copy array handles and metadata so mutable cache objects are never shared."""
+ result = []
+ for entry in cache:
+ if entry.empty():
+ result.append(RotatingKVCache(entry.max_size, entry.keep) if isinstance(entry, RotatingKVCache) else KVCache())
+ else:
+ result.append(type(entry).from_state(tuple(mx.array(x) for x in entry.state), entry.meta_state))
+ mx.eval([entry.state for entry in result if not entry.empty()])
+ return result
+
+
+def generate(engine, ids, sampling, *, should_stop, grammar, return_logprobs):
+ from .engine import ContextLimitError
+ if len(ids) + sampling.max_tokens > engine.context_limit:
+ raise ContextLimitError("Prompt plus output allowance exceeds the configured context limit")
+ mx = engine.mx
+ model = engine.runtime.model.native
+ cache = [KVCache() if kind == "full_attention" else RotatingKVCache(max_size=513, keep=0)
+ for kind in model.model.layer_types]
+ reused = 0
+ saved = getattr(engine, "_native_prefix", None)
+ if saved and len(saved[0]) < len(ids) and ids[:len(saved[0])] == saved[0]:
+ reused = len(saved[0])
+ cache = clone_cache(saved[1], mx)
+ sampler = make_sampler(temp=sampling.temperature, top_p=sampling.top_p, top_k=sampling.top_k)
+ mx.random.seed(sampling.seed)
+ start = time.perf_counter()
+ generated, logprobs = [], []
+ stream = StreamingText(engine.tokenizer)
+ state = {}
+ mask = mx.zeros(model.args.vocab_size, mx.float32)
+ first = None
+ reason = "length"
+ try:
+ # Stop one token before the end. That stable prefix remains valid when
+ # the next request extends this prompt with generated tool calls/results.
+ anchor = len(ids) - 1
+ for begin in range(reused, anchor, 512):
+ if should_stop(): return
+ logits = model(mx.array([ids[begin:min(begin+512, anchor)]]), cache)[:, -1, :]
+ mx.eval(logits, [entry.state for entry in cache])
+ if should_stop(): return
+ snapshot = (list(ids[:anchor]), clone_cache(cache, mx)) if anchor else None
+ logits = model(mx.array([ids[-1:]]), cache)[:, -1, :]
+ mx.eval(logits, [entry.state for entry in cache])
+ engine._native_prefix = snapshot
+ prefill_end = time.perf_counter()
+ logging.info("Prefill: prompt=%d common_prefix=%d reused=%d new=%d %.0f t/s (native oQ3e)",
+ len(ids), reused, reused, len(ids)-reused, (len(ids)-reused)/max(prefill_end-start, 1e-9))
+ for index in range(sampling.max_tokens):
+ if should_stop(): reason = "stop"; break
+ flat = logits.reshape(-1)
+ if sampling.presence_penalty: flat = flat - sampling.presence_penalty * mask
+ if grammar is not None: flat = grammar.first_logits(flat)
+ probabilities = flat - mx.logsumexp(flat)
+ token = int(sampler(probabilities[None]).item())
+ if first is None: first = time.perf_counter()
+ generated.append(token)
+ logprobs.append(float(probabilities[token].item()))
+ mask[token] = 1
+ if grammar is not None:
+ grammar.accept_first(token, state)
+ grammar.initial_mask = state["grammar_mask"]
+ stops = engine.eos if grammar is None else set(grammar.stops)
+ if token in stops:
+ reason = "stop"
+ yield {"token": token, "text": stream.finish(generated[:-1]), "finish_reason": reason}
+ break
+ delta = stream.add_token(token)
+ if index+1 == sampling.max_tokens: delta += stream.finish(generated)
+ yield {"token": token, "text": delta, "finish_reason": "length" if index+1 == sampling.max_tokens else None}
+ if index+1 < sampling.max_tokens:
+ logits = model(mx.array([[token]]), cache)[:, -1, :]
+ mx.eval(logits, [entry.state for entry in cache])
+ end = time.perf_counter()
+ decode = end - (first or end)
+ yield {"finish_reason": reason, "metrics": {
+ "prompt_tokens": len(ids), "cached_prompt_tokens": reused, "common_prefix_tokens": reused,
+ "prefilled_tokens": len(ids)-reused, "generated_tokens": len(generated), "token_ids": generated,
+ "prefill_seconds": prefill_end-start, "ttft_seconds": first-start if first else None,
+ "request_seconds": end-start, "decode_seconds": decode,
+ "decode_tokens_per_second": max(0,len(generated)-1)/decode if decode else 0,
+ "decode_strategy": "native_mlx_oq3e", "peak_mlx_bytes": mx.get_peak_memory(),
+ **({"token_logprobs":logprobs} if return_logprobs else {}),
+ **(grammar.metrics() if grammar is not None else {})}}
+ finally:
+ mx.synchronize()
+ del cache
+ mx.clear_cache()
diff --git a/engine/src/rec_mlx/quantized.py b/engine/src/rec_mlx/quantized.py
index a204421..85c8cfe 100644
--- a/engine/src/rec_mlx/quantized.py
+++ b/engine/src/rec_mlx/quantized.py
@@ -18,14 +18,19 @@ class QATModel:
def __init__(self, directory):
self.config = json.loads((directory / "config.json").read_text())
- export = self.config["mlx_export"]
- assert export["bits"] == 4 and export["routed_expert_bits"] in (3, 4)
- assert export["embedding_bits"] == 8
+ export = self.config.get("mlx_export")
+ oq = export is None
+ assert oq or (export["bits"] == 4 and export["routed_expert_bits"] in (3, 4))
+ assert oq or export["embedding_bits"] == 8
native = Model(ModelArgs.from_dict(self.config))
def quantization(path, module):
if not hasattr(module, "to_quantized"):
return False
+ if oq:
+ q = self.config["quantization"]
+ spec = q.get("language_model." + path, q.get(path, q))
+ return {"group_size": spec["group_size"], "bits": spec["bits"], "mode": "affine"}
bits = 4
if path == "model.embed_tokens":
bits = 8
@@ -38,7 +43,13 @@ def quantization(path, module):
for path in sorted(directory.glob("model-*.safetensors")):
shard = mx.load(str(path))
assert not weights.keys() & shard.keys(), f"Duplicate tensor in {path}"
- weights.update(shard)
+ for key, value in shard.items():
+ key = key.removeprefix("language_model.")
+ if oq and key.endswith(".mlp.expert_bias"):
+ key = key.removesuffix(".expert_bias") + ".gate.expert_bias"
+ if key in weights:
+ raise ValueError(f"Duplicate normalized tensor: {key}")
+ weights[key] = value
native.load_weights(list(native.sanitize(weights).items()), strict=True)
native.eval()
mx.eval(native.parameters())
diff --git a/engine/tests/test_checkpoint.py b/engine/tests/test_checkpoint.py
index 980cee0..ecc02c6 100644
--- a/engine/tests/test_checkpoint.py
+++ b/engine/tests/test_checkpoint.py
@@ -87,6 +87,22 @@ def test_mixed_rejects_wrong_layout(self):
with self.assertRaises(ValueError):
validate(directory, profile)
+ def test_oq3e_layout(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ config, profile = self.fixture(directory, 3)
+ config.pop("mlx_export")
+ config["quantization"] = {"group_size": 64, "bits": 3, "mode": "affine",
+ "language_model.model.layers.0.self_attn.q_proj": {"bits": 6, "group_size": 64},
+ "language_model.model.embed_tokens": {"bits": 8, "group_size": 64}}
+ config["quantization_config"] = config["quantization"]
+ profile.write_text(json.dumps(config))
+ (directory/"special_tokens_map.json").unlink()
+ self.assertEqual(validate(directory, profile)["weight_format"], "mlx-affine-oq3e")
+ config["quantization"]["language_model.model.embed_tokens"]["bits"] = 7
+ profile.write_text(json.dumps(config))
+ with self.assertRaises(ValueError): validate(directory, profile)
+
def test_extra_shard_rejected(self):
# An unindexed shard can indicate a stale or accidentally combined checkpoint.
with tempfile.TemporaryDirectory() as temporary:
diff --git a/engine/tests/test_native_decode.py b/engine/tests/test_native_decode.py
new file mode 100644
index 0000000..909972b
--- /dev/null
+++ b/engine/tests/test_native_decode.py
@@ -0,0 +1,50 @@
+from types import SimpleNamespace
+import mlx.core as mx
+from tokenizers import Tokenizer
+from tokenizers.models import WordLevel
+from transformers import PreTrainedTokenizerFast
+from rec_mlx.engine import Sampling, ContextLimitError
+from rec_mlx.native_decode import generate
+import pytest
+
+class TinyModel:
+ args = SimpleNamespace(vocab_size=4)
+ model = SimpleNamespace(layer_types=[])
+ def __init__(self): self.calls=0
+ def __call__(self, tokens, cache):
+ self.calls+=1
+ token=2 if self.calls==1 else 3
+ return mx.array([[[-100.,-100.,100. if token==2 else -100.,100. if token==3 else -100.]]])
+
+def engine():
+ tokenizer=PreTrainedTokenizerFast(tokenizer_object=Tokenizer(WordLevel({'[UNK]':0,'a':1,'b':2,'[EOS]':3},unk_token='[UNK]')))
+ return SimpleNamespace(mx=mx,runtime=SimpleNamespace(model=SimpleNamespace(native=TinyModel())),tokenizer=tokenizer,eos={3},context_limit=8)
+
+def test_native_stream_stops_and_reports_metrics():
+ events=list(generate(engine(),[1],Sampling(temperature=0,max_tokens=3),should_stop=lambda:False,grammar=None,return_logprobs=True))
+ assert [e['token'] for e in events if 'token' in e]==[2,3]
+ assert events[-1]['metrics']['decode_strategy']=='native_mlx_oq3e'
+ assert events[-1]['metrics']['cached_prompt_tokens']==0
+ assert len(events[-1]['metrics']['token_logprobs'])==2
+ assert ''.join(e.get('text','') for e in events)=='b'
+
+def test_native_context_and_cancellation():
+ with pytest.raises(ContextLimitError):
+ list(generate(engine(),[1]*7,Sampling(max_tokens=2),should_stop=lambda:False,grammar=None,return_logprobs=False))
+ assert list(generate(engine(),[1],Sampling(max_tokens=2),should_stop=lambda:True,grammar=None,return_logprobs=False))==[]
+
+def test_cache_snapshot_isolation_after_rotating_wrap():
+ from rec_mlx.native_decode import clone_cache
+ from mlx_lm.models.cache import KVCache, RotatingKVCache
+ caches=[KVCache(), RotatingKVCache(max_size=4,keep=0)]
+ for cache in caches:
+ for i in range(7): cache.update_and_fetch(mx.full((1,1,1,2),i),mx.full((1,1,1,2),-i))
+ snapshot=clone_cache(caches,mx)
+ before=[tuple(mx.array(x) for x in c.state) for c in snapshot]
+ metadata=[c.meta_state for c in snapshot]
+ for cache in caches:cache.update_and_fetch(mx.full((1,1,1,2),99),mx.full((1,1,1,2),99))
+ mx.eval([c.state for c in caches])
+ for saved, arrays, meta in zip(snapshot,before,metadata):
+ assert saved.meta_state==meta
+ assert all(bool(mx.array_equal(a,b).item()) for a,b in zip(saved.state,arrays))
+ assert saved.offset==7
diff --git a/packaging/flint-oq3e-mlx.json b/packaging/flint-oq3e-mlx.json
new file mode 100644
index 0000000..0d83d34
--- /dev/null
+++ b/packaging/flint-oq3e-mlx.json
@@ -0,0 +1,64 @@
+{
+ "schema": 1,
+ "model": "flint",
+ "revision": "flint-oq3e",
+ "development": false,
+ "engine": "mlx",
+ "os": "darwin",
+ "arch": "arm64",
+ "backend": "metal",
+ "context": 262144,
+ "max_context": 262144,
+ "min_ram_bytes": 34359738368,
+ "entry": "config.json",
+ "files": [
+ {
+ "name": "config.json",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/config.json",
+ "size": 93877,
+ "sha256": "2c81e0122299a82877056780577f8cc4a52196ea644dd9ff2e61957042646ff1"
+ },
+ {
+ "name": "model-00001-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/model-00001-of-00004.safetensors",
+ "size": 5051387989,
+ "sha256": "f0f097583b1d7c0a301ed53296b42684fc04ac9b846430d6e6633b699ffb7283"
+ },
+ {
+ "name": "model-00002-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/model-00002-of-00004.safetensors",
+ "size": 5049969669,
+ "sha256": "3a47671453c44b4a36ff2e3ab32638e523ceab283f40e89a95d4f8855080c247"
+ },
+ {
+ "name": "model-00003-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/model-00003-of-00004.safetensors",
+ "size": 5049969054,
+ "sha256": "1bd14adf5f75432a879f8a2d7eafee25e9e63ca3415efbbfb3998ab53b1ec2ed"
+ },
+ {
+ "name": "model-00004-of-00004.safetensors",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/model-00004-of-00004.safetensors",
+ "size": 3288352472,
+ "sha256": "80f96478bc89207ebb5bbada7268995f684105317188b00ca3c8559fb969837e"
+ },
+ {
+ "name": "model.safetensors.index.json",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/model.safetensors.index.json",
+ "size": 182839,
+ "sha256": "2b5ae25fe3c6739fd18b04bf403f3dbe39dfc53bc2bf6e092d522e41dc92389e"
+ },
+ {
+ "name": "tokenizer.json",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/tokenizer.json",
+ "size": 10879128,
+ "sha256": "a263cce0b7e2b03f7ee07ce809783ef4b47f160c3f268a4ea1524f638da45b68"
+ },
+ {
+ "name": "tokenizer_config.json",
+ "url": "https://huggingface.co/mlx-community/flint-oQ3e/resolve/274084481e0cafc1850408b315bb3375ec46a280/tokenizer_config.json",
+ "size": 30439,
+ "sha256": "159ac88217e90ffd0a4a294726048bb302829e82e1958dc85bb203aa79138fbf"
+ }
+ ]
+}
diff --git a/packaging/package.py b/packaging/package.py
index a39484a..ac10ce2 100644
--- a/packaging/package.py
+++ b/packaging/package.py
@@ -73,8 +73,15 @@ def main():
default="0.1.0-dev",
help="version reported by rec --version (default: %(default)s)",
)
+ p.add_argument("--oq3e-manifest", type=Path, help="oQ3e checkpoint descriptor")
p.add_argument("--output", type=Path, required=True, help="path of the rec executable to write")
a = p.parse_args()
+ # Existing release commands already select the small-memory descriptor.
+ # Include its sibling oQ3e profile without requiring workflow changes.
+ if a.oq3e_manifest is None and a.low_memory_manifest:
+ sibling = a.low_memory_manifest.with_name("flint-oq3e-mlx.json")
+ if sibling.is_file():
+ a.oq3e_manifest = sibling
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", a.version):
p.error("invalid version")
try:
@@ -104,6 +111,13 @@ def main():
(assets / "runtime.tar.gz").write_bytes(archive)
if low is not None:
(assets / "manifest-low-memory.json").write_text(json.dumps(low, indent=2) + "\n")
+ if a.oq3e_manifest:
+ oq = json.loads(a.oq3e_manifest.read_text())
+ for key in ("engine", "os", "arch", "backend", "model"):
+ if oq[key] != m[key]: p.error(f"oQ3e manifest disagrees on {key}")
+ oq["runtime_files"] = m["runtime_files"]
+ oq["runtime_sha256"] = m["runtime_sha256"]
+ (assets / "manifest-oq3e.json").write_text(json.dumps(oq, indent=2)+"\n")
subprocess.run(["go", "test", "./cli", "-run", "TestEmbedded"], cwd=build, check=True)
env = dict(os.environ, CGO_ENABLED="0", GOOS=m["os"], GOARCH=m["arch"])
staged = build / "rec"
@@ -122,7 +136,11 @@ def main():
env=env,
check=True,
)
- shutil.copy2(staged, output)
+ # Replace the inode atomically: overwriting a running Mach-O can leave
+ # macOS with stale code-signature pages and cause SIGKILL on launch.
+ replacement = output.with_name(output.name + ".new")
+ shutil.copy2(staged, replacement)
+ os.replace(replacement, output)
output.chmod(0o755)
data = output.read_bytes()
output.with_name(output.name + ".meta").write_text(f"{sha(data)} {len(data)}\n")
diff --git a/tests/package_test.py b/tests/package_test.py
index 87d14b5..df811cb 100644
--- a/tests/package_test.py
+++ b/tests/package_test.py
@@ -46,7 +46,7 @@
)
descriptor = json.loads(output.with_name(output.name + ".manifest.json").read_text())
low = json.loads(output.with_name(output.name + ".low-memory.manifest.json").read_text())
- assert low["entry"] == "config.json" and low["context"] == 131072
+ assert low["entry"] == "config.json" and low["context"] == 262144
assert low["runtime_sha256"] == descriptor["runtime_sha256"]
assert low["runtime_files"] == descriptor["runtime_files"]
assert {f["name"] for f in descriptor["runtime_files"]} == {
@@ -66,6 +66,10 @@
result = subprocess.run([str(output), "doctor"], text=True, capture_output=True)
# CI Macs may fail the hardware requirement after the profile is reported.
assert re.search(r"Engine +mlx \(metal\)", result.stdout), result.stdout + result.stderr
+ for quant, revision in (("3/4", "flint-mixed-3-4bit"), ("oq3e", "flint-oq3e")):
+ selected = subprocess.run([str(output), "doctor", "--quant", quant], text=True, capture_output=True)
+ assert revision in selected.stdout, selected.stdout + selected.stderr
+ assert "262,144" in selected.stdout, selected.stdout + selected.stderr
assert (
subprocess.check_output([str(output), "--version"], text=True).strip()
== "rec 0.1.0-test"
From f42401f43f72383a7a52243df0a0a52593d9ed08 Mon Sep 17 00:00:00 2001
From: keith siilats
Date: Mon, 9 Nov 2026 11:28:25 -0500
Subject: [PATCH 11/11] Report prefix-cache reads in Anthropic Messages usage
---
engine/src/rec_mlx/anthropic.py | 39 +++++++++++++++++++++++-------
engine/tests/test_anthropic.py | 43 ++++++++++++++++++++++++++++++++-
engine/tests/test_serving.py | 5 +++-
3 files changed, 76 insertions(+), 11 deletions(-)
diff --git a/engine/src/rec_mlx/anthropic.py b/engine/src/rec_mlx/anthropic.py
index bb0c60d..31eec64 100644
--- a/engine/src/rec_mlx/anthropic.py
+++ b/engine/src/rec_mlx/anthropic.py
@@ -65,6 +65,24 @@ def messages_to_chat(body):
return chat, set(body) - _MAPPED, _include_thinking(body.get("thinking"))
+def _message_usage(usage):
+ """Split Chat's inclusive prompt count into new input and actual KV reuse.
+
+ Example: prompt_tokens=100, cached_tokens=80 means 20 new input tokens
+ and 80 cache reads. Rec has automatic local prefix reuse, not Anthropic's
+ billed cache-creation tiers, so cache_creation_input_tokens stays zero.
+ """
+ total = usage.get("prompt_tokens", 0)
+ cached = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) or 0
+ cached = max(0, min(total, cached))
+ return {
+ "input_tokens": total - cached,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": cached,
+ "output_tokens": usage.get("completion_tokens", 0),
+ }
+
+
def chat_to_message(response, *, stop_sequences=(), include_thinking=True):
choice = response["choices"][0]
message = choice.get("message") or {}
@@ -80,10 +98,7 @@ def chat_to_message(response, *, stop_sequences=(), include_thinking=True):
"content": _blocks(message, include_thinking),
"stop_reason": stop_reason,
"stop_sequence": stop_sequence,
- "usage": {
- "input_tokens": usage.get("prompt_tokens", 0),
- "output_tokens": usage.get("completion_tokens", 0),
- },
+ "usage": _message_usage(usage),
}
@@ -115,6 +130,7 @@ def __init__(
self.raw = raw
self.status = status
self.input_tokens = input_tokens
+ self.usage = {"prompt_tokens": input_tokens, "completion_tokens": 0}
self.stop_sequences = tuple(stop_sequences)
self.include_thinking = include_thinking
self.pending = b""
@@ -177,9 +193,11 @@ def _chunk(self, chunk):
self.message_id = _message_id(chunk.get("id"))
self.model = chunk.get("model", self.model)
usage = chunk.get("usage")
- if usage and not chunk.get("choices"):
- self.output_tokens = usage.get("completion_tokens", 0)
- return
+ if usage:
+ self.usage.update(usage)
+ self.output_tokens = usage.get("completion_tokens", self.output_tokens)
+ if not chunk.get("choices"):
+ return
choices = chunk.get("choices") or []
if not choices:
return
@@ -252,7 +270,7 @@ def _start(self):
"model": self.model,
"stop_reason": None,
"stop_sequence": None,
- "usage": {"input_tokens": self.input_tokens, "output_tokens": 0},
+ "usage": _message_usage(self.usage),
},
},
)
@@ -269,7 +287,10 @@ def _finish(self):
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": stop_sequence},
- "usage": {"output_tokens": self.output_tokens},
+ # Cache reuse is only known when mlx-lm emits final Chat usage.
+ # Send the complete cumulative split so SDKs replace the
+ # provisional message_start counts rather than double count.
+ "usage": _message_usage(self.usage),
},
)
self._emit("message_stop", {"type": "message_stop"})
diff --git a/engine/tests/test_anthropic.py b/engine/tests/test_anthropic.py
index 8132746..2c9c5a0 100644
--- a/engine/tests/test_anthropic.py
+++ b/engine/tests/test_anthropic.py
@@ -259,7 +259,15 @@ def test_maps_text_thinking_tool_use_and_stop_reasons(self):
)
self.assertEqual(message["id"], "msg_abc")
self.assertEqual(message["stop_reason"], "tool_use")
- self.assertEqual(message["usage"], {"input_tokens": 3, "output_tokens": 5})
+ self.assertEqual(
+ message["usage"],
+ {
+ "input_tokens": 3,
+ "output_tokens": 5,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ },
+ )
self.assertEqual(
message["content"][0], {"type": "thinking", "thinking": "Because", "signature": ""}
)
@@ -281,6 +289,39 @@ def test_maps_text_thinking_tool_use_and_stop_reasons(self):
self.assertEqual(hidden["stop_reason"], "stop_sequence")
self.assertEqual(hidden["stop_sequence"], "here")
+ def test_cache_usage_preserves_reuse_without_double_counting(self):
+ usage = {
+ "prompt_tokens": 100,
+ "completion_tokens": 6,
+ "prompt_tokens_details": {"cached_tokens": 80},
+ }
+ message = chat_to_message({"choices": [{"message": {"content": "OK"}}], "usage": usage})
+ expected = {
+ "input_tokens": 20,
+ "cache_read_input_tokens": 80,
+ "cache_creation_input_tokens": 0,
+ "output_tokens": 6,
+ }
+ self.assertEqual(message["usage"], expected)
+ raw = io.BytesIO()
+ stream = MessageEventStream(raw, input_tokens=100)
+ for chunk in [
+ {"choices": [{"delta": {"content": "OK"}, "finish_reason": "stop"}]},
+ {"choices": [], "usage": usage},
+ ]:
+ stream.write(f"data: {json.dumps(chunk)}\n\n".encode())
+ stream.write(b"data: [DONE]\n\n")
+ events = [
+ json.loads(line[6:])
+ for line in raw.getvalue().decode().splitlines()
+ if line.startswith("data: ")
+ ]
+ final = next(e for e in events if e["type"] == "message_delta")
+ self.assertEqual(final["usage"], expected)
+ self.assertEqual(
+ final["usage"]["input_tokens"] + final["usage"]["cache_read_input_tokens"], 100
+ )
+
def test_stream_emits_the_messages_event_sequence(self):
raw = io.BytesIO()
stream = MessageEventStream(raw, input_tokens=4, stop_sequences=["END"])
diff --git a/engine/tests/test_serving.py b/engine/tests/test_serving.py
index f3d9cbf..a323bef 100644
--- a/engine/tests/test_serving.py
+++ b/engine/tests/test_serving.py
@@ -408,7 +408,8 @@ def prompt(messages, tools=None):
self.assertEqual(message["content"], [{"type": "text", "text": "x"}])
self.assertEqual(message["stop_reason"], "max_tokens")
self.assertEqual(message["usage"]["output_tokens"], 1)
- self.assertEqual(message["usage"]["input_tokens"], 1)
+ self.assertEqual(message["usage"]["input_tokens"], 0)
+ self.assertEqual(message["usage"]["cache_read_input_tokens"], 1)
self.assertEqual(
logs.output, ["WARNING:root:Ignoring unsupported request field 'service_tier'"]
)
@@ -434,6 +435,8 @@ def test_messages_stream_uses_the_event_sequence(self):
delta = json.loads(raw.split("event: message_delta\ndata: ", 1)[1].split("\n", 1)[0])
self.assertEqual(delta["delta"]["stop_reason"], "max_tokens")
self.assertEqual(delta["usage"]["output_tokens"], 2)
+ self.assertEqual(delta["usage"]["input_tokens"], 0)
+ self.assertEqual(delta["usage"]["cache_read_input_tokens"], 1)
def test_messages_translates_errors_raised_before_generation(self):
first = self.pool.submit(self.request, "A", max_tokens=2)