frnet-p150 / code /tt_frnet /reference /rewrites.py
changh95's picture
tt-model push frnet-p150 (container)
fe51a63 verified
Raw History Blame Contribute Delete
14.7 kB
# SPDX-License-Identifier: Apache-2.0
"""The TT device dataflow of FRNet (PORT_LOG.md "trace layout"), run on the CPU in fp32 and proven equal to the
literal graph (``reference/model.py``) by ``tests/test_reference_rewrites_host.py``. Each rewrite is exact in real
arithmetic; in float32 they differ only by summation order:
- **points sorted by cell and padded** to ``N_cap`` (``host/preprocess.py`` ``FramePlan``): the network is
permutation-equivariant in the points; padded rows form one trailing sentinel segment whose results nothing reads;
- **host features**: the 8-d decoration and ``pre_norm`` in float64, one rounding (``decorate_points``);
- **segmented max** (6 sites: ``ScatterElements(max)``) as the exact **log-step** form of PLAN.md 1.3 (the K1
kernel's stock-op fallback): round ``r`` takes ``x = max(x, x[idx_r])`` with the host shift table ``idx_r``,
then one gather of each segment's last row (``seg_last``) -> ``(M_cap, C)``; :func:`segmax_logstep`;
- **frustum2pixel** (6 ``ScatterND``) as a gather: ``image[pixel] = table[pix2vox_s[pixel]]`` from the voxel table
with one appended **zero row** (index ``M_cap``) for empty pixels -- ``ttaw.ops.gather.scatter_rows`` semantics
(C19); duplicates at s > 1 resolved on the host (last voxel wins);
- **pixel2point** as a gather of NHWC rows (``point2pix_s``);
- **concat-free convs**: ``conv(concat(a, b))`` = ``conv_a(a) + conv_b(b)`` for the fusion convs (256 = 128 + 128)
and the 5-way K-split ``fuse_layer1`` (640 = 5 x 128, probe P14: 8.3 vs 14.2 ms);
- **concat-free point layers**: ``linear(concat(a, b))`` = ``a @ W_a + b @ W_b + bias`` (point stem, point fusion,
``point_fuse_layer1``), which also lets the two halves keep different dtypes;
- **align-corners bilinear** as interpolation matrices ``A_h X A_w^T`` (C18 ``interp_matrix``, float64 -> float32).
:class:`Emulation` optionally rounds layer operands the way the device stores them (bf16, or the TF32-like
mantissa of the device's fp32 matmul, probe P12) -- a first-order CPU model to size the fp32 islands of the
port before the device runs (``code/scripts/precision_study.py``); it models no math-fidelity phases or tile
layouts, so its numbers are indicative only.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Sequence
import numpy as np
import torch
import torch.nn.functional as F
from ..host.preprocess import FramePlan, segmax_rounds, segmax_shift_tables
from ..ttaw.golden import NULL_TAPS, TapRegistry
from ..ttaw.ops.upsample import interp_matrix
from .model import TorchLayers, _t
from .weights import Conv, FRNetWeights, Linear
__all__ = ["Emulation", "round_mode", "segmax_logstep", "FRNetPlanTorch", "plan_taps_to_literal"]
MODES = ("fp32", "bf16", "tf32")
def round_mode(x: torch.Tensor, mode: str) -> torch.Tensor:
"""``fp32``: unchanged; ``bf16``: round to nearest even bfloat16; ``tf32``: truncate to 10 mantissa bits (the
probe P12 truncation model of the device's fp32 matmul operands; the device measured 1.4-1.75x its error)."""
if mode == "fp32":
return x
if mode == "bf16":
return x.to(torch.bfloat16).to(torch.float32)
if mode == "tf32":
return (x.contiguous().view(torch.int32) & ~0x1FFF).view(torch.float32)
raise ValueError(f"unknown rounding mode {mode!r}; one of {MODES}")
@dataclass(frozen=True)
class Emulation:
"""Operand rounding per site (``fp32`` = exact float32). ``conv_act`` / ``conv_weight``: inputs and weights
(+ bias) of the 53 convs; ``point_act`` / ``point_weight``: inputs and weights of the 17 point / voxel linears;
``gather``: rows produced by the embedding gathers (frustum2pixel, pixel2point: ``ttnn.embedding`` tables are
bf16-only, so ``bf16`` is the stock-op device); ``upsample``: the decoder's interpolation operands. ``overrides``:
per-layer modes by layer name (e.g. ``{"head.logits": ("tf32", "fp32")}``: (act, weight))."""
conv_act: str = "fp32"
conv_weight: str = "fp32"
point_act: str = "fp32"
point_weight: str = "fp32"
gather: str = "fp32"
upsample: str = "fp32"
overrides: Dict[str, tuple] = field(default_factory=dict)
@classmethod
def parse(cls, spec: str) -> "Emulation":
"""``"conv_act=bf16,point_act=tf32,gather=bf16"`` (unset sites stay fp32)."""
kw = {}
for part in filter(None, (p.strip() for p in spec.split(","))):
k, _, v = part.partition("=")
if k not in cls.__dataclass_fields__ or k == "overrides" or v not in MODES:
raise ValueError(f"bad emulation item {part!r}")
kw[k] = v
return cls(**kw)
@property
def exact(self) -> bool:
return all(getattr(self, k) == "fp32" for k in ("conv_act", "conv_weight", "point_act", "point_weight",
"gather", "upsample")) and not self.overrides
def label(self) -> str:
items = [f"{k}={getattr(self, k)}" for k in ("conv_act", "conv_weight", "point_act", "point_weight",
"gather", "upsample") if getattr(self, k) != "fp32"]
items += [f"{k}:{v}" for k, v in sorted(self.overrides.items())]
return ",".join(items) or "fp32"
def segmax_logstep(x: torch.Tensor, plan: FramePlan, rounds: Optional[int] = None) -> torch.Tensor:
"""Per-voxel max of the sorted, padded rows ``x`` ``(N_cap, C)`` -> ``(M_cap, C)``: ``rounds`` (default: just
enough for the frame's longest segment) log-step rounds over the host shift tables, then a gather of every
segment's last row (PLAN.md 1.3). Exact (max is order-independent)."""
rounds = segmax_rounds(plan.max_segment_length) if rounds is None else int(rounds)
for idx in segmax_shift_tables(plan.seg_id, rounds):
x = torch.maximum(x, x[_t(idx.astype(np.int64))])
return x[_t(plan.seg_last.astype(np.int64))]
def _with_zero_row(table: torch.Tensor) -> torch.Tensor:
return torch.cat([table, table.new_zeros(1, table.shape[1])], 0)
def _nhwc(x: torch.Tensor) -> torch.Tensor:
_, c, h, w = x.shape
return x.permute(0, 2, 3, 1).reshape(h * w, c)
def _nchw(rows: torch.Tensor, h: int, w: int) -> torch.Tensor:
return rows.reshape(1, h, w, -1).permute(0, 3, 1, 2).contiguous()
class FRNetPlanTorch:
"""``FRNetPlanTorch(weights)(plan, taps)`` -> ``pred_probs`` of the sorted, padded rows ``(N_cap, 27)``
(``plan.unsort`` gives the network-input order). Taps use the names of ``reference/model.py``; point taps are in
sorted padded order, voxel taps have ``M_cap`` rows (:func:`plan_taps_to_literal` aligns them)."""
def __init__(self, weights: FRNetWeights, *, emulation: Optional[Emulation] = None,
rounds: Optional[int] = None):
self.weights = weights
self.layers = TorchLayers(weights)
self.emu = emulation or Emulation()
self.rounds = rounds
H, W = weights.frustum_hw
self._interp = {}
for st in [1] + [s.stride for s in weights.stages]:
ah = interp_matrix(H // st, H, mode="linear", align_corners=True)
aw = interp_matrix(W // st, W, mode="linear", align_corners=True)
self._interp[st] = (_t(ah.astype(np.float32)), _t(aw.astype(np.float32)))
# K-split weights of the concat-consuming layers
self._split_lin = {}
for lin, parts in ((weights.point_stem, (128, 256)), (weights.point_fuse[0], (128,) * 5),
*((st.point_fusion, (128, 128)) for st in weights.stages)):
w_t, b_t, _ = self.layers.lin[lin.name]
if w_t.shape[0] != sum(parts):
raise ValueError(f"{lin.name}: {w_t.shape[0]} inputs, expected {sum(parts)}")
self._split_lin[lin.name] = (list(torch.split(w_t, list(parts), 0)), b_t)
self._split_conv = {}
for conv, parts in ((weights.fusion_stem, (128, 128)), (weights.fuse_layer1, (128,) * 5),
*((st.pixel_fusion, (128, 128)) for st in weights.stages)):
w_t, b_t, stride, pad, act = self.layers.conv[conv.name]
if w_t.shape[1] != sum(parts):
raise ValueError(f"{conv.name}: {w_t.shape[1]} inputs, expected {sum(parts)}")
self._split_conv[conv.name] = (list(torch.split(w_t, list(parts), 1)), b_t, stride, pad, act)
# ---- rounded layer applications ---------------------------------------------------------------------------
def _modes(self, name: str, act: str, weight: str) -> tuple:
return self.emu.overrides.get(name, (act, weight))
def linear(self, parts: Sequence[torch.Tensor], layer: Linear) -> torch.Tensor:
act_m, w_m = self._modes(layer.name, self.emu.point_act, self.emu.point_weight)
if layer.name in self._split_lin:
ws, b = self._split_lin[layer.name]
else:
w, b, _ = self.layers.lin[layer.name]
ws = [w]
if len(parts) != len(ws):
raise ValueError(f"{layer.name}: {len(parts)} input parts for {len(ws)} weight parts")
y = round_mode(b, w_m).expand(parts[0].shape[0], -1).clone()
for x, w in zip(parts, ws):
y = y + round_mode(x, act_m) @ round_mode(w, w_m)
return F.relu(y) if layer.relu else y
def conv(self, parts: Sequence[torch.Tensor], layer: Conv) -> torch.Tensor:
act_m, w_m = self._modes(layer.name, self.emu.conv_act, self.emu.conv_weight)
if layer.name in self._split_conv:
ws, b, stride, pad, act = self._split_conv[layer.name]
else:
w, b, stride, pad, act = self.layers.conv[layer.name]
ws = [w]
if len(parts) != len(ws):
raise ValueError(f"{layer.name}: {len(parts)} input parts for {len(ws)} weight parts")
y = None
for i, (x, w) in enumerate(zip(parts, ws)):
part = F.conv2d(round_mode(x, act_m), round_mode(w, w_m), round_mode(b, w_m) if i == 0 else None,
stride=stride, padding=pad)
y = part if y is None else y + part
if act == "hardswish":
y = F.hardswish(y)
elif act == "sigmoid":
y = torch.sigmoid(y)
return y
def gather(self, table: torch.Tensor, index: np.ndarray) -> torch.Tensor:
return round_mode(table, self.emu.gather)[_t(index.astype(np.int64))]
def upsample(self, x: torch.Tensor, stride: int) -> torch.Tensor:
if stride == 1:
return x
ah, aw = self._interp[stride]
m = self.emu.upsample
x = round_mode(x, m)
return torch.einsum("hi,bcij,wj->bchw", round_mode(ah, m), x, round_mode(aw, m))
# ---- the device graph -------------------------------------------------------------------------------------
def __call__(self, plan: FramePlan, taps: TapRegistry = NULL_TAPS) -> np.ndarray:
return self.forward(plan, taps)
@torch.no_grad()
def forward(self, plan: FramePlan, taps: TapRegistry = NULL_TAPS) -> np.ndarray:
Wt = self.weights
H, W = plan.frustum_hw
if (H, W) != Wt.frustum_hw:
raise ValueError(f"plan grid {plan.frustum_hw} != weights grid {Wt.frustum_hw}")
def tap(name: str, value: torch.Tensor) -> torch.Tensor:
if taps.wants(name):
taps.tap(name, value.numpy())
return value
def to_image(table: torch.Tensor, s: int) -> torch.Tensor:
return _nchw(self.gather(_with_zero_row(table), plan.pix2vox[s]), H // s, W // s)
def to_points(img: torch.Tensor, s: int) -> torch.Tensor:
return self.gather(_nhwc(img), plan.point2pix[s])
x = tap("encoder.input", _t(plan.features))
pf: List[torch.Tensor] = []
for i, lin in enumerate(Wt.ffe):
x = self.linear([x], lin)
pf.append(tap(f"encoder.pf{i}", x))
vmax = tap("encoder.voxel_max", segmax_logstep(x, plan, self.rounds))
vfeat = tap("encoder.voxel_feats", self.linear([vmax], Wt.compression))
img = tap("backbone.frustum", to_image(vfeat, 1))
for c in Wt.stem:
img = self.conv([img], c)
tap("backbone.stem", img)
p = tap("backbone.point_stem", self.linear([to_points(img, 1), pf[-1]], Wt.point_stem))
seg = tap("backbone.segmax0", segmax_logstep(p, plan, self.rounds))
x = tap("backbone.fusion_stem", self.conv([to_image(seg, 1), img], Wt.fusion_stem))
maps, pts_out = [x], [p]
for st in Wt.stages:
li, s = st.index, st.stride
for bi, (c1, c2, ds) in enumerate(st.blocks):
y = self.conv([self.conv([x], c1)], c2)
idt = self.conv([x], ds) if ds is not None else x
x = tap(f"backbone.layer{li}.block{bi}", F.hardswish(y + idt))
tap(f"backbone.layer{li}", x)
p = tap(f"backbone.point_fusion{li}", self.linear([to_points(x, s), p], st.point_fusion))
seg = tap(f"backbone.segmax{li}", segmax_logstep(p, plan, self.rounds))
fuse = tap(f"backbone.pixel_fusion{li}", self.conv([to_image(seg, s), x], st.pixel_fusion))
att = tap(f"backbone.attention{li}", self.conv([self.conv([fuse], st.attention[0])], st.attention[1]))
x = tap(f"backbone.stage{li}", fuse * att + x)
maps.append(x)
pts_out.append(p)
strides = [1] + [st.stride for st in Wt.stages]
ups = [tap(f"decoder.up{k}", self.upsample(mp, s)) for k, (mp, s) in enumerate(zip(maps, strides))]
y = tap("decoder.fuse1", self.conv(ups, Wt.fuse_layer1))
y = tap("decoder.fuse2", self.conv([y], Wt.fuse_layer2))
q = tap("decoder.point_fuse1", self.linear(pts_out, Wt.point_fuse[0]))
q = tap("decoder.point_fuse2", self.linear([q], Wt.point_fuse[1]))
h = to_points(y, 1)
for i, (lin, skip) in enumerate(zip(Wt.head_mlps, (q, pf[2], pf[1], pf[0]))):
h = tap(f"head.mlp{i}", self.linear([h], lin) + skip)
logits = tap("head.logits", self.linear([h], Wt.conv_seg))
return tap("pred_probs", torch.softmax(logits, -1)).numpy()
def plan_taps_to_literal(name: str, value: np.ndarray, plan: FramePlan) -> np.ndarray:
"""A tap of :class:`FRNetPlanTorch` in the layout of ``reference/model.py``: point tensors (``N_cap`` rows)
unsorted to the network-input order, voxel tensors (``M_cap`` rows) cut to ``M``, maps unchanged."""
v = np.asarray(value)
if v.ndim == 2 and v.shape[0] == plan.n_cap:
return plan.unsort(v)
if v.ndim == 2 and v.shape[0] == plan.m_cap:
return v[: plan.m]
return v