File size: 12,694 Bytes
51defdc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# SPDX-License-Identifier: Apache-2.0
"""Weights of ``meteor_v157c3Z.onnx`` (AutowareFoundation/meteor@v1.0), read the way the TT port reads them.

The ONNX is parsed as data with the vendored ``ttaw.weights.OnnxWeights`` (C04): nothing is executed, and every
parameter is addressed through the graph node that consumes it, so convolution attributes (strides, pads, dilations,
groups) come from the deployed graph itself, never from re-typed constants. Module names are the ONNX scopes without
the ``/net/`` prefix and the op suffix, e.g. ``seg_head/d1/d1.3/d1.3.0`` for ``/net/seg_head/d1/d1.3/d1.3.0/Conv``.

BatchNorm: the export folded every BatchNorm2d of the network into its convolution (SPEC section 4.2, "Every BN is
folded into the conv in the ONNX"; the stem equals ``meteor_v157.pt`` conv1 + BN to 2e-8, S:113), and the graph has
no BatchNormalization node left (checked by :meth:`MeteorWeights.check_graph`). The port therefore uses the conv
weights as stored and folds nothing itself: BN folding happens exactly where the TT port folds it, i.e. nowhere.
The exact rewrites the port applies (the /255 or ImageNet scale into the stem, merged sibling heads, the paint
gather as a 21->96 1x1 conv, tgate + tfuse merge, the zero ``mot`` channels of traj_stem dropped) are proven on the
CPU in ``reference.port_form``.

Besides convolutions and Gemms the graph carries a few non-conv constants the reference reads by consuming node:
the attention-pool parameters (``ego_attn``, ``agent_attn``: packed in-projections sliced by ``Slice`` nodes, learned
queries expanded by ``Expand``), the e2e LayerNorm, the two learned gates (``risk_gate`` -> ``/net/Mul_19``,
``dec_gate`` -> ``/net/Tanh``), the constant adaptive-pooling matrices (``MatMul`` operands), the 0.4 m lift ground
points (``onnx::Expand_1643`` [1, 4, 100000]) and the painted seg2d classes (``/net/Gather`` indices).
"""
from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Tuple

import numpy as np

from ..ttaw.weights import ConvParams, GemmParams, OnnxWeights, file_sha256
from . import config as C

__all__ = ["MeteorWeights", "MHAParams", "find_onnx", "conv_node", "gemm_node", "POOL_NODES"]

NET = "/net/"


def conv_node(module: str) -> str:
    return f"{NET}{module}/Conv"


def gemm_node(module: str) -> str:
    return f"{NET}{module}/Gemm"


# constant adaptive-pool / mean matrices: name -> (MatMul node, constant slot). Windows follow
# adaptive_avg_pool2d: [floor(i n / m), ceil((i + 1) n / m)) (export_onnx.py:53-104; S:356-366)
POOL_NODES: Dict[str, Tuple[str, int]] = {
    "ego_stem_w": ("/net/ego_stem/ego_stem.15/MatMul", 1),       # [16, 1]   global mean over 25x16
    "ego_stem_h": ("/net/ego_stem/ego_stem.15/MatMul_1", 0),     # [1, 25]
    "tl_w": ("/net/tl_head/tl_head.8/MatMul", 1),                # [48, 1]   global mean over 27x48
    "tl_h": ("/net/tl_head/tl_head.8/MatMul_1", 0),              # [1, 27]
    "fused_pool_w": ("/net/MatMul_1", 1),                        # [500, 16] fused 800x500 -> 25x16 (ego_attn keys)
    "fused_pool_h": ("/net/MatMul_2", 0),                        # [25, 800]
    "sem_w": ("/net/sem_ego/sem_ego.9/MatMul", 1),               # [16, 4]   25x16 -> 5x4
    "sem_h": ("/net/sem_ego/sem_ego.9/MatMul_1", 0),             # [5, 25]
    "det_pool_w": ("/net/MatMul_3", 1),                          # [250, 16] det_feat 400x250 -> 25x16 (agent keys)
    "det_pool_h": ("/net/MatMul_4", 0),                          # [25, 400]
    "fused_mean_w": ("/net/MatMul_5", 1),                        # [500, 1]  mean(fused) for dec_head
    "fused_mean_h": ("/net/MatMul_6", 0),                        # [1, 800]
    "e2e_mean_w": ("/net/refiner/e2e/MatMul", 1),                # [500, 1]  the same mean for the e2e refiner
    "e2e_mean_h": ("/net/refiner/e2e/MatMul_1", 0),              # [1, 800]
}


