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)