Skip to content

Commit 754e6fc

Browse files
authored
Merge pull request #217 from interscript/feat/static-export-path
feat(export): static-int8 artifact path (calibration + builder)
2 parents a0be869 + 740537b commit 754e6fc

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

src/gpu/modal_export.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,84 @@ def rebuild_int8_head32(model_id: str, limit: int = 0) -> dict:
584584
}
585585

586586

587+
@app.function(
588+
cpu=8,
589+
memory=32 * 1024,
590+
timeout=5 * 3600,
591+
volumes={**CHECKPOINT_VOLUMES, **DATASET_VOLUMES, "/outputs": MODELS_VOLUME},
592+
)
593+
def export_int8_static(model_id: str, limit: int = 0) -> dict:
594+
"""Build the static-activation int8 artifact (TODO.impl/11's
595+
positive branch): fp32 encoder + static-int8 decoder, calibrated on
596+
the model's own eval pairs across BOTH decode framings.
597+
598+
The MEASURED composition (scored 4.6241 full-set vs dynamic 4.5701,
599+
+8% CPU decode): the fp32 encoder keeps the artifact server-sized;
600+
a browser-sized int8-encoder + static-decoder composition needs its
601+
own gate run before it ships. Lands as {mid}-int8static.zip; the
602+
release swap is a version decision."""
603+
import re
604+
import sys
605+
import tempfile
606+
import zipfile
607+
from pathlib import Path
608+
609+
sys.path.insert(0, "/root/interscript-ml/src")
610+
from imf.export import (
611+
collect_decode_calibration,
612+
head_matmul_names,
613+
quantize_int8_static,
614+
refresh_member_shas,
615+
)
616+
617+
spec = MODELS[model_id]
618+
test_path = Path(spec["test_volume"]) / spec["test_data"]
619+
pairs = _load_pairs(test_path)[: limit or None]
620+
621+
out_dir = Path("/outputs/imf") / model_id
622+
meta_path = Path("/root/interscript-ml", spec["metadata"])
623+
mid = re.search(r"^id:\s*(\S+)", meta_path.read_text(encoding="utf-8"), re.M).group(1)
624+
fp32_zip = out_dir / f"{mid}-fp32.zip"
625+
if not fp32_zip.exists():
626+
raise RuntimeError(f"{fp32_zip.name} missing on the volume")
627+
628+
import onnxruntime as ort
629+
630+
with tempfile.TemporaryDirectory() as tmp:
631+
tmp = Path(tmp)
632+
with zipfile.ZipFile(fp32_zip) as zf:
633+
zf.extract("encoder.onnx", tmp)
634+
dec = "decoder-kv.onnx" if "decoder-kv.onnx" in zf.namelist() else "decoder.onnx"
635+
zf.extract(dec, tmp)
636+
opts = ort.SessionOptions()
637+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
638+
enc_sess = ort.InferenceSession(str(tmp / "encoder.onnx"), opts)
639+
dec_sess = ort.InferenceSession(str(tmp / dec), opts)
640+
calibration = collect_decode_calibration(enc_sess, dec_sess, [s for s, _ in pairs])
641+
print(f"[{model_id}] calibration: {len(calibration)} feeds", flush=True)
642+
dec_static = tmp / dec.replace(".onnx", "-static.onnx")
643+
quantize_int8_static(
644+
tmp / dec, dec_static, calibration,
645+
nodes_to_exclude=head_matmul_names(tmp / dec),
646+
)
647+
new_zip = out_dir / f"{mid}-int8static.zip"
648+
with zipfile.ZipFile(fp32_zip) as src, zipfile.ZipFile(
649+
new_zip, "w", zipfile.ZIP_DEFLATED
650+
) as dst:
651+
for member in src.namelist():
652+
if member == "metadata.yaml":
653+
meta = src.read(member).decode("utf-8")
654+
meta = re.sub(r"^precision:\s*\S+", "precision: int8-static", meta, flags=re.M)
655+
meta = re.sub(r"^id:\s*\S+", f"id: {mid}-int8static", meta, flags=re.M)
656+
dst.writestr(member, meta)
657+
elif member == dec:
658+
dst.writestr(member, dec_static.read_bytes())
659+
else:
660+
dst.writestr(member, src.read(member))
661+
refresh_member_shas(new_zip)
662+
return {"artifact": str(new_zip), "calibration": len(calibration)}
663+
664+
587665
@app.local_entrypoint()
588666
def rebuild_int8(model: str, limit: int = 0) -> None:
589667
print(rebuild_int8_head32.remote(model, limit))
@@ -618,3 +696,8 @@ def zip_meta(model_id: str, precision: str) -> dict:
618696
def zmeta(model: str, precisions: str = "fp32,fp16,int8") -> None:
619697
for precision in precisions.split(","):
620698
print(precision, zip_meta.remote(model, precision))
699+
@app.local_entrypoint()
700+
def static(model_id: str, limit: int = 0) -> None:
701+
print(export_int8_static.remote(model_id, limit))
702+
703+