@dataclass(frozen=True)
class MHAParams:
    """``nn.MultiheadAttention`` exported by torch 2.1: packed in-projection (q | k | v rows), out-projection."""

    in_weight: np.ndarray          # [3E, E]
    in_bias: np.ndarray            # [3E]
    out_weight: np.ndarray         # [E, E] (Gemm transB=1: y = x W^T + b)
    out_bias: np.ndarray           # [E]
    num_heads: int
    scale_divisor: float           # q is divided by sqrt(E / heads) (the graph's Div constant)

    @property
    def embed_dim(self) -> int:
        return int(self.out_weight.shape[0])


def find_onnx(weights_dir: Optional[os.PathLike] = None) -> Path:
    """The ONNX file in ``weights_dir`` (a snapshot of AutowareFoundation/meteor) or, for workspace runs,
    ``$METEOR_WEIGHTS_DIR`` or ``assets/meteor/hf_meteor`` of the workspace."""
    cands: List[Path] = []
    if weights_dir is not None:
        cands.append(Path(weights_dir))
    if os.environ.get("METEOR_WEIGHTS_DIR"):
        cands.append(Path(os.environ["METEOR_WEIGHTS_DIR"]))
    here = Path(__file__).resolve()
    for parent in here.parents:
        if (parent / "assets" / "meteor" / "hf_meteor").is_dir():
            cands.append(parent / "assets" / "meteor" / "hf_meteor")
            break
    for c in cands:
        p = c if c.suffix == ".onnx" else c / C.ONNX_FILE
        if p.is_file():
            return p
    raise FileNotFoundError(f"{C.ONNX_FILE} not found in {[str(c) for c in cands]}")


