Skip to content

Commit 9be85b8

Browse files
committed
Add inference quantization helper script
1 parent 68e31cc commit 9be85b8

6 files changed

Lines changed: 383 additions & 1 deletion

File tree

docs/inference.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ GB10/CUDA parity are separate tasks.
1616
| Vanilla speculative decode | `generate_tokens_speculative(` | Eager batch=1 draft-window verifier with Leviathan-style acceptance/rejection | target model, draft model, `draft_window`, sampling knobs | No KV/paged speculative serving |
1717
| MTP self-speculative decode | `generate_tokens_mtp_self_speculative(` | Eager batch=1 path using attached `mtp_head` as draft source | `draft_window`, trained MTP depth, sampling knobs | No EAGLE-2/token-recycling claim |
1818
| Local token-id API serving | `create_local_generation_app(` | Optional FastAPI app exposing `/health` and `/generate` over token IDs | caller-owned model, generation options, `model_kwargs_builder`, optional decoder | not an OpenAI-compatible API |
19+
| Inference quantization manifest | `scripts/quantize_for_inference.py` | Local q4 helper manifest over repo-local inference quantization primitives | preset, q4 bits/group size, KV-q4 bits/group size, forward check | not a full checkpoint converter |
1920
| q4 quality smoke | `scripts/bench_inference_quality.py` | Built-in ARC/MMLU/HumanEval-style token-id smoke harness over q4 linears | `--tasks-jsonl`, suites, q4 bits/group size | not a real ARC/MMLU/HumanEval leaderboard run |
2021
| KV-q4 long-context smoke | `scripts/bench_inference_long_context.py` | Built-in NIAH/RULER-style token-id smoke harness over `QuantizedKVCache` | `--context-tokens`, suites, `--kv-bits`, `--kv-group-size`, `--quantized-kv-start` | not a real NIAH/RULER leaderboard run |
2122

@@ -85,6 +86,13 @@ fleet endpoint, and not model-integrated paged attention.
8586

8687
## Benchmarks
8788

89+
`scripts/quantize_for_inference.py` is an inference-only helper that builds a
90+
small local preset, applies `quantize_module_for_inference`, validates the
91+
KV-q4 configuration, and writes a manifest with quantized/remaining Linear
92+
counts, skipped embedding/output-head policy, memory-safety metadata, and
93+
optional forward-diff information. It is not a full checkpoint converter and
94+
does not claim training quantization.
95+
8896
`scripts/bench_inference_throughput.py` measures local smoke prefill/decode
8997
throughput for Qwen3-4B-class and NAM56R-class route profiles without
9098
allocating full multi-billion-parameter models.
@@ -119,5 +127,6 @@ being silently fabricated.
119127
- This is not model-integrated paged attention.
120128
- This is not a real ARC/MMLU/HumanEval leaderboard run.
121129
- This is not a real NIAH/RULER leaderboard run.
130+
- This is not a full checkpoint converter.
122131
- This is not a GB10 parity claim.
123132
- This is not mixed bf16-to-q4 quantized_kv_start > 0 transition coverage.

