File size: 12,092 Bytes
4f5f309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""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)