class MeteorWeights:
    """The deployed graph's parameters, addressed by consuming node (see the module docstring)."""

    def __init__(self, path: Optional[os.PathLike] = None, *, verify_sha256: bool = False):
        self.path = find_onnx(path) if (path is None or Path(path).is_dir()) else Path(path)
        if verify_sha256 and file_sha256(self.path) != C.ONNX_SHA256:
            raise ValueError(f"{self.path} is not the pinned {C.ONNX_FILE} (sha256 {C.ONNX_SHA256[:12]}...)")
        self.onnx = OnnxWeights(self.path)

    # ---- generic access ------------------------------------------------------------------------------------------
    @property
    def sha256(self) -> str:
        return self.onnx.sha256

    def conv(self, module: str) -> ConvParams:
        return self.onnx.conv(conv_node(module))

    def gemm(self, module: str) -> GemmParams:
        return self.onnx.gemm(gemm_node(module))

    def conv_modules(self) -> List[str]:
        """Every Conv of the graph by module name, in graph order (190)."""
        return [n.name[len(NET):-len("/Conv")] for n in self.onnx.nodes("Conv")]

    def gemm_modules(self) -> List[str]:
        return [n.name[len(NET):-len("/Gemm")] for n in self.onnx.nodes("Gemm")]

    def iter_convs(self) -> Iterator[Tuple[str, ConvParams]]:
        for m in self.conv_modules():
            yield m, self.conv(m)

    def const(self, node: str, slot: int) -> np.ndarray:
        """The constant feeding input ``slot`` of ``node`` (a full node name, e.g. ``/net/Mul_19``)."""
        return self.onnx.param(node, slot)

    # ---- named non-conv parameters ---------------------------------------------------------------------------------
    def pool_matrix(self, name: str) -> np.ndarray:
        node, slot = POOL_NODES[name]
        return self.const(node, slot)

    def mha(self, name: str) -> MHAParams:
        """``ego_attn`` (E 96, 4 heads, 3 queries) or ``agent_attn`` (E 128, 4 heads, 4 queries)."""
        p = f"{NET}{name}/"
        w_q = self.const(p + "Slice_1", 0)            # the packed [3E, E] in_proj_weight, sliced into q and k|v
        w_kv = self.const(p + "Slice_2", 0)
        b_q = self.const(p + "Slice_3", 0)
        b_kv = self.const(p + "Slice_4", 0)
        if w_q is not w_kv and not np.array_equal(w_q, w_kv):
            raise ValueError(f"{name}: the q and k|v slices do not read one packed in-projection")
        if not np.array_equal(b_q, b_kv):
            raise ValueError(f"{name}: the q and k|v bias slices do not read one packed bias")
        out = self.gemm(name)
        div = float(self.const(p + "Div_1", 1))
        heads = int(self.const(p + "Div", 1))         # head dim = E // heads (the graph's /Div by 4)
        e = int(out.weight.shape[0])
        if abs(div - np.float32(np.sqrt(e / heads))) > 1e-6:
            raise ValueError(f"{name}: q scale divisor {div} != sqrt({e}/{heads})")
        return MHAParams(np.asarray(w_q, np.float32), np.asarray(b_q, np.float32), np.asarray(out.weight, np.float32),
                         np.asarray(out.bias, np.float32), heads, div)

    def queries(self, name: str) -> np.ndarray:
        """Learned attention-pool queries: ``ego`` [1, 3, 96] (``/net/Expand_1``), ``agent`` [1, 4, 128]
        (``/net/Expand_2``)."""
        node = {"ego": "/net/Expand_1", "agent": "/net/Expand_2"}[name]
        return self.const(node, 0)

    def layer_norm(self) -> Tuple[np.ndarray, np.ndarray, float]:
        node = self.onnx.node(f"{NET}refiner/e2e/norm/LayerNormalization")
        return (self.const(node.name, 1), self.const(node.name, 2), float(node.attrs.get("epsilon", 1e-5)))

    def risk_gate(self) -> float:
        """``net.risk_gate`` (2.2981195, S:120): mode logits -= risk_gate * mean_t sigmoid(risk)(path_t)."""
        return float(self.const("/net/Mul_19", 0).reshape(()))

    def dec_gate(self) -> float:
        """``net.dec_gate`` (0.0336921, S:120): ego[:36] += tanh(dec_gate) * (wp_dec - ego[:36])."""
        return float(self.const("/net/Tanh", 0).reshape(()))

    def lift_ground_points(self) -> np.ndarray:
        """The constant [1, 4, 100000] homogeneous ground points (x, y, 0, 1) of the 400 x 250 lift grid."""
        return self.const("/net/Expand", 0)

    def paint_classes(self) -> Tuple[int, ...]:
        return tuple(int(i) for i in self.const("/net/Gather", 1).reshape(-1))

    def zero_constants(self) -> Dict[str, np.ndarray]:
        """The baked-out history (``mot``) constants that the graph subtracts / multiplies (both all-zero)."""
        return {"hist": self.const("/net/Sub_5", 1), "mask": self.const("/net/Mul_14", 1)}

    # ---- graph check ----------------------------------------------------------------------------------------------
    def check_graph(self) -> Dict[str, int]:
        """Fail loudly unless the file is the graph this port reproduces (ops, attributes, constants)."""
        o = self.onnx
        problems: List[str] = []
        ops: Dict[str, int] = {}
        for n in o.nodes():
            ops[n.op_type] = ops.get(n.op_type, 0) + 1
        expect = {"Conv": 190, "Gemm": 15, "GridSample": 3, "Resize": 16, "Softmax": 7, "ArgMax": 4,
                  "LayerNormalization": 1, "AveragePool": 3, "MaxPool": 1, "GatherElements": 2, "CumSum": 2}
        for op, cnt in expect.items():
            if ops.get(op, 0) != cnt:
                problems.append(f"{ops.get(op, 0)} {op} nodes, expected {cnt}")
        if ops.get("BatchNormalization", 0):
            problems.append("BatchNormalization nodes present (the reference assumes a fully folded export)")
        for n in o.nodes("Resize"):
            a = n.attrs
            if a.get("mode") != "linear" or a.get("coordinate_transformation_mode") != "half_pixel":
                problems.append(f"{n.name}: Resize {a.get('mode')}/{a.get('coordinate_transformation_mode')}")
        for n in o.nodes("GridSample"):
            want = "border" if n.name == "/net/GridSample_2" else "zeros"
            a = n.attrs
            if (a.get("mode"), a.get("padding_mode"), int(a.get("align_corners", 0))) != ("bilinear", want, 0):
                problems.append(f"{n.name}: GridSample {a}")
        for n in o.nodes("ArgMax"):
            if int(n.attrs.get("select_last_index", 0)) != 0:
                problems.append(f"{n.name}: select_last_index")
        if self.paint_classes() != C.PAINT_CLASSES:
            problems.append(f"paint classes {self.paint_classes()} != {C.PAINT_CLASSES}")
        g, b, eps = self.layer_norm()
        if g.shape != (139,) or abs(eps - C.E2E_LN_EPS) > 1e-9:
            problems.append(f"e2e LayerNorm {g.shape} eps {eps}")
        z = self.zero_constants()
        if any(np.any(v != 0) for v in z.values()):
            problems.append("the baked history constants are not all zero")
        pts = self.lift_ground_points()
        if pts.shape != (1, 4, C.LIFT_H * C.LIFT_W):
            problems.append(f"lift points {pts.shape}")
        for name, (node, slot) in POOL_NODES.items():
            if not o.has(o.node(node).inputs[slot]):
                problems.append(f"pool matrix {name}: {node} slot {slot} is not constant")
        if problems:
            raise ValueError(f"{self.path.name} is not the graph this port reproduces: " + "; ".join(problems))
        return ops