src/imf/export.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,3 +523,115 @@ def export_zips(
523523
)
524524
return zips
525525

526+
527+
528+
def collect_decode_calibration(
529+
enc_sess, dec_sess, texts: list[str], steps: int = 64,
530+
) -> list[dict]:
531+
"""Decoder feeds across framings for static activation calibration:
532+
prefills, incremental single-token steps, and 8-token windows —
533+
the shapes whose scales must hold (the framing axis). Mirrors
534+
scripts/static_int8_experiment.py, which measured the recipe."""
535+
import numpy as np
536+
537+
EOS_ID, PAD_ID = 1, 0
538+
encode = encode_bytes
539+
540+
past_names = [i.name for i in dec_sess.get_inputs() if i.name.startswith("past_")]
541+
meta = {i.name: i for i in dec_sess.get_inputs()}
542+
present_of = {
543+
o.name.replace("present_", "past_"): o.name
544+
for o in dec_sess.get_outputs()
545+
if o.name.startswith("present_")
546+
}
547+
548+
def run(tokens, hidden, pasts):
549+
feed = {
550+
"input_ids": np.array([tokens], dtype=np.int64),
551+
"encoder_hidden_states": hidden,
552+
}
553+
for n in past_names:
554+
feed[n] = pasts.get(n) if pasts else np.zeros(
555+
(1, meta[n].shape[1], 0, meta[n].shape[3]), dtype=np.float32
556+
)
557+
out = dec_sess.run(None, feed)
558+
names = [o.name for o in dec_sess.get_outputs()]
559+
return dict(zip(names, out, strict=True))
560+
561+
def split(out):
562+
return int(np.argmax(out["logits"][0, -1])), {
563+
k: out[v] for k, v in present_of.items()
564+
}
565+
566+
def trim(pasts, n):
567+
return {k: v[:, :, :n, :].copy() for k, v in pasts.items()}
568+
569+
samples: list[dict] = []
570+
571+
def record(tokens, hidden, pasts):
572+
feed = {"input_ids": np.array([tokens], dtype=np.int64),
573+
"encoder_hidden_states": hidden}
574+
for n in past_names:
575+
feed[n] = pasts.get(n) if pasts else np.zeros(
576+
(1, meta[n].shape[1], 0, meta[n].shape[3]), dtype=np.float32
577+
)
578+
samples.append(feed)
579+
580+
for text in texts:
581+
ids = encode(text)
582+
hidden = enc_sess.run(None, {"input_ids": np.array([ids], dtype=np.int64)})[0]
583+
tok, pasts = split(run([PAD_ID], hidden, None))
584+
record([PAD_ID], hidden, None)
585+
window = []
586+
for _ in range(steps):
587+
window.append(tok)
588+
record([tok], hidden, pasts)
589+
tok, pasts = split(run([tok], hidden, pasts))
590+
if tok == EOS_ID:
591+
break
592+
if len(window) == 8:
593+
seq_len = next(iter(pasts.values())).shape[2]
594+
record(window, hidden, trim(pasts, max(seq_len - len(window), 0)))
595+
window = []
596+
return samples
597+
598+
599+
def quantize_int8_static(
600+
src: Path | str, dst: Path | str, calibration: list[dict],
601+
nodes_to_exclude: list[str] | None = None,
602+
) -> Path:
603+
"""fp32 -> static-int8 (calibrated QUInt8 activations, MatMul-only,
604+
head fp32). Activation scales live in the graph — the export-side
605+
half of TODO.impl/11: quality-clean at full set (4.6241 vs dynamic
606+
4.5701) and +8% CPU decode speed. Note the measured composition:
607+
fp32 encoder + static decoder; an int8-encoder + static-decoder
608+
(browser-size) composition needs its own gate run before shipping.
609+
"""
610+
from onnxruntime.quantization import (
611+
CalibrationDataReader,
612+
QuantFormat,
613+
QuantType,
614+
quantize_static,
615+
)
616+
617+
class _Reader(CalibrationDataReader):
618+
def __init__(self, data):
619+
self.data = list(data)
620+
621+
def get_next(self):
622+
return self.data.pop(0) if self.data else None
623+
624+
def rewind(self):
625+
pass # one pass; the corpus is the calibration set
626+
627+
quantize_static(
628+
str(src),
629+
str(dst),
630+
calibration_data_reader=_Reader(calibration),
631+
quant_format=QuantFormat.QOperator,
632+
activation_type=QuantType.QUInt8,
633+
weight_type=QuantType.QInt8,
634+
op_types_to_quantize=["MatMul"],
635+
nodes_to_exclude=nodes_to_exclude or [],
636+
)
637+
return Path(dst)

0 commit comments

Comments
 (0)