docs/mlx_port_master_plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -893,7 +893,7 @@ Excluded as Hopper-only dead-end on Metal: cppmega/megatron/cute_dsl_mimo/ (sm_9
893893
175. **SCOPED DONE (q4 token-id quality harness)**: add `scripts/bench_inference_quality.py` for local q4 inference quality smoke checks. The harness quantizes eligible `HybridTinyLM` linear layers with the repo-local q4 affine helper, evaluates ARC/MMLU-style multiple-choice and HumanEval-style generation tasks over token-id rows, ships built-in smoke tasks for CI-safe coverage, accepts external JSONL token-id tasks, validates malformed rows fail-closed, and records per-suite metrics plus explicit local-only/no-leaderboard/no-GB10 claims. This is not a real ARC/MMLU/HumanEval leaderboard run, not a full q4 NAM56R/Qwen receipt, and not tokenizer/dataset download wiring.
894894
176. **SCOPED DONE (KV-q4 long-context smoke harness)**: add `scripts/bench_inference_long_context.py` for local token-id NIAH and RULER-style long-context checks on the repo-local contiguous `QuantizedKVCache` path. The default run is allocation-safe, uses built-in smoke tasks, supports external JSONL token-id rows, records actual context length, q4 KV bits/group/start, prefill+decode timing, exact-token-match metrics, memory-safety metadata, and explicit local-only/no-leaderboard/no-GB10/no-full-model claims. This is not a real NIAH/RULER leaderboard run, not a full-model long-context receipt, and not mixed bf16-to-q4 `quantized_kv_start > 0` transition coverage.
895895
177. **SCOPED DONE**: add `docs/inference.md` documenting the current local MLX inference modes: eager full-prefix generation, contiguous KV generation/streaming, prompt-cache reuse and safety guards, vanilla and MTP self-speculative loops, local token-id FastAPI serving, q4 quality smoke, KV-q4 long-context smoke, side-channel `model_kwargs`/`model_kwargs_builder` boundaries, and explicit non-claims for OpenAI-compatible API, model-integrated paged attention, real benchmark leaderboards, GB10 parity, and mixed `quantized_kv_start > 0` transition coverage.
896-
178. Add inference-only quantize_for_inference.py helper script.
896+
178. **SCOPED DONE**: add `scripts/quantize_for_inference.py`, an inference-only CLI wrapper over the repo-local q4 quantization helpers. The script builds a small `HybridTinyLM` preset, applies `quantize_module_for_inference`, validates q4 KV-cache configuration, optionally runs a finite forward-diff check, and writes a manifest with quantized/remaining Linear counts, skipped embedding/output-head policy, memory-safety metadata, and explicit local-only/no-training/no-full-checkpoint-converter/no-GB10 claims. This is not a production checkpoint loader/saver or training quantization path.
897897
179. Add JSON-mode constrained decoding (logits processor).
898898
180. Add tool-use template support (chat-template special tokens already reserved).
899899

docs/porting_plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ The current collected test files are:
266266
- tests/test_profile.py
267267
- tests/test_profile_capture_script.py
268268
- tests/test_pytest_markers.py
269+
- tests/test_quantize_for_inference_script.py
269270
- tests/test_quantized_muon_momentum.py
270271
- tests/test_real_parquet_samples.py
271272
- tests/test_render_1b_training_matrix_html.py

scripts/quantize_for_inference.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Inference-only quantization helper for local MLX smoke workflows.
2+
3+
This script wraps ``cppmega_mlx.inference.quantization`` for manifestable local
4+
q4 inference checks. It does not load or rewrite production checkpoints yet; it
5+
is a fail-closed CLI around the repo-local inference quantization primitives.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import json
12+
import platform
13+
from pathlib import Path
14+
from typing import Any, cast
15+
16+
import mlx.core as mx
17+
import mlx.nn as nn
18+
import numpy as np
19+
from mlx.utils import tree_flatten
20+
21+
from cppmega_mlx.inference.quantization import (
22+
InferenceQuantizationConfig,
23+
make_quantized_kv_cache,
24+
quantize_module_for_inference,
25+
validate_kv_head_dim,
26+
)
27+
from cppmega_mlx.models.hybrid_lm import HybridTinyConfig, HybridTinyLM
28+
29+
ROOT = Path(__file__).resolve().parents[1]
30+
DEFAULT_OUTPUT = ROOT / "bench" / "baselines" / "quantize_for_inference_smoke.json"
31+
PRESET_CHOICES = ("smoke_attention", "smoke_hybrid")
32+
DTYPE_CHOICES = ("float32", "bfloat16")
33+
LARGE_MODEL_LIMIT_BYTES = 10 * 1024**3
34+
35+
36+
def main() -> int:
37+
parser = _build_parser()
38+
args = parser.parse_args()
39+
try:
40+
payload = run_quantization(args)
41+
except ValueError as exc:
42+
error = {
43+
"status": "error",
44+
"schema_version": 1,
45+
"error": str(exc),
46+
}
47+
print(json.dumps(error, indent=2, sort_keys=True))
48+
return 2
49+
50+
output = Path(args.out)
51+
output.parent.mkdir(parents=True, exist_ok=True)
52+
output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
53+
if args.json:
54+
print(json.dumps(payload, indent=2, sort_keys=True))
55+
else:
56+
quant = payload["linear_quantization"]
57+
print(f"[quantize] wrote {output}")
58+
print(
59+
" quantized_linear_modules="
60+
f"{quant['quantized_linear_modules']} remaining_linear_modules="
61+
f"{quant['remaining_linear_modules']}"
62+
)
63+
return 0
64+
65+
66+
def _build_parser() -> argparse.ArgumentParser:
67+
parser = argparse.ArgumentParser(description=__doc__)
68+
parser.add_argument("--preset", choices=PRESET_CHOICES, default="smoke_attention")
69+
parser.add_argument("--bits", type=int, default=4)
70+
parser.add_argument("--group-size", type=int, default=32)
71+
parser.add_argument("--kv-bits", type=int, default=4)
72+
parser.add_argument("--kv-group-size", type=int, default=32)
73+
parser.add_argument("--quantized-kv-start", type=int, default=0)
74+
parser.add_argument("--dtype", choices=DTYPE_CHOICES, default="bfloat16")
75+
parser.add_argument("--check-forward", action="store_true")
76+
parser.add_argument("--seed", type=int, default=178)
77+
parser.add_argument("--out", default=str(DEFAULT_OUTPUT))
78+
parser.add_argument("--json", action="store_true")
79+
return parser
80+
81+
82+
def run_quantization(args: argparse.Namespace) -> dict[str, Any]:
83+
quant_config = InferenceQuantizationConfig(
84+
bits=int(args.bits),
85+
group_size=int(args.group_size),
86+
kv_bits=int(args.kv_bits),
87+
kv_group_size=int(args.kv_group_size),
88+
quantized_kv_start=int(args.quantized_kv_start),
89+
)
90+
dtype = _dtype_from_name(str(args.dtype))
91+
mx.random.seed(int(args.seed))
92+
model = build_preset_model(str(args.preset), dtype=dtype)
93+
validate_kv_head_dim(model.config.hidden_size // model.config.num_attention_heads, group_size=quant_config.kv_group_size)
94+
pre_quant_bytes = _parameter_bytes(model)
95+
if pre_quant_bytes >= LARGE_MODEL_LIMIT_BYTES:
96+
raise ValueError("quantize_for_inference smoke preset exceeded memory limit")
97+
98+
prompt = _synthetic_prompt(
99+
vocab_size=model.config.vocab_size,
100+
seq_len=min(4, model.config.max_seq_length),
101+
seed=int(args.seed),
102+
)
103+
source_logits = model(prompt) if args.check_forward else None
104+
if source_logits is not None:
105+
mx.eval(source_logits)
106+
107+
quantize_module_for_inference(
108+
model,
109+
bits=quant_config.bits,
110+
group_size=quant_config.group_size,
111+
)
112+
quantized_logits = model(prompt) if args.check_forward else None
113+
if quantized_logits is not None:
114+
mx.eval(quantized_logits)
115+
116+
kv_cache = make_quantized_kv_cache(
117+
bits=quant_config.kv_bits,
118+
group_size=quant_config.kv_group_size,
119+
)
120+
return {
121+
"status": "ok",
122+
"schema_version": 1,
123+
"receipt_scope": "local_inference_quantization_manifest",
124+
"local_only": True,
125+
"training_quantization_claim": False,
126+
"full_checkpoint_converter_claim": False,
127+
"gb10_parity_claim": False,
128+
"preset": str(args.preset),
129+
"model": {
130+
"kind": "HybridTinyLM",
131+
"scale": "smoke",
132+
"vocab_size": model.config.vocab_size,
133+
"hidden_size": model.config.hidden_size,
134+
"pattern": model.config.pattern,
135+
"depth": model.config.depth,
136+
"dtype": str(args.dtype),
137+
},
138+
"linear_quantization": {
139+
"bits": quant_config.bits,
140+
"group_size": quant_config.group_size,
141+
"mode": quant_config.mode,
142+
"quantized_linear_modules": _count_modules(model, nn.QuantizedLinear),
143+
"remaining_linear_modules": _count_modules(model, nn.Linear),
144+
"embed_lm_head_skipped": True,
145+
},
146+
"kv_cache": {
147+
"quantized": True,
148+
"bits": quant_config.kv_bits,
149+
"group_size": quant_config.kv_group_size,
150+
"quantized_kv_start": quant_config.quantized_kv_start,
151+
"class_name": kv_cache.__class__.__name__,
152+
},
153+
"forward_check": _forward_check_payload(source_logits, quantized_logits),
154+
"memory_safety": {
155+
"pre_quant_model_bytes": pre_quant_bytes,
156+
"post_quant_model_bytes": _parameter_bytes(model),
157+
"large_tensor_limit_bytes": LARGE_MODEL_LIMIT_BYTES,
158+
"full_model_materialized": False,
159+
},
160+
"hardware": _hardware_metadata(),
161+
"non_claims": {
162+
"training_quantization": True,
163+
"full_checkpoint_conversion": True,
164+
"m4_vs_gb10_quantization_parity": True,
165+
},
166+
}
167+
168+
169+
def build_preset_model(preset: str, *, dtype: mx.Dtype) -> HybridTinyLM:
170+
if preset == "smoke_attention":
171+
config = _smoke_config(pattern="A", depth=1, max_seq_length=8)
172+
elif preset == "smoke_hybrid":
173+
config = _smoke_config(pattern="AEMR", depth=4, max_seq_length=8)
174+
else:
175+
raise ValueError(f"unsupported preset {preset!r}")
176+
return HybridTinyLM(config, dtype=dtype)
177+
178+
179+
def _smoke_config(*, pattern: str, depth: int, max_seq_length: int) -> HybridTinyConfig:
180+
return HybridTinyConfig(
181+
vocab_size=128,
182+
hidden_size=64,
183+
pattern=pattern,
184+
depth=depth,
185+
dsa_a_layer_ranks=(),
186+
num_attention_heads=2,
187+
num_attention_kv_heads=2,
188+
max_seq_length=max_seq_length,
189+
moe_num_experts=4,
190+
moe_top_k=2,
191+
moe_expert_hidden_size=32,
192+
moe_shared_expert_hidden_size=32,
193+
mamba_expand=1,
194+
mamba_head_dim=4,
195+
mamba_state_dim=4,
196+
mamba_groups=1,
197+
mamba_mimo_rank=1,
198+
mamba_is_mimo=False,
199+
mamba_chunk_size=8,
200+
m2rnn_k_head_dim=4,
201+
m2rnn_v_head_dim=4,
202+
m2rnn_num_q_heads=1,
203+
m2rnn_num_k_heads=1,
204+
m2rnn_num_v_heads=1,
205+
m2rnn_num_f_heads=1,
206+
m2rnn_num_weight_heads=1,
207+
m2rnn_chunk_size=8,
208+
)
209+
210+
211+
def _synthetic_prompt(*, vocab_size: int, seq_len: int, seed: int) -> mx.array:
212+
rng = np.random.default_rng(seed)
213+
data = rng.integers(0, vocab_size, size=(1, seq_len), dtype=np.int32)
214+
return mx.array(data, dtype=mx.int32)
215+
216+
217+
def _forward_check_payload(
218+
source_logits: mx.array | None,
219+
quantized_logits: mx.array | None,
220+
) -> dict[str, Any]:
221+
if source_logits is None or quantized_logits is None:
222+
return {
223+
"enabled": False,
224+
"finite": None,
225+
"max_abs_diff": None,
226+
}
227+
source = np.array(source_logits.astype(mx.float32))
228+
quantized = np.array(quantized_logits.astype(mx.float32))
229+
finite = bool(np.isfinite(source).all() and np.isfinite(quantized).all())
230+
max_abs_diff = float(np.max(np.abs(source - quantized)))
231+
return {
232+
"enabled": True,
233+
"finite": finite,
234+
"max_abs_diff": max_abs_diff,
235+
}
236+
237+
238+
def _count_modules(model: nn.Module, cls: type[nn.Module]) -> int:
239+
return sum(isinstance(module, cls) for _name, module in model.named_modules())
240+
241+
242+
def _parameter_bytes(model: HybridTinyLM) -> int:
243+
total = 0
244+
for _name, value in tree_flatten(model.trainable_parameters()):
245+
if isinstance(value, mx.array):
246+
array_value = cast(mx.array, value)
247+
nbytes = getattr(array_value, "nbytes", None)
248+
if nbytes is not None:
249+
total += int(nbytes)
250+
else:
251+
total += int(array_value.size * array_value.itemsize)
252+
return total
253+
254+
255+
def _dtype_from_name(name: str) -> mx.Dtype:
256+
if name == "float32":
257+
return mx.float32
258+
if name == "bfloat16":
259+
return mx.bfloat16
260+
raise ValueError(f"unsupported dtype {name!r}")
261+
262+
263+
def _hardware_metadata() -> dict[str, Any]:
264+
return {
265+
"platform": platform.platform(),
266+
"machine": platform.machine(),
267+
"python": platform.python_version(),
268+
"default_device": str(mx.default_device()),
269+
"mlx_version": getattr(mx, "__version__", None),
270+
}
271+
272+
273+
if __name__ == "__main__":
274+
raise SystemExit(main())

tests/test_inference_docs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def test_inference_modes_doc_pins_supported_local_surfaces() -> None:
2626
"create_local_generation_app(",
2727
"scripts/bench_inference_quality.py",
2828
"scripts/bench_inference_long_context.py",
29+
"scripts/quantize_for_inference.py",
2930
"QuantizedKVCache",
3031
"model_kwargs_builder",
3132
):
@@ -41,6 +42,7 @@ def test_inference_modes_doc_keeps_non_claims_explicit() -> None:
4142
"not a real ARC/MMLU/HumanEval leaderboard run",
4243
"not a real NIAH/RULER leaderboard run",
4344
"not a GB10 parity claim",
45+
"not a full checkpoint converter",
4446
"not mixed bf16-to-q4 quantized_kv_start > 0 transition coverage",
4547
):
4648
assert phrase in text

0 commit comments

Comments
 (0)