Skip to content

Commit df78a77

Browse files
committed
Add q4 inference quality smoke benchmark
1 parent ad6d0e3 commit df78a77

4 files changed

Lines changed: 449 additions & 1 deletion

File tree

docs/mlx_port_master_plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ Excluded as Hopper-only dead-end on Metal: cppmega/megatron/cute_dsl_mimo/ (sm_9
890890
172. **SCOPED DONE**: validate the local contiguous prompt-cache path as attention/stateless-route only. `build_prompt_cache(...)` and `generate_tokens_with_prompt_cache(...)` now fail closed for stateful route roles such as Mamba3/M2RNN/engram, because the contiguous KV entry does not carry SSM/recurrent/ngram state. Attention-only prompt-cache parity remains covered against full-prefix KV generation. This is not model-integrated sliding-window cache support, SSM-state prompt caching, paged prompt caching, or mlx-lm API parity for every upstream route.
891891
173. **SCOPED DONE (local token-id serving only)**: add `create_local_generation_app(...)` in `cppmega_mlx/inference/serve.py`, exported from `cppmega_mlx.inference`. The FastAPI adapter exposes `/health` and `/generate` for already-tokenized prompts, threads eager generation options, supports an explicit `model_kwargs_builder` hook for caller-owned side-channel tensors, validates invalid token-id/options requests as HTTP 400, and can return optional generated text via a caller-supplied token decoder. This follows the resolved local research topology; it is not an OpenAI-compatible chat/completions API, tokenizer service, cloud fleet endpoint, paged-attention serving stack, or vllm-mlx parity claim.
892892
174. **SCOPED DONE (allocation-safe smoke throughput harness)**: add `scripts/bench_inference_throughput.py` for current MLX inference prefill/decode timing. The default run covers `qwen3_4b_class` and `nam56r_class` smoke-scale route profiles, records prefill tokens/s, decode tokens/s, decode mode, actual config, parameter-memory estimate, hardware metadata, and explicit local-only/no-GB10/no-full-model-throughput claims. Full multi-billion-parameter receipts remain gated behind a future large-run workflow; `--scale full` fails closed unless explicitly allowed and is not produced by the default CI-safe path.
893-
175. Add quality benchmark: ARC / MMLU / HumanEval on q4-quantized model.
893+
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. Add long-context benchmark (NIAH, RULER) on KV-q4 path.
895895
177. Document inference modes in docs/inference.md.
896896
178. Add inference-only quantize_for_inference.py helper script.

docs/porting_plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ The current collected test files are:
172172
- tests/test_bench_dtype_cast_cost.py
173173
- tests/test_bench_dtype_long_horizon.py
174174
- tests/test_bench_inference_latency.py
175+
- tests/test_bench_inference_quality.py
175176
- tests/test_bench_inference_throughput.py
176177
- tests/test_bench_matrix.py
177178
- tests/test_bench_production_presets.py

scripts/bench_inference_quality.py

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
"""Stream I q4 inference quality smoke harness.
2+
3+
The default benchmark uses built-in token-id tasks shaped like ARC, MMLU, and
4+
HumanEval. It validates the q4 inference path and receipt schema without
5+
claiming real leaderboard quality. Real benchmark data can be supplied as
6+
JSONL rows using the same token-id schema.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import json
13+
import platform
14+
from dataclasses import dataclass
15+
from pathlib import Path
16+
from typing import Any, Literal
17+
18+
import mlx.core as mx
19+
import mlx.nn as nn
20+
import numpy as np
21+
22+
from cppmega_mlx.inference.generation import generate_tokens
23+
from cppmega_mlx.inference.quantization import quantize_module_for_inference
24+
from cppmega_mlx.models.hybrid_lm import HybridTinyConfig, HybridTinyLM
25+
26+
ROOT = Path(__file__).resolve().parents[1]
27+
DEFAULT_OUTPUT = ROOT / "bench" / "baselines" / "inference_quality_smoke.json"
28+
SUITE_CHOICES = ("arc", "mmlu", "humaneval")
29+
DTYPE_CHOICES = ("float32", "bfloat16")
30+
DEFAULT_VOCAB_SIZE = 128
31+
32+
33+
@dataclass(frozen=True)
34+
class QualityTask:
35+
suite: Literal["arc", "mmlu", "humaneval"]
36+
task_id: str
37+
prompt_ids: tuple[int, ...]
38+
choice_token_ids: tuple[int, ...] = ()
39+
answer_index: int | None = None
40+
expected_token_ids: tuple[int, ...] = ()
41+
42+
43+
def main() -> int:
44+
parser = _build_parser()
45+
args = parser.parse_args()
46+
try:
47+
payload = run_benchmark(args)
48+
except ValueError as exc:
49+
error = {
50+
"status": "error",
51+
"schema_version": 1,
52+
"error": str(exc),
53+
}
54+
print(json.dumps(error, indent=2, sort_keys=True))
55+
return 2
56+
57+
output = Path(args.out)
58+
output.parent.mkdir(parents=True, exist_ok=True)
59+
output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
60+
if args.json:
61+
print(json.dumps(payload, indent=2, sort_keys=True))
62+
else:
63+
print(f"[quality] wrote {output}")
64+
for row in payload["suite_rows"]:
65+
print(f" {row['suite']}: {row['metric']}={row['score']:.4f}")
66+
return 0
67+
68+
69+
def _build_parser() -> argparse.ArgumentParser:
70+
parser = argparse.ArgumentParser(description=__doc__)
71+
parser.add_argument("--tasks-jsonl")
72+
parser.add_argument("--suites", nargs="+", choices=SUITE_CHOICES, default=list(SUITE_CHOICES))
73+
parser.add_argument("--bits", type=int, default=4)
74+
parser.add_argument("--group-size", type=int, default=32)
75+
parser.add_argument("--dtype", choices=DTYPE_CHOICES, default="bfloat16")
76+
parser.add_argument("--seed", type=int, default=175)
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_benchmark(args: argparse.Namespace) -> dict[str, Any]:
83+
selected_suites = tuple(str(suite) for suite in args.suites)
84+
tasks = load_tasks(
85+
Path(args.tasks_jsonl) if args.tasks_jsonl else None,
86+
selected_suites=selected_suites,
87+
)
88+
dtype = _dtype_from_name(str(args.dtype))
89+
mx.random.seed(int(args.seed))
90+
model = build_quality_model(dtype=dtype)
91+
quantize_module_for_inference(
92+
model,
93+
bits=int(args.bits),
94+
group_size=int(args.group_size),
95+
)
96+
quantized_linear_modules = _count_quantized_linear_modules(model)
97+
if quantized_linear_modules <= 0:
98+
raise ValueError("q4 quality benchmark quantized zero Linear modules")
99+
100+
suite_rows = [
101+
evaluate_suite(model, suite, tasks_for_suite)
102+
for suite, tasks_for_suite in _group_tasks(tasks, selected_suites).items()
103+
]
104+
return {
105+
"status": "ok",
106+
"schema_version": 1,
107+
"receipt_scope": "local_inference_quality_smoke",
108+
"local_only": True,
109+
"gb10_parity_claim": False,
110+
"leaderboard_claim": False,
111+
"dataset_source": "jsonl_token_id_tasks"
112+
if args.tasks_jsonl
113+
else "built_in_token_id_smoke",
114+
"suites": list(selected_suites),
115+
"quantization": {
116+
"bits": int(args.bits),
117+
"group_size": int(args.group_size),
118+
"mode": "affine",
119+
"quantized_linear_modules": quantized_linear_modules,
120+
"embed_lm_head_skipped": True,
121+
},
122+
"model": {
123+
"kind": "HybridTinyLM",
124+
"scale": "smoke",
125+
"vocab_size": model.config.vocab_size,
126+
"hidden_size": model.config.hidden_size,
127+
"pattern": model.config.pattern,
128+
"depth": model.config.depth,
129+
"dtype": str(args.dtype),
130+
},
131+
"suite_rows": suite_rows,
132+
"hardware": _hardware_metadata(),
133+
"non_claims": {
134+
"real_arc_mmlu_humaneval_leaderboard": True,
135+
"full_q4_model_quality": True,
136+
"m4_vs_gb10_quality_parity": True,
137+
},
138+
}
139+
140+
141+
def build_quality_model(*, dtype: mx.Dtype) -> HybridTinyLM:
142+
config = HybridTinyConfig(
143+
vocab_size=DEFAULT_VOCAB_SIZE,
144+
hidden_size=32,
145+
pattern="AE",
146+
depth=2,
147+
dsa_a_layer_ranks=(),
148+
num_attention_heads=4,
149+
max_seq_length=16,
150+
moe_num_experts=4,
151+
moe_top_k=2,
152+
moe_expert_hidden_size=32,
153+
moe_shared_expert_hidden_size=32,
154+
mamba_expand=1,
155+
mamba_head_dim=4,
156+
mamba_state_dim=4,
157+
mamba_groups=1,
158+
mamba_mimo_rank=1,
159+
mamba_is_mimo=False,
160+
mamba_chunk_size=8,
161+
m2rnn_k_head_dim=4,
162+
m2rnn_v_head_dim=4,
163+
m2rnn_num_q_heads=1,
164+
m2rnn_num_k_heads=1,
165+
m2rnn_num_v_heads=1,
166+
m2rnn_num_f_heads=1,
167+
m2rnn_num_weight_heads=1,
168+
m2rnn_chunk_size=8,
169+
)
170+
return HybridTinyLM(config, dtype=dtype)
171+
172+
173+
def load_tasks(
174+
path: Path | None,
175+
*,
176+
selected_suites: tuple[str, ...],
177+
) -> list[QualityTask]:
178+
if path is None:
179+
tasks = built_in_tasks()
180+
else:
181+
tasks = []
182+
for line_no, line in enumerate(path.read_text().splitlines(), start=1):
183+
if not line.strip():
184+
continue
185+
try:
186+
raw = json.loads(line)
187+
except json.JSONDecodeError as exc:
188+
raise ValueError(f"{path}:{line_no}: invalid JSON: {exc}") from exc
189+
if not isinstance(raw, dict):
190+
raise ValueError(f"{path}:{line_no}: task row must be a JSON object")
191+
tasks.append(parse_task(raw, source=f"{path}:{line_no}"))
192+
193+
filtered = [task for task in tasks if task.suite in selected_suites]
194+
if not filtered:
195+
raise ValueError("no quality tasks remain after suite filtering")
196+
return filtered
197+
198+
199+
def built_in_tasks() -> list[QualityTask]:
200+
return [
201+
QualityTask(
202+
suite="arc",
203+
task_id="arc_smoke_0",
204+
prompt_ids=(10, 11, 12),
205+
choice_token_ids=(21, 22, 23, 24),
206+
answer_index=0,
207+
),
208+
QualityTask(
209+
suite="mmlu",
210+
task_id="mmlu_smoke_0",
211+
prompt_ids=(30, 31, 32),
212+
choice_token_ids=(41, 42, 43, 44),
213+
answer_index=1,
214+
),
215+
QualityTask(
216+
suite="humaneval",
217+
task_id="humaneval_smoke_0",
218+
prompt_ids=(50, 51, 52),
219+
expected_token_ids=(61,),
220+
),
221+
]
222+
223+
224+
def parse_task(raw: dict[str, Any], *, source: str) -> QualityTask:
225+
suite = raw.get("suite")
226+
if suite not in SUITE_CHOICES:
227+
raise ValueError(f"{source}: suite must be one of {', '.join(SUITE_CHOICES)}")
228+
task_id = str(raw.get("task_id", source))
229+
prompt_ids = _parse_token_tuple(raw.get("prompt_ids"), field="prompt_ids", source=source)
230+
if suite in {"arc", "mmlu"}:
231+
choice_token_ids = _parse_token_tuple(
232+
raw.get("choice_token_ids"),
233+
field="choice_token_ids",
234+
source=source,
235+
)
236+
answer_index = raw.get("answer_index")
237+
if not isinstance(answer_index, int) or isinstance(answer_index, bool):
238+
raise ValueError(f"{source}: answer_index must be an integer")
239+
if not 0 <= answer_index < len(choice_token_ids):
240+
raise ValueError(f"{source}: answer_index must reference choice_token_ids")
241+
return QualityTask(
242+
suite=suite,
243+
task_id=task_id,
244+
prompt_ids=prompt_ids,
245+
choice_token_ids=choice_token_ids,
246+
answer_index=answer_index,
247+
)
248+
249+
expected_token_ids = _parse_token_tuple(
250+
raw.get("expected_token_ids"),
251+
field="expected_token_ids",
252+
source=source,
253+
)
254+
return QualityTask(
255+
suite="humaneval",
256+
task_id=task_id,
257+
prompt_ids=prompt_ids,
258+
expected_token_ids=expected_token_ids,
259+
)
260+
261+
262+
def _parse_token_tuple(value: Any, *, field: str, source: str) -> tuple[int, ...]:
263+
if not isinstance(value, list) or not value:
264+
raise ValueError(f"{source}: {field} must be a non-empty list")
265+
tokens: list[int] = []
266+
for token in value:
267+
if not isinstance(token, int) or isinstance(token, bool) or token < 0:
268+
raise ValueError(f"{source}: {field} must contain non-negative ints")
269+
if token >= DEFAULT_VOCAB_SIZE:
270+
raise ValueError(f"{source}: {field} token {token} exceeds smoke vocab")
271+
tokens.append(token)
272+
return tuple(tokens)
273+
274+
275+
def _group_tasks(
276+
tasks: list[QualityTask],
277+
selected_suites: tuple[str, ...],
278+
) -> dict[str, list[QualityTask]]:
279+
grouped = {suite: [] for suite in selected_suites}
280+
for task in tasks:
281+
grouped[task.suite].append(task)
282+
return {suite: suite_tasks for suite, suite_tasks in grouped.items() if suite_tasks}
283+
284+
285+
def evaluate_suite(
286+
model: HybridTinyLM,
287+
suite: str,
288+
tasks: list[QualityTask],
289+
) -> dict[str, Any]:
290+
if suite in {"arc", "mmlu"}:
291+
correct = sum(evaluate_multiple_choice(model, task) for task in tasks)
292+
metric = "accuracy"
293+
elif suite == "humaneval":
294+
correct = sum(evaluate_humaneval(model, task) for task in tasks)
295+
metric = "pass_at_1_exact_token_match"
296+
else:
297+
raise ValueError(f"unsupported suite {suite!r}")
298+
score = correct / len(tasks)
299+
return {
300+
"suite": suite,
301+
"status": "ok",
302+
"metric": metric,
303+
"num_tasks": len(tasks),
304+
"num_correct": int(correct),
305+
"score": float(score),
306+
}
307+
308+
309+
def evaluate_multiple_choice(model: HybridTinyLM, task: QualityTask) -> bool:
310+
if task.answer_index is None or not task.choice_token_ids:
311+
raise ValueError(f"{task.task_id}: multiple-choice task is incomplete")
312+
prompt = mx.array([task.prompt_ids], dtype=mx.int32)
313+
logits = model(prompt)
314+
mx.eval(logits)
315+
last_logits = np.array(logits[0, -1, :])
316+
scores = [float(last_logits[token_id]) for token_id in task.choice_token_ids]
317+
predicted = int(np.argmax(np.array(scores, dtype=np.float32)))
318+
return predicted == task.answer_index
319+
320+
321+
def evaluate_humaneval(model: HybridTinyLM, task: QualityTask) -> bool:
322+
if not task.expected_token_ids:
323+
raise ValueError(f"{task.task_id}: HumanEval task needs expected_token_ids")
324+
prompt = mx.array([task.prompt_ids], dtype=mx.int32)
325+
generated = generate_tokens(
326+
model,
327+
prompt,
328+
max_new_tokens=len(task.expected_token_ids),
329+
temperature=0.0,
330+
)
331+
mx.eval(generated)
332+
generated_suffix = tuple(int(value) for value in np.array(generated)[0, -len(task.expected_token_ids) :])
333+
return generated_suffix == task.expected_token_ids
334+
335+
336+
def _count_quantized_linear_modules(model: HybridTinyLM) -> int:
337+
return sum(isinstance(module, nn.QuantizedLinear) for _name, module in model.named_modules())
338+
339+
340+
def _dtype_from_name(name: str) -> mx.Dtype:
341+
if name == "float32":
342+
return mx.float32
343+
if name == "bfloat16":
344+
return mx.bfloat16
345+
raise ValueError(f"unsupported dtype {name!r}")
346+
347+
348+
def _hardware_metadata() -> dict[str, Any]:
349+
return {
350+
"platform": platform.platform(),
351+
"machine": platform.machine(),
352+
"python": platform.python_version(),
353+
"default_device": str(mx.default_device()),
354+
"mlx_version": getattr(mx, "__version__", None),
355+
}
356+
357+
358+
if __name__ == "__main__":
359+
raise SystemExit(main())
360+

0 commit comments

Comments
 (0)