maya2-native-fast / tools /maya_export /export_backbone.py
mrwine's picture
Initial release: optimized CPU runtime and converted weights of maya2-native
4f5f309 verified
Raw History Blame Contribute Delete
12.1 kB
"""Fused backbone ONNX: 28 Qwen3 decoder layers + final RMSNorm + cb0 head, with KV cache in/out.
Build (one layer resident at a time, so it fits the 4 GB limit):
1. each layer: transformers Qwen3DecoderLayer loaded from the checkpoint (tools/maya_export/streamed_backbone.py),
exported to ONNX (opset 17, dynamo=False), saved with external data;
for wo8 quantized with MatMulNBitsQuantizer(bits=8, block_size=32, is_symmetric=True, accuracy_level=4).
2. head: final RMSNorm + cb0_head (fp32).
3. merge: graph structure only (weights stay in the per-part data files), tensors renamed, then the weight bytes
are copied into one data file next to the merged model.
Output: work_dir()/assets/v1/backbone_{precision}.onnx + backbone_{precision}.data
Per-part files: work_dir()/backbone_build/{precision}/ (kept for the layer-by-layer reference in the tests).
.venv/bin/python tools/memguard.py --rss-limit-mb 4000 --min-free-mb 5000 -- \
.venv-tts/bin/python -m tools.maya_export.export_backbone --precision wo8
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
import numpy as np
import onnx
import torch
import torch.nn as nn
from onnx import TensorProto, helper
from tools.maya_export.common import ckpt_paths, work_dir
from tools.maya_export.streamed_backbone import StreamedBackbone, shard_files
N_LAYERS = 28
N_KV_HEADS = 8
HEAD_DIM = 128
D_MODEL = 2048
N_CB0 = 2049 # 2048 codes + <aeos>
OPSET = 17
PREFILL = 8 # trace length for the export; dynamic axes cover any T
COPY_CHUNK = 1 << 24
class LayerKV(nn.Module):
"""One decoder layer, KV cache as flat tensors, additive attention mask passed in."""
def __init__(self, layer):
super().__init__()
self.layer = layer
def forward(self, h, cos, sin, mask, past_k, past_v):
from transformers import DynamicCache
cache = DynamicCache()
cache.update(past_k, past_v, 0)
out = self.layer(h, attention_mask=mask, position_ids=None, past_key_values=cache,
use_cache=True, cache_position=None, position_embeddings=(cos, sin))
if isinstance(out, tuple):
out = out[0]
return out, cache.layers[0].keys, cache.layers[0].values
class Head(nn.Module):
def __init__(self, norm: nn.Module, cb0_head: nn.Linear) -> None:
super().__init__()
self.norm = norm
self.cb0_head = cb0_head
def forward(self, hid):
x = self.norm(hid)
return x, self.cb0_head(x)
def masks(T: int, past: int) -> torch.Tensor:
"""Additive causal mask (1, 1, T, past+T): query i sees keys j <= past + i."""
i = torch.arange(T)[:, None]
j = torch.arange(past + T)[None, :]
return torch.zeros(T, past + T).masked_fill(j > past + i, float("-inf"))[None, None]
def rope(rot, T: int, past: int) -> tuple[torch.Tensor, torch.Tensor]:
pos = torch.arange(past, past + T)[None]
return rot(torch.zeros(1, T, 1), pos)
def _export(module: nn.Module, args: tuple, path: Path, **kw) -> None:
with torch.inference_mode():
torch.onnx.export(module, args, str(path), opset_version=OPSET, dynamo=False,
do_constant_folding=True, **kw)
def _save_external(model: onnx.ModelProto, path: Path) -> None:
# onnx appends to an existing data file; remove it so a rebuild does not leave stale bytes behind
path.with_name(path.name + ".data").unlink(missing_ok=True)
onnx.save_model(model, str(path), save_as_external_data=True, all_tensors_to_one_file=True,
location=path.name + ".data", size_threshold=1024) # small tensors inline: ORT shape inference needs them
def _quantize_wo8(model: onnx.ModelProto, exclude: list[str] | None = None) -> onnx.ModelProto:
from onnxruntime.quantization.matmul_nbits_quantizer import MatMulNBitsQuantizer
q = MatMulNBitsQuantizer(model, bits=8, block_size=32, is_symmetric=True, accuracy_level=4,
nodes_to_exclude=exclude or [])
q.process()
return q.model.model
def export_layer(sb: StreamedBackbone, i: int, precision: str, build: Path) -> Path:
sb._load_layer(i)
sb.layer.self_attn.layer_idx = 0 # LayerKV owns a one-slot cache
wrap = LayerKV(sb.layer).eval()
h0 = torch.randn(1, PREFILL, D_MODEL) * 0.02
cos, sin = rope(sb.rotary, PREFILL, 0)
pk = torch.zeros(1, N_KV_HEADS, 0, HEAD_DIM)
pv = torch.zeros(1, N_KV_HEADS, 0, HEAD_DIM)
tmp = build / f"layer_{i:02d}_raw.onnx"
_export(wrap, (h0, cos, sin, masks(PREFILL, 0), pk, pv), tmp,
input_names=["h", "cos", "sin", "mask", "past_k", "past_v"],
output_names=["out", "new_k", "new_v"],
dynamic_axes={"h": {1: "t"}, "cos": {1: "t"}, "sin": {1: "t"}, "mask": {2: "t", 3: "total"},
"past_k": {2: "past"}, "past_v": {2: "past"},
"out": {1: "t"}, "new_k": {2: "total"}, "new_v": {2: "total"}})
model = onnx.load(str(tmp))
if precision == "wo8":
model = _quantize_wo8(model)
out = build / f"layer_{i:02d}.onnx"
_save_external(model, out)
tmp.unlink()
tmp.with_suffix(".onnx.data").unlink(missing_ok=True)
return out
def export_head(sb: StreamedBackbone, build: Path) -> Path:
cb0 = nn.Linear(D_MODEL, N_CB0, bias=False)
cb0.weight.data.copy_(sb.weights.get("backbone.cb0_head.weight").float())
head = Head(sb.norm, cb0).eval()
tmp = build / "head_raw.onnx"
_export(head, (torch.randn(1, PREFILL, D_MODEL),), tmp, input_names=["hid"],
output_names=["hidden", "cb0_logits"],
dynamic_axes={"hid": {1: "t"}, "hidden": {1: "t"}, "cb0_logits": {1: "t"}})
out = build / "head.onnx"
_save_external(onnx.load(str(tmp)), out)
tmp.unlink()
tmp.with_suffix(".onnx.data").unlink(missing_ok=True)
return out
def _rename(model: onnx.ModelProto, prefix: str, mapping: dict[str, str], keep: set[str]):
"""Rename every tensor and node of a part. Names in `mapping` are replaced; names in `keep` are shared
across parts (cos/sin/mask); everything else gets `prefix`."""
def new(name: str) -> str:
if name == "":
return name
if name in mapping:
return mapping[name]
if name in keep:
return name
return prefix + name
g = model.graph
nodes = []
for node in g.node:
n = onnx.NodeProto()
n.CopyFrom(node)
del n.input[:]
n.input.extend(new(x) for x in node.input)
del n.output[:]
n.output.extend(new(x) for x in node.output)
n.name = prefix + node.name if node.name else ""
nodes.append(n)
inits = []
for init in g.initializer:
t = TensorProto()
t.CopyFrom(init)
t.name = new(init.name)
inits.append(t)
return nodes, inits
def merge(layer_paths: list[Path], head_path: Path, out_path: Path, precision: str) -> None:
all_nodes, all_inits, opsets = [], [], {}
for i, path in enumerate(layer_paths):
m = onnx.load(str(path), load_external_data=False)
src_in = "embeds" if i == 0 else f"hid_{i - 1}"
mapping = {"h": src_in, "past_k": f"past_k_{i}", "past_v": f"past_v_{i}",
"out": f"hid_{i}", "new_k": f"present_k_{i}", "new_v": f"present_v_{i}"}
nodes, inits = _rename(m, f"L{i}_", mapping, keep={"cos", "sin", "mask"})
all_nodes += nodes
all_inits += inits
for op in m.opset_import:
opsets[op.domain] = max(opsets.get(op.domain, 0), op.version)
hm = onnx.load(str(head_path), load_external_data=False)
nodes, inits = _rename(hm, "H_", {"hid": f"hid_{len(layer_paths) - 1}", "hidden": "hidden",
"cb0_logits": "cb0_logits"}, keep=set())
all_nodes += nodes
all_inits += inits
for op in hm.opset_import:
opsets[op.domain] = max(opsets.get(op.domain, 0), op.version)
data_name = out_path.with_suffix(".data").name
_consolidate(all_inits, layer_paths + [head_path], out_path.parent, data_name)
inputs = [helper.make_tensor_value_info("embeds", TensorProto.FLOAT, [1, "T", D_MODEL]),
helper.make_tensor_value_info("cos", TensorProto.FLOAT, [1, "T", HEAD_DIM]),
helper.make_tensor_value_info("sin", TensorProto.FLOAT, [1, "T", HEAD_DIM]),
helper.make_tensor_value_info("mask", TensorProto.FLOAT, [1, 1, "T", "total"])]
outputs = [helper.make_tensor_value_info("hidden", TensorProto.FLOAT, [1, "T", D_MODEL]),
helper.make_tensor_value_info("cb0_logits", TensorProto.FLOAT, [1, "T", N_CB0])]
n = len(layer_paths)
for i in range(n):
inputs.append(helper.make_tensor_value_info(f"past_k_{i}", TensorProto.FLOAT, [1, N_KV_HEADS, "P", HEAD_DIM]))
inputs.append(helper.make_tensor_value_info(f"past_v_{i}", TensorProto.FLOAT, [1, N_KV_HEADS, "P", HEAD_DIM]))
outputs.append(helper.make_tensor_value_info(f"present_k_{i}", TensorProto.FLOAT, [1, N_KV_HEADS, "total", HEAD_DIM]))
outputs.append(helper.make_tensor_value_info(f"present_v_{i}", TensorProto.FLOAT, [1, N_KV_HEADS, "total", HEAD_DIM]))
graph = helper.make_graph(all_nodes, f"maya2_backbone_{precision}", inputs, outputs, all_inits)
opset_imports = [helper.make_opsetid(d, v) for d, v in sorted(opsets.items())]
ir_version = onnx.load(str(layer_paths[0]), load_external_data=False).ir_version
model = helper.make_model(graph, opset_imports=opset_imports, ir_version=ir_version)
onnx.save_model(model, str(out_path))
def _consolidate(inits: list[TensorProto], parts: list[Path], out_dir: Path, data_name: str) -> None:
"""Copy every external tensor's bytes into one data file, updating offset and location in place."""
offset = 0
with open(out_dir / data_name, "wb") as dst:
for t in inits:
if t.data_location != TensorProto.EXTERNAL:
continue
entries = {e.key: e.value for e in t.external_data}
loc = entries["location"]
src = _source_file(loc, parts)
length = int(entries["length"])
start = int(entries.get("offset", 0))
with open(src, "rb") as f:
f.seek(start)
remaining = length
while remaining:
chunk = f.read(min(COPY_CHUNK, remaining))
if not chunk:
raise IOError(f"short read for {t.name} in {src}")
dst.write(chunk)
remaining -= len(chunk)
del t.external_data[:]
for k, v in (("location", data_name), ("offset", str(offset)), ("length", str(length))):
e = t.external_data.add()
e.key, e.value = k, v
offset += length
def _source_file(location: str, parts: list[Path]) -> Path:
for p in parts:
if (p.parent / location).exists():
return p.parent / location
raise FileNotFoundError(location)
def build(precision: str) -> Path:
if precision not in ("fp32", "wo8"):
raise ValueError(precision)
build_dir = work_dir() / "backbone_build" / precision
build_dir.mkdir(parents=True, exist_ok=True)
paths = ckpt_paths()
sb = StreamedBackbone(paths["backbone_dir"], shard_files(paths["ckpt"]), n_layers=N_LAYERS)
layer_paths = []
for i in range(N_LAYERS):
layer_paths.append(export_layer(sb, i, precision, build_dir))
print(f"layer {i:02d} {precision} ok", flush=True)
head = export_head(sb, build_dir)
out_dir = work_dir() / "assets" / "v1"
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / f"backbone_{precision}.onnx"
merge(layer_paths, head, out, precision)
print(f"wrote {out} and {out.with_suffix('.data').name}", flush=True)
return out
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--precision", choices=("fp32", "wo8"), required=True)
build(ap.parse_args().precision)