xDecision / src /xdecision /benchmark_runtime.py
mccoysc's picture
Add native MLX inference with calibrated output parity
d97731b verified
Raw History Blame Contribute Delete
10.4 kB
"""Same-machine inference parity and synchronized end-to-end timing.
Run sequentially with no other GPU workload. This checks execution fidelity,
not ground-truth model accuracy. Every output includes all decision heads and
the checkpoint's calibration. No answers or contextual states are cached.
"""
import argparse
import gc
import hashlib
import json
import os
import platform
import resource
import subprocess
import time
from pathlib import Path
import numpy as np
import torch
from .runtime import load
def fixtures():
questions = {
'choice': dict(type='choice', instructions='Which service primarily hosts general source code?',
criteria={'github':'GitHub', 'hf':'Hugging Face'}),
'noul': dict(type='noul', instructions='Hugging Face can also store source code.'),
'score': dict(type='score', instructions='How suitable is GitHub for general code hosting?',
criteria=['unsuitable','partly suitable','suitable','best fit']),
}
facts = ['GitHub primarily hosts general software source code.',
'Hugging Face primarily hosts AI models and datasets but can also store code.']
states = [' '.join(facts), ' '.join(reversed(facts)), '',
{'facts':facts}, [{'role':'user','content':' '.join(facts)}],
' '.join(facts)+' The literal token <mask> is part of this message.',
'GitHub主要用于通用代码协作;Hugging Face主要发布模型和数据,也能存储代码。',
'GitHub héberge du code. Hugging Face héberge des modèles et du code.',
'GitHub aloja código. Hugging Face aloja modelos y también código.',
'GitHubにはコード、Hugging Faceにはモデルとコードを保存できます。',
'No information identifies either service.',
('Background information is unavailable. '*350)+' '.join(facts)]
cases = [(f'state-{i}', s, questions) for i, s in enumerate(states)]
cases += [('reversed-options', states[0], {
**questions, 'choice': {**questions['choice'], 'criteria': {'hf':'Hugging Face','github':'GitHub'}},
'score': {**questions['score'], 'criteria': list(reversed(questions['score']['criteria']))}})]
cases += [('one-option', 'Only one candidate exists.', {'q':dict(type='choice',instructions='Select it.',criteria=['only'])}),
('custom-noul', 'The flag is active.', {'q':dict(type='noul',instructions='The flag is active.',labels={'false':'B','true':'A'})}),
('many-options', 'The selected number is 17.', {'q':dict(type='choice', instructions='Select the stated number.', criteria=[str(i) for i in range(20)])}),
('empty-questions', 'No questions.', {})]
return cases
def compare(reference, candidate):
if len(reference) != len(candidate):
raise AssertionError('Number of cases changed')
count = agreement = 0
probability_error = action_error = score_error = confidence_error = 0.
for ref, got in zip(reference, candidate):
if ref['usage'] != got['usage'] or ref['answers'].keys() != got['answers'].keys():
raise AssertionError('Token usage or output keys changed')
for key, a in ref['answers'].items():
b = got['answers'][key]
if a['type'] == 'noul':
pa, pb = np.array([1-a['noul'], a['noul']]), np.array([1-b['noul'], b['noul']])
else:
if list(a['probabilities']) != list(b['probabilities']):
raise AssertionError('Candidate order changed')
pa, pb = np.array(list(a['probabilities'].values())), np.array(list(b['probabilities'].values()))
probability_error = max(probability_error, float(np.max(np.abs(pa-pb))))
action_error = max(action_error, abs(a['action']['act_probability']-b['action']['act_probability']))
confidence_error = max(confidence_error, abs(a['confidence']-b['confidence']),
abs(a['answer_confidence']-b['answer_confidence']))
if a['type'] == 'score':
score_error = max(score_error, abs(a['score']-b['score']))
agreement += int(pa.argmax() == pb.argmax())
count += 1
return dict(questions=count, argmax_agreement=agreement, max_probability_error=probability_error,
max_act_probability_error=action_error, max_score_error=score_error,
max_confidence_error=confidence_error)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--checkpoint', default='models/checkpoint')
parser.add_argument('--f16', default=None)
parser.add_argument('--q8', default=None)
parser.add_argument('--output', required=True)
parser.add_argument('--iterations', type=int, default=30)
parser.add_argument('--warmup', type=int, default=5)
args = parser.parse_args()
if os.environ.get('MLX_ENABLE_TF32') != '0':
raise ValueError('Launch this comparison with MLX_ENABLE_TF32=0 for strict FP32')
out = Path(args.output)
if out.exists():
raise FileExistsError(out)
if args.iterations < 1 or args.warmup < 1:
raise ValueError('iterations and warmup must be positive')
torch.set_num_threads(4)
cases = fixtures()
reference = None
report = dict(model='xDecision', machine=platform.platform(), torch=torch.__version__,
processor=subprocess.check_output(['sysctl','-n','machdep.cpu.brand_string'],text=True).strip(),
memory_bytes=int(subprocess.check_output(['sysctl','-n','hw.memsize'],text=True)),
threads=4, iterations=args.iterations, warmup=args.warmup,
mlx_enable_tf32=os.environ['MLX_ENABLE_TF32'],
input_sha256=hashlib.sha256(json.dumps(cases,ensure_ascii=False).encode()).hexdigest(),
timing='Sequential backends; synchronized end-to-end predict, excluding model load. No output cache.',
limits='One machine/session; agreement is not accuracy. Load and first request are not fresh-process cold starts. Process RSS is cumulative across backends; MLX allocation is separate.',
results={})
paths = [('torch-cpu', args.checkpoint, dict(backend='torch',device='cpu')),
('torch-mps', args.checkpoint, dict(backend='torch',device='mps')),
('mlx-f32', args.checkpoint, dict(backend='mlx',dtype='float32')),
('mlx-f16', args.checkpoint, dict(backend='mlx',dtype='float16'))]
if not torch.backends.mps.is_available():
raise RuntimeError('This paired benchmark requires Apple Silicon MPS and MLX')
if args.f16:
paths.append(('mlx-gguf-f16',args.f16,dict(backend='mlx')))
if args.q8:
paths += [('torch-gguf-q8',args.q8,dict(backend='torch',device='cpu')),
('mlx-gguf-q8',args.q8,dict(backend='mlx'))]
import mlx.core as mx
report['mlx'] = mx.__version__
q8_reference = None
for name, path, options in paths:
gc.collect()
mx.clear_cache()
mx.reset_peak_memory()
begin = time.perf_counter()
with load(path, **options) as model:
load_s = time.perf_counter()-begin
begin = time.perf_counter()
model.predict(cases[0][1], cases[0][2])
first_ms = 1000*(time.perf_counter()-begin)
outputs = [model.predict(state,q) for _,state,q in cases]
if reference is None:
reference = outputs
if name == 'torch-gguf-q8':
q8_reference = outputs
parity = compare(q8_reference if name == 'mlx-gguf-q8' else reference, outputs)
if name.startswith('mlx') and (parity['argmax_agreement'] != parity['questions']
or parity['max_probability_error'] > .01
or parity['max_act_probability_error'] > .01):
raise AssertionError(f'Backend fidelity check failed: {parity}')
native = model._model
if options['backend'] == 'mlx':
# Verify exact input IDs against the installed reference sequence/tokenizer path.
from .model_io import load_tokenizer
from laya.agent import Agent
from types import SimpleNamespace
ref = SimpleNamespace(tok=load_tokenizer(args.checkpoint),cfg=native.cfg)
for _, state, q in cases:
rows, internal = native.prepare(state,q)
if q and rows != Agent._encode_state(ref,state,list(q),internal):
raise AssertionError('Prepared tokens differ')
timings = {}
for workload, state, q in [('short-choice',cases[0][1],{'q':cases[0][2]['choice']}),
('short-three-types',cases[0][1],cases[0][2]),
('long-three-types',cases[11][1],cases[11][2])]:
for _ in range(args.warmup):
model.predict(state,q)
samples=[]
for _ in range(args.iterations):
start=time.perf_counter()
model.predict(state,q)
samples.append(1000*(time.perf_counter()-start))
timings[workload]=dict(p50_ms=float(np.median(samples)),p95_ms=float(np.percentile(samples,95)),samples_ms=samples)
repeated=[model.predict(cases[0][1],cases[0][2]) for _ in range(10)]
if any(x!=repeated[0] for x in repeated):
raise AssertionError('Repeated outputs differ')
record=dict(load_s=load_s,first_request_ms=first_ms,parity=parity,timings=timings,
peak_rss_bytes=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
mlx_peak_bytes=mx.get_peak_memory() if options['backend']=='mlx' else None)
report['results'][name]=record
print(name,json.dumps({k:v for k,v in record.items() if k!='timings'}),flush=True)
print('latencies',json.dumps({k:round(v['p50_ms'],3) for k,v in timings.items()}),flush=True)
out.parent.mkdir(parents=True,exist_ok=True)
out.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
if __name__ == '__main__':
main()