File size: 41,332 Bytes
4d9b003
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
# SPDX-License-Identifier: Apache-2.0
"""C17: conv2d builders for NHWC feature maps (PLAN.md section 1.2 row C17; probes P5 and P14, common/PROBES.md).

A conv layer of a port is built once from host weights and called on device feature maps, eagerly for the warm-up
and then inside trace captures. What every builder here guarantees:

- **Weights prepared once.** The first call hands the host weights to ``ttnn.conv2d`` (which prepares them for the
  chosen sharding and moves them to the device, a host write); the prepared device tensors are kept per input spec
  and reused, so a traced call never writes from the host ("Writes are not supported during trace capture",
  RP section 1.4). Call each layer once eagerly before capturing (``TraceRunner`` warm-up does).
- **Explicit precision.** ``compute_config`` comes from :mod:`..precision` (default :data:`CONV_PRECISION`: HiFi4 +
  fp32 accumulation + packer L1 accumulation, exact SFPU activations; never ttnn's implicit defaults; without the L1
  accumulation the partial sums of wide convs round to bf16 between inner blocks). Weights default to bf16
  (``Precision.weights`` selects bfp8); ``output_dtype`` may be float32 (e.g. heads whose logits feed thresholds).
- **Fused bias + activation** in the conv epilogue: ``relu`` (packer), ``relu6`` / ``silu`` / ``gelu`` /
  ``sigmoid`` (SFPU). RELU6 is verified on the device (P5: output in [0, 6], PCC >= 0.99997). HARDSWISH is refused:
  the fused form is silently skipped on this tree (P5; apply ``ttnn.hardswish`` as a separate op).
- **Grid from the device.** Nothing here hard-codes 12x10 / 11x10: ttnn's auto-sharding reads
  ``device.compute_with_storage_grid_size()``; explicit shard overrides go through ``conv_config``.
- **Memory placement.** ``slicing="auto"`` keeps ttnn's rule: a DRAM input runs the DRAM-sliced path with automatic
  slice counts (P14: every accepted config captures and replays bit-exactly; explicit counts are often rejected, so
  never hard-code them) and writes a DRAM-interleaved output; an L1 input runs the L1 path. 1x1 stride-1 convs are
  matmuls and always run the L1 path; ``output_memory_config`` (default ``"dram"``) brings their output back to DRAM
  so a graph can keep every activation in DRAM between ops (the robust layout of a first port; L1 residency is an
  optimization). Feed large-kernel convs TILE tensors (``[1, 1, N*H*W, C]``, what a producing conv writes): from
  row-major DRAM they cost 2x (P5: 926 vs 482 us for YOLOX's 12 large-kernel convs).

Feature maps travel as :class:`FeatureMap` (device tensor + logical N, H, W, C), because ttnn's flattened
``[1, 1, N*H*W, C]`` layout no longer carries the spatial size.

Builders: :class:`Conv2d` (any kernel / stride / padding / dilation / groups), :class:`KSplitConv`
(``conv(concat(x_1..x_n))`` as ``sum_i conv_i(x_i)`` without building the concat; P14: 1.7-4.1x faster and more
accurate for CenterPoint / PointPainting / FRNet's wide concats), :class:`ConvTranspose2d` for kernel == stride
(P5: k = 2 -> ``ttnn.conv_transpose2d``; k >= 3 -> ``ttnn.linear`` + depth-to-space, 5x faster at k = 4), and the
host helpers :func:`merge_sibling_convs` (one conv for sibling heads that read the same input) /
:func:`split_k_weights`. Glue: :func:`concat_channels`, :func:`split_channels`, :func:`residual_add`,
:func:`apply_activation`, :func:`fmap_from_numpy` / :func:`fmap_to_numpy` (NCHW <-> device NHWC).
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union

import numpy as np

from ..precision import Precision
from ..tensors import dtype_name, round_to_bf16, ttnn_dtype

__all__ = [
    "CONV_PRECISION",
    "FUSED_ACTIVATIONS",
    "BINARY_FUSED_ACTIVATIONS",
    "FeatureMap",
    "Conv2d",
    "KSplitConv",
    "ConvTranspose2d",
    "normalize_activation",
    "unary_with_param",
    "apply_activation",
    "conv_padding",
    "conv_out_hw",
    "merge_sibling_convs",
    "split_k_weights",
    "concat_channels",
    "split_channels",
    "residual_add",
    "fmap_from_numpy",
    "fmap_to_numpy",
    "is_dram",
]

# Default precision of every builder here: HiFi4 + fp32 accumulation + packer L1 accumulation. Without
# ``packer_l1_acc`` a conv whose inner dim spans several blocks keeps its partial sums between blocks in the OUTPUT
# dtype (bf16) (conv2d_op_program_factory_common.cpp:175-178: partials CB = output dtype unless packer_l1_acc), which
# costs accuracy on wide convs; with it the partials stay fp32 (C17 device test, logs/ttaw/c17_c18_device_results.json).
CONV_PRECISION = "HiFi4+fp32+l1acc"

# Activations a conv epilogue applies correctly on this tree (P5 / P14 on the device). HARDSWISH is NOT among them:
# a fused HARDSWISH compiles to nothing (unary_op_utils.cpp:964-965, probe P5) and returns the un-activated result.
FUSED_ACTIVATIONS = ("relu", "relu6", "silu", "gelu", "sigmoid")
# Activations fused into ``ttnn.add(..., activations=[...])`` verified on the device (P5: RELU6 on the clip6 add).
BINARY_FUSED_ACTIVATIONS = ("relu", "relu6")
_REFUSED = {
    "hardswish": "a fused HARDSWISH is silently skipped by ttnn on this tree (probe P5): use activation=None and "
                 "ttnn.hardswish / ttnn.unary_chain([HARDSWISH]) as a separate op",
}

ActivationSpec = Optional[str]


def normalize_activation(activation: Any) -> ActivationSpec:
    """``None`` / ``"none"`` / ``"linear"`` / ``"identity"`` -> None; a fused activation name -> its lower-case name.
    Raises ``ValueError`` for HARDSWISH (silently skipped when fused, P5) and for unknown names."""
    if activation is None:
        return None
    name = str(activation).strip().lower()
    if name in ("", "none", "linear", "identity"):
        return None
    if name in _REFUSED:
        raise ValueError(f"activation {activation!r}: {_REFUSED[name]}")
    if name not in FUSED_ACTIVATIONS:
        raise ValueError(f"activation {activation!r}: expected None or one of {FUSED_ACTIVATIONS}")
    return name


def unary_with_param(activation: Any):
    """The ``ttnn.UnaryWithParam`` of a fused activation (None for no activation). GELU is the exact (erf) form."""
    import ttnn

    name = normalize_activation(activation)
    if name is None:
        return None
    if name == "gelu":
        return ttnn.UnaryWithParam(ttnn.UnaryOpType.GELU, 0.0)
    return ttnn.UnaryWithParam(getattr(ttnn.UnaryOpType, name.upper()))


def apply_activation(tensor: Any, activation: Any):
    """The activation as a separate (unfused) ttnn op; identity for None."""
    import ttnn

    name = normalize_activation(activation)
    if name is None:
        return tensor
    if name == "relu6":
        return ttnn.relu6(tensor)
    if name == "gelu":
        return ttnn.gelu(tensor, fast_and_approximate_mode=False)
    return getattr(ttnn, name)(tensor)


def _pair(value: Union[int, Sequence[int]], what: str) -> Tuple[int, int]:
    if isinstance(value, (int, np.integer)):
        return int(value), int(value)
    v = tuple(int(x) for x in value)
    if len(v) != 2:
        raise ValueError(f"{what} must be an int or a pair, got {value!r}")
    return v


def conv_padding(kernel: Tuple[int, int], padding: Any = "same", dilation: Tuple[int, int] = (1, 1)
                 ) -> Tuple[int, int, int, int]:
    """Padding as (top, bottom, left, right). ``"same"``: ``dilation * (k // 2)`` on every side (odd kernels; the
    "same" padding of stride-1 convs in every model here). An int, a pair (h, w) or a 4-tuple pass through."""
    if isinstance(padding, str):
        if padding != "same":
            raise ValueError(f"padding {padding!r}: expected 'same', an int, a pair or (top, bottom, left, right)")
        if kernel[0] % 2 == 0 or kernel[1] % 2 == 0:
            raise ValueError(f"'same' padding needs odd kernels, got {kernel}")
        ph, pw = dilation[0] * (kernel[0] // 2), dilation[1] * (kernel[1] // 2)
        return ph, ph, pw, pw
    if isinstance(padding, (int, np.integer)):
        p = int(padding)
        return p, p, p, p
    p = tuple(int(x) for x in padding)
    if len(p) == 2:
        return p[0], p[0], p[1], p[1]
    if len(p) == 4:
        return p  # type: ignore[return-value]
    raise ValueError(f"padding {padding!r}: expected 'same', an int, a pair or (top, bottom, left, right)")


def conv_out_hw(hw: Tuple[int, int], kernel: Tuple[int, int], stride: Tuple[int, int],
                padding: Tuple[int, int, int, int], dilation: Tuple[int, int] = (1, 1)) -> Tuple[int, int]:
    """Output size of a convolution (floor), as torch / ttnn compute it."""
    h = (hw[0] + padding[0] + padding[1] - dilation[0] * (kernel[0] - 1) - 1) // stride[0] + 1
    w = (hw[1] + padding[2] + padding[3] - dilation[1] * (kernel[1] - 1) - 1) // stride[1] + 1
    if h <= 0 or w <= 0:
        raise ValueError(f"conv of a {hw} input with kernel {kernel}, stride {stride}, padding {padding} is empty")
    return h, w


def is_dram(tensor_or_config: Any) -> bool:
    """True when a device tensor (or a memory config) lives in DRAM."""
    import ttnn

    mc = tensor_or_config.memory_config() if hasattr(tensor_or_config, "memory_config") else tensor_or_config
    bt = getattr(mc, "buffer_type", None)
    if bt is not None and hasattr(ttnn, "BufferType"):
        return bt == ttnn.BufferType.DRAM
    return mc == ttnn.DRAM_MEMORY_CONFIG


def _memory_config(spec: Any):
    """``"dram"`` / ``"l1"`` / None / a ttnn.MemoryConfig -> a memory config (or None)."""
    import ttnn

    if spec is None:
        return None
    if isinstance(spec, str):
        key = spec.lower()
        if key == "dram":
            return ttnn.DRAM_MEMORY_CONFIG
        if key == "l1":
            return ttnn.L1_MEMORY_CONFIG
        raise ValueError(f"memory config {spec!r}: expected 'dram', 'l1', None or a ttnn.MemoryConfig")
    return spec


def _layout(spec: Any):
    import ttnn

    if isinstance(spec, str):
        key = spec.lower().replace("_layout", "")
        if key == "tile":
            return ttnn.TILE_LAYOUT
        if key in ("row_major", "rm"):
            return ttnn.ROW_MAJOR_LAYOUT
        raise ValueError(f"layout {spec!r}: expected 'tile' or 'row_major'")
    return spec


def _layout_name(spec: Any) -> str:
    """``"tile"`` / ``"row_major"`` of a layout spec (string or ttnn layout), without importing ttnn for strings."""
    if isinstance(spec, str):
        key = spec.lower().replace("_layout", "")
        return "row_major" if key in ("row_major", "rm") else key
    return "tile" if "TILE" in str(spec).upper() else "row_major"


def _as_float32(a: Any, what: str) -> np.ndarray:
    if hasattr(a, "detach"):
        a = a.detach().cpu().float().numpy()
    arr = np.asarray(a, dtype=np.float32)
    if not np.all(np.isfinite(arr)):
        raise ValueError(f"{what} holds non-finite values")
    return np.ascontiguousarray(arr)


def _host_tensor(a: np.ndarray, dtype: str = "bfloat16"):
    """A ROW_MAJOR ttnn *host* tensor (no device involved: only TILE host tensors open the device, probe G4-X).
    The data are copied (ONNX initializers are read-only arrays, which torch warns about)."""
    import torch
    import ttnn

    return ttnn.from_torch(torch.from_numpy(np.array(a, dtype=np.float32, copy=True)), dtype=ttnn_dtype(dtype),
                           layout=ttnn.ROW_MAJOR_LAYOUT)


# --------------------------------------------------------------------------------------------- feature maps

@dataclass(frozen=True)
class FeatureMap:
    """A device feature map: ``tensor`` holds N*H*W rows of C channels (ttnn's flattened ``[1, 1, N*H*W, C]``,
    TILE or ROW_MAJOR; a ``[N, H, W, C]`` ROW_MAJOR tensor is accepted too), plus the logical sizes ttnn's layout
    does not keep."""

    tensor: Any
    batch: int
    height: int
    width: int
    channels: int

    @property
    def rows(self) -> int:
        return self.batch * self.height * self.width

    @property
    def hw(self) -> Tuple[int, int]:
        return self.height, self.width

    def with_tensor(self, tensor: Any, channels: Optional[int] = None) -> "FeatureMap":
        """The same spatial map with another tensor (and channel count)."""
        return FeatureMap(tensor, self.batch, self.height, self.width,
                          self.channels if channels is None else int(channels))

    def deallocate(self) -> None:
        """Free the tensor's device memory (``force=False``: a view's base is left to its owner, probe P2)."""
        import ttnn

        if self.tensor is not None and self.tensor.is_allocated():
            ttnn.deallocate(self.tensor, False)


def fmap_from_numpy(nchw: Any, device: Any, *, dtype: Any = "bfloat16", layout: Any = "tile",
                    memory_config: Any = "dram") -> FeatureMap:
    """Upload an NCHW array as a device feature map ``[1, 1, N*H*W, C]`` (TILE by default). A TILE host tensor
    touches the device: run under ``bin/devrun`` (probe G4-X)."""
    import torch
    import ttnn

    a = _as_float32(nchw, "feature map") if np.asarray(nchw).dtype != np.uint8 else np.asarray(nchw)
    if a.ndim != 4:
        raise ValueError(f"expected NCHW, got shape {a.shape}")
    n, c, h, w = a.shape
    flat = np.ascontiguousarray(a.transpose(0, 2, 3, 1).reshape(1, 1, n * h * w, c))
    t = ttnn.from_torch(torch.from_numpy(flat), dtype=ttnn_dtype(dtype), layout=_layout(layout), device=device,
                        memory_config=_memory_config(memory_config))
    return FeatureMap(t, n, h, w, c)


def fmap_to_numpy(fm: FeatureMap, dtype: Any = np.float32) -> np.ndarray:
    """Read a device feature map back as an NCHW numpy array (blocking; never inside a capture)."""
    import ttnn

    a = ttnn.to_torch(fm.tensor)
    a = a.float().numpy() if a.is_floating_point() else a.numpy()
    a = a.reshape(-1, a.shape[-1])[: fm.rows, : fm.channels]
    return np.ascontiguousarray(a.reshape(fm.batch, fm.height, fm.width, fm.channels).transpose(0, 3, 1, 2),
                                dtype=dtype)


# ------------------------------------------------------------------------------------------------- Conv2d

class Conv2d:
    """``ttnn.conv2d`` with weights prepared by the first call and reused afterwards (see the module docstring).

    Args:
        weight: float array ``[Cout, Cin / groups, kh, kw]`` (OIHW, numpy or torch; BN already folded).
        bias: float array ``[Cout]`` or None.
        stride, dilation: int or pair. ``padding``: ``"same"`` (default), int, pair or (top, bottom, left, right).
        groups: 1 for dense convs; ``groups == Cin`` is a depthwise conv (correct but expanded to dense weights by
            ttnn: P5, 311 MiB for SceneSeg).
        activation: None or one of :data:`FUSED_ACTIVATIONS` (fused in the epilogue).
        precision: a :class:`..precision.Precision` or its spec (default :data:`CONV_PRECISION` = HiFi4 + fp32
            accumulation + packer L1 accumulation; ``Precision.weights`` is the device weight dtype: bf16 or bfp8).
        output_dtype: dtype of the output (``"bfloat16"``; ``"float32"`` for logits that feed thresholds).
        output_layout: ``"tile"`` (default) or ``"row_major"``.
        slicing: ``"auto"`` (ttnn's rule: DRAM input -> automatic DRAM slicing, L1 input -> L1 path), ``"l1"``
            (``Conv2dL1FullSliceConfig``: the op moves the whole input to L1) or ``("height" | "width", n)``
            (explicit DRAM slices; P14 found most explicit counts rejected or slower: diagnostics only).
        output_memory_config: where an L1-path output (1x1 matmul convs, L1 inputs) is placed: ``"dram"``
            (default), ``"l1"``, None (the op's sharded output) or a ttnn.MemoryConfig. DRAM-sliced convs always
            write DRAM interleaved; ``"l1"`` then adds a move.
        conv_config: extra ``ttnn.Conv2dConfig`` fields (``act_block_h_override``, ``shard_layout``,
            ``enable_act_double_buffer``, ``deallocate_activation``, ...): optimization knobs.
        weight_terms: 1 (default) or 2. With 2 the weight and the bias are split into two bf16 terms
            (``hi = bf16(w)``, ``lo = bf16(w - hi)``: about 16 significant bits) run as two convs into float32
            outputs, summed in fp32 with the activation fused into the add, then cast to ``output_dtype``: the conv
            then carries ~fp32 weights and bias for twice the MACs and 3 more programs. The device's own float32
            weight path is no substitute (1.5x less error than bf16, vs 3.5x for the split; YOLOX port,
            logs/yolox/m3_weight_precision_probe.log). Use it where the bf16 rounding of weights and biases, which
            does not average out over a deep network (a per-channel bias error is the same at every pixel),
            decides an output: YOLOX's 16-class mask on public frames (97.5 % -> 99.3 % agreement in emulation).
        name: label for messages and :meth:`describe`.
    """

    def __init__(self, weight: Any, bias: Any = None, *, stride: Any = 1, padding: Any = "same",
                 dilation: Any = 1, groups: int = 1, activation: Any = None, precision: Any = CONV_PRECISION,
                 output_dtype: Any = "bfloat16", output_layout: Any = "tile", slicing: Any = "auto",
                 output_memory_config: Any = "dram", conv_config: Optional[Dict[str, Any]] = None,
                 weight_terms: int = 1, name: str = "conv"):
        w = _as_float32(weight, f"{name}: weight")
        if w.ndim != 4:
            raise ValueError(f"{name}: weight must be [Cout, Cin/groups, kh, kw], got {w.shape}")
        self.name = name
        self.groups = int(groups)
        self.out_channels, cin_g, kh, kw = (int(s) for s in w.shape)
        self.in_channels = cin_g * self.groups
        if self.groups < 1 or self.out_channels % self.groups:
            raise ValueError(f"{name}: groups={groups} does not divide Cout={self.out_channels}")
        self.kernel = (kh, kw)
        self.stride = _pair(stride, "stride")
        self.dilation = _pair(dilation, "dilation")
        self.padding = conv_padding(self.kernel, padding, self.dilation)
        self.activation = normalize_activation(activation)
        self.precision = Precision.parse(precision)
        self.output_dtype = dtype_name(output_dtype)
        self.output_layout = output_layout
        if not (slicing == "auto" or slicing == "l1" or (isinstance(slicing, (tuple, list)) and len(slicing) == 2
                                                         and slicing[0] in ("height", "width"))):
            raise ValueError(f"{name}: slicing {slicing!r}: expected 'auto', 'l1' or ('height' | 'width', n)")
        self.slicing = slicing if isinstance(slicing, str) else (str(slicing[0]), int(slicing[1]))
        self.output_memory_config = output_memory_config
        self.conv_config_overrides = dict(conv_config or {})
        if "activation" in self.conv_config_overrides or "weights_dtype" in self.conv_config_overrides:
            raise ValueError(f"{name}: pass activation / weights dtype as arguments, not in conv_config")
        b = np.zeros(self.out_channels, np.float32) if bias is None else _as_float32(bias, f"{name}: bias")
        if b.shape != (self.out_channels,):
            raise ValueError(f"{name}: bias must have shape ({self.out_channels},), got {b.shape}")
        if int(weight_terms) not in (1, 2):
            raise ValueError(f"{name}: weight_terms must be 1 or 2, got {weight_terms!r}")
        self.weight_terms = int(weight_terms)
        if self.weight_terms == 2 and str(_layout_name(output_layout)) != "tile":
            raise ValueError(f"{name}: weight_terms=2 writes TILE outputs (the fp32 sum is cast in TILE)")
        self._weight = w
        self._bias = b
        self._host: Optional[List[Tuple[Any, Any]]] = None
        self._prepared: Dict[Tuple, Tuple[Any, Any]] = {}

    # ---- geometry
    @property
    def is_matmul(self) -> bool:
        """1x1, stride 1, no padding, no dilation, dense: ttnn runs it as a matmul on the L1 path."""
        return (self.kernel == (1, 1) and self.stride == (1, 1) and self.padding == (0, 0, 0, 0)
                and self.dilation == (1, 1) and self.groups == 1)

    def out_hw(self, height: int, width: int) -> Tuple[int, int]:
        return conv_out_hw((height, width), self.kernel, self.stride, self.padding, self.dilation)

    @property
    def weight(self) -> np.ndarray:
        """The float32 host weight (OIHW)."""
        return self._weight

    @property
    def bias(self) -> np.ndarray:
        return self._bias

    # ---- ttnn arguments
    def host_terms(self) -> List[Tuple[np.ndarray, np.ndarray]]:
        """The float32 (weight, bias) of each term: one term, or ``hi = bf16(x)`` and ``lo = bf16(x - hi)`` (both
        exactly representable in bf16, so the bf16 upload is exact)."""
        if self.weight_terms == 1:
            return [(self._weight, self._bias)]
        hi_w, hi_b = round_to_bf16(self._weight), round_to_bf16(self._bias)
        return [(hi_w, hi_b), (round_to_bf16(self._weight - hi_w), round_to_bf16(self._bias - hi_b))]

    def _host_tensors(self, term: int = 0) -> Tuple[Any, Any]:
        if self._host is None:
            # host tensors in the device weight dtype's host precision: bf16 for bf16 / bfp8 weights (ttnn rounds
            # to bfp8 itself), float32 for float32 weights (a bf16 host tensor would round them first)
            host_dtype = "float32" if self.precision.weights == "float32" else "bfloat16"
            self._host = [(_host_tensor(w, host_dtype), _host_tensor(b.reshape(1, 1, 1, -1), host_dtype))
                          for w, b in self.host_terms()]
        return self._host[term]

    def _conv_config(self, fused: bool = True):
        import ttnn

        kw = dict(self.conv_config_overrides)
        kw["weights_dtype"] = ttnn_dtype(self.precision.weights)
        kw["activation"] = unary_with_param(self.activation) if fused else None
        kw.setdefault("output_layout", _layout(self.output_layout))
        return ttnn.Conv2dConfig(**kw)

    def _slice_config(self):
        import ttnn

        if self.slicing == "auto":
            return None
        if self.slicing == "l1":
            return ttnn.Conv2dL1FullSliceConfig
        kind, num = self.slicing
        slice_type = ttnn.Conv2dDRAMSliceHeight if kind == "height" else ttnn.Conv2dDRAMSliceWidth
        return ttnn.Conv2dSliceConfig(slice_type=slice_type, num_slices=num)

    def _spec_key(self, fm: FeatureMap) -> Tuple:
        t = fm.tensor
        return (fm.batch, fm.height, fm.width, str(t.dtype), str(t.layout), str(t.memory_config()),
                tuple(int(s) for s in t.shape))

    def takes_l1_path(self, x: Any) -> bool:
        """Whether a call on ``x`` (a device tensor) runs ttnn's L1 path (else the DRAM-sliced path)."""
        if self.slicing == "l1" or self.is_matmul:
            return True
        return self.slicing == "auto" and not is_dram(x)

    def __call__(self, x: Union[FeatureMap, Any], *, batch: Optional[int] = None, height: Optional[int] = None,
                 width: Optional[int] = None) -> FeatureMap:
        """Run the conv on a :class:`FeatureMap` (or a raw device tensor plus ``batch`` / ``height`` / ``width``)
        and return the output :class:`FeatureMap` (``[1, 1, N*OH*OW, Cout]``)."""
        import ttnn

        fm = x if isinstance(x, FeatureMap) else FeatureMap(x, int(batch), int(height), int(width), self.in_channels)
        if fm.channels != self.in_channels:
            raise ValueError(f"{self.name}: input has {fm.channels} channels, the conv takes {self.in_channels}")
        key = self._spec_key(fm)
        l1_path = self.takes_l1_path(fm.tensor)
        out_mc = _memory_config(self.output_memory_config)
        split = self.weight_terms == 2
        outs = []
        for term in range(self.weight_terms):
            tkey = key + (term,)
            weight, bias = self._prepared.get(tkey) or self._host_tensors(term)
            out, (oh, ow), (weight, bias) = ttnn.conv2d(
                input_tensor=fm.tensor, weight_tensor=weight, device=fm.tensor.device(),
                in_channels=self.in_channels, out_channels=self.out_channels, batch_size=fm.batch,
                input_height=fm.height, input_width=fm.width, kernel_size=self.kernel, stride=self.stride,
                padding=self.padding, dilation=self.dilation, groups=self.groups, bias_tensor=bias,
                conv_config=self._conv_config(fused=not split), compute_config=self.precision.compute_kernel_config(),
                slice_config=self._slice_config(), memory_config=out_mc if l1_path else None,
                dtype=ttnn.float32 if split else ttnn_dtype(self.output_dtype), return_output_dim=True,
                return_weights_and_bias=True)
            self._prepared[tkey] = (weight, bias)
            outs.append(out)
        if (int(oh), int(ow)) != self.out_hw(fm.height, fm.width):
            raise RuntimeError(f"{self.name}: ttnn returned a {oh}x{ow} output, expected "
                               f"{self.out_hw(fm.height, fm.width)}")
        out = outs[0]
        if split:
            fuse = self.activation in BINARY_FUSED_ACTIVATIONS
            out = ttnn.add(outs[0], outs[1], activations=[unary_with_param(self.activation)] if fuse else [])
            for t in outs:
                ttnn.deallocate(t, False)
            if not fuse:
                out = apply_activation(out, self.activation)
            if self.output_dtype != "float32":
                out = ttnn.typecast(out, ttnn_dtype(self.output_dtype))
        if not l1_path and out_mc is not None and not is_dram(out_mc):
            out = ttnn.to_memory_config(out, out_mc)
        return FeatureMap(out, fm.batch, int(oh), int(ow), self.out_channels)

    @property
    def prepared_specs(self) -> int:
        """Number of input specs this layer holds prepared device weights for."""
        return len({k[:-1] for k in self._prepared})

    def release(self) -> None:
        """Free the prepared device weights (the host weights stay; the next call prepares them again)."""
        import ttnn

        for w, b in self._prepared.values():
            for t in (w, b):
                if t is not None and t.is_allocated():
                    ttnn.deallocate(t)
        self._prepared.clear()

    def describe(self) -> Dict[str, Any]:
        return {"name": self.name, "cin": self.in_channels, "cout": self.out_channels, "kernel": list(self.kernel),
                "stride": list(self.stride), "padding": list(self.padding), "dilation": list(self.dilation),
                "groups": self.groups, "activation": self.activation, "precision": self.precision.label,
                "output_dtype": self.output_dtype, "slicing": self.slicing if isinstance(self.slicing, str)
                else list(self.slicing), "matmul": self.is_matmul, "weight_terms": self.weight_terms}


# ----------------------------------------------------------------------------------- K-split (concat-free)

def split_k_weights(weight: Any, parts: Sequence[int]) -> List[np.ndarray]:
    """Split a conv weight ``[Cout, sum(parts), kh, kw]`` along its input channels: ``conv(concat(x_i), W) ==
    sum_i conv(x_i, W_i)``."""
    w = _as_float32(weight, "weight")
    if w.ndim != 4 or w.shape[1] != sum(int(p) for p in parts):
        raise ValueError(f"weight {w.shape} does not have sum(parts) = {sum(parts)} input channels")
    out, start = [], 0
    for p in parts:
        out.append(np.ascontiguousarray(w[:, start:start + int(p)]))
        start += int(p)
    return out


class KSplitConv:
    """``conv(concat([x_1, ..., x_n], channels))`` computed as ``sum_i conv_i(x_i)`` without building the concat
    (S:centerpoint:448; P14: CenterPoint's shared conv 3.6 vs 14.8 ms and PCC 0.999992 vs 0.999961).

    The bias rides on the first partial conv, the partial sums are added in order, and the activation is fused into
    the last add (RELU / RELU6, P5) or applied after it. ``accumulate_dtype="float32"`` keeps the partial outputs and
    their sums in fp32 and casts to ``output_dtype`` once (more accurate, more traffic); the default ``"bfloat16"``
    rounds each partial (what P14 measured). Every keyword except ``activation`` / ``output_dtype`` /
    ``accumulate_dtype`` goes to the partial :class:`Conv2d` layers."""

    def __init__(self, weight: Any, bias: Any, parts: Sequence[int], *, activation: Any = None,
                 output_dtype: Any = "bfloat16", accumulate_dtype: Any = "bfloat16", name: str = "ksplit",
                 **conv_kwargs: Any):
        if len(parts) < 2:
            raise ValueError(f"{name}: a K-split needs at least two parts")
        self.name = name
        self.parts = tuple(int(p) for p in parts)
        self.activation = normalize_activation(activation)
        self.output_dtype = dtype_name(output_dtype)
        self.accumulate_dtype = dtype_name(accumulate_dtype)
        weights = split_k_weights(weight, self.parts)
        cout = weights[0].shape[0]
        b = np.zeros(cout, np.float32) if bias is None else _as_float32(bias, f"{name}: bias")
        self.convs = [Conv2d(w, b if i == 0 else None, activation=None, output_dtype=self.accumulate_dtype,
                             name=f"{name}.{i}", **conv_kwargs) for i, w in enumerate(weights)]
        self.out_channels = cout

    def __call__(self, inputs: Sequence[FeatureMap]) -> FeatureMap:
        import ttnn

        if len(inputs) != len(self.convs):
            raise ValueError(f"{self.name}: expected {len(self.convs)} inputs, got {len(inputs)}")
        total: Optional[FeatureMap] = None
        n = len(inputs)
        for i, (conv, x) in enumerate(zip(self.convs, inputs)):
            part = conv(x)
            if total is None:
                total = part
                continue
            last = i == n - 1
            fuse = last and self.activation in BINARY_FUSED_ACTIVATIONS
            acts = [unary_with_param(self.activation)] if fuse else []
            summed = ttnn.add(total.tensor, part.tensor, activations=acts)
            for t in (total, part):
                t.deallocate()
            total = total.with_tensor(summed)
            if last and not fuse:
                total = total.with_tensor(apply_activation(total.tensor, self.activation))
        assert total is not None
        if self.accumulate_dtype != self.output_dtype:
            total = total.with_tensor(ttnn.typecast(total.tensor, ttnn_dtype(self.output_dtype)))
        return total

    def release(self) -> None:
        for c in self.convs:
            c.release()

    def describe(self) -> Dict[str, Any]:
        return {"name": self.name, "parts": list(self.parts), "activation": self.activation,
                "accumulate_dtype": self.accumulate_dtype, "convs": [c.describe() for c in self.convs]}


# ----------------------------------------------------------------------------------- sibling merge / glue

def merge_sibling_convs(weights: Sequence[Any], biases: Sequence[Any]) -> Tuple[np.ndarray, np.ndarray, List[int]]:
    """One conv for sibling convs reading the same input (S:centerpoint:450; S:yolox section 8.5 #1): output
    channels concatenated in order -> ``(weight, bias, out_channels_per_sibling)``; split the output with
    :func:`split_channels`. Exact (each output channel keeps its own weights)."""
    ws = [_as_float32(w, "weight") for w in weights]
    bs = [np.zeros(w.shape[0], np.float32) if b is None else _as_float32(b, "bias") for w, b in zip(ws, biases)]
    shapes = {w.shape[1:] for w in ws}
    if len(shapes) != 1:
        raise ValueError(f"sibling convs must share Cin and kernel, got {[w.shape for w in ws]}")
    return (np.ascontiguousarray(np.concatenate(ws, axis=0)), np.ascontiguousarray(np.concatenate(bs)),
            [int(w.shape[0]) for w in ws])


def concat_channels(maps: Sequence[FeatureMap], *, memory_config: Any = "dram") -> FeatureMap:
    """Concatenate feature maps of one spatial size along channels (``ttnn.concat(dim=3)``); every input must have
    the same layout and dtype. TILE inputs with channel counts that are multiples of 32 avoid any re-tiling."""
    import ttnn

    if not maps:
        raise ValueError("concat_channels needs at least one map")
    first = maps[0]
    for m in maps[1:]:
        if (m.batch, m.height, m.width) != (first.batch, first.height, first.width):
            raise ValueError(f"concat of maps of different sizes: {[(m.batch, m.height, m.width) for m in maps]}")
    if len(maps) == 1:
        return first
    t = ttnn.concat([m.tensor for m in maps], dim=3, memory_config=_memory_config(memory_config))
    return first.with_tensor(t, sum(m.channels for m in maps))


def split_channels(fm: FeatureMap, sizes: Sequence[int]) -> List[FeatureMap]:
    """Slice a map's channels into consecutive groups of ``sizes`` (``ttnn.slice`` on the last dim)."""
    import ttnn

    if sum(int(s) for s in sizes) != fm.channels:
        raise ValueError(f"split sizes {list(sizes)} do not add up to {fm.channels} channels")
    shape = [int(s) for s in fm.tensor.shape]
    out, start = [], 0
    for s in sizes:
        begin = [0] * len(shape)
        end = list(shape)
        begin[-1], end[-1] = start, start + int(s)
        out.append(fm.with_tensor(ttnn.slice(fm.tensor, begin, end), int(s)))
        start += int(s)
    return out


def residual_add(a: FeatureMap, b: FeatureMap, activation: Any = None) -> FeatureMap:
    """``a + b`` (same spec), with RELU / RELU6 fused into the add (P5: the clip6 residual of YOLOX) or another
    activation applied after it."""
    import ttnn

    if (a.rows, a.channels) != (b.rows, b.channels):
        raise ValueError(f"residual add of {a.rows}x{a.channels} and {b.rows}x{b.channels}")
    act = normalize_activation(activation)
    if act in BINARY_FUSED_ACTIVATIONS:
        return a.with_tensor(ttnn.add(a.tensor, b.tensor, activations=[unary_with_param(act)]))
    return a.with_tensor(apply_activation(ttnn.add(a.tensor, b.tensor), act))


# ----------------------------------------------------------------------------- ConvTranspose (kernel == stride)

class ConvTranspose2d:
    """ConvTranspose2d with kernel == stride and no padding (the BEV deblocks of CenterPoint / PointPainting /
    TransFusion and SceneSeg's up-sampling layers), weights prepared by the first call.

    ``method="auto"`` follows probe P5: k = 2 -> ``ttnn.conv_transpose2d`` (CP deblock1 0.64 ms vs 2.08 ms for the
    rewrite), k >= 3 -> ``"linear_d2s"`` = ``ttnn.linear(Cin -> k*k*Cout)`` with the bias and activation fused, then
    depth-to-space (``to_layout(ROW_MAJOR)``, ``reshape [N*h, w, k, k, C]``, ``permute (0, 2, 1, 3, 4)``, ``reshape``;
    CP deblock2 k = 4: 1.97 vs 10.27 ms). The linear output channel order is (i, j, c):
    ``W2[cin, (i*k + j)*Cout + c] = W[cin, c, i, j]``. Output: a ROW_MAJOR ``[1, 1, N*h*k*w*k, Cout]`` map for
    ``linear_d2s`` (``output_layout="tile"`` re-tiles it), the conv's TILE output for ``conv_transpose2d``.

    ``weight``: ``[Cin, Cout, k, k]`` (torch ConvTranspose2d layout); ``bias``: ``[Cout]`` or None."""

    METHODS = ("auto", "conv_transpose2d", "linear_d2s")

    def __init__(self, weight: Any, bias: Any = None, *, stride: Optional[int] = None, activation: Any = None,
                 precision: Any = CONV_PRECISION, output_dtype: Any = "bfloat16", method: str = "auto",
                 output_layout: Any = "tile", memory_config: Any = "dram", name: str = "deconv"):
        w = _as_float32(weight, f"{name}: weight")
        if w.ndim != 4 or w.shape[2] != w.shape[3]:
            raise ValueError(f"{name}: weight must be [Cin, Cout, k, k], got {w.shape}")
        self.name = name
        self.in_channels, self.out_channels, self.k = int(w.shape[0]), int(w.shape[1]), int(w.shape[2])
        if stride is not None and int(stride) != self.k:
            raise ValueError(f"{name}: only kernel == stride is supported (k={self.k}, stride={stride})")
        if method not in self.METHODS:
            raise ValueError(f"{name}: method {method!r}: expected one of {self.METHODS}")
        self.method = ("conv_transpose2d" if self.k == 2 else "linear_d2s") if method == "auto" else method
        self.activation = normalize_activation(activation)
        self.precision = Precision.parse(precision)
        self.output_dtype = dtype_name(output_dtype)
        self.output_layout = output_layout
        self.memory_config = memory_config
        b = np.zeros(self.out_channels, np.float32) if bias is None else _as_float32(bias, f"{name}: bias")
        if b.shape != (self.out_channels,):
            raise ValueError(f"{name}: bias must have shape ({self.out_channels},), got {b.shape}")
        self._weight, self._bias = w, b
        self._prepared: Dict[Tuple, Tuple[Any, Any]] = {}
        self._linear: Optional[Tuple[Any, Any]] = None

    def _spec_key(self, fm: FeatureMap) -> Tuple:
        t = fm.tensor
        return (fm.batch, fm.height, fm.width, str(t.dtype), str(t.layout), str(t.memory_config()))

    def linear_weights(self) -> Tuple[np.ndarray, np.ndarray]:
        """Host form of the ``linear_d2s`` weights: ``W2 [Cin, k*k*Cout]`` in (i, j, c) order, bias repeated."""
        k = self.k
        w2 = self._weight.transpose(0, 2, 3, 1).reshape(self.in_channels, k * k * self.out_channels)
        return np.ascontiguousarray(w2), np.ascontiguousarray(np.tile(self._bias, k * k))

    def __call__(self, x: FeatureMap) -> FeatureMap:
        import ttnn

        if x.channels != self.in_channels:
            raise ValueError(f"{self.name}: input has {x.channels} channels, the layer takes {self.in_channels}")
        k = self.k
        if self.method == "conv_transpose2d":
            key = self._spec_key(x)
            # float32 host tensors for float32 weights, as Conv2d (a bf16 host tensor would round them first)
            host_dtype = "float32" if self.precision.weights == "float32" else "bfloat16"
            weight, bias = self._prepared.get(key) or (_host_tensor(self._weight, host_dtype),
                                                       _host_tensor(self._bias.reshape(1, 1, 1, -1), host_dtype))
            conv_config = ttnn.Conv2dConfig(weights_dtype=ttnn_dtype(self.precision.weights),
                                            activation=unary_with_param(self.activation),
                                            output_layout=_layout(self.output_layout))
            out, (weight, bias) = ttnn.conv_transpose2d(
                input_tensor=x.tensor, weight_tensor=weight, device=x.tensor.device(),
                in_channels=self.in_channels, out_channels=self.out_channels, batch_size=x.batch,
                input_height=x.height, input_width=x.width, kernel_size=(k, k), stride=(k, k), padding=(0, 0),
                output_padding=(0, 0), dilation=(1, 1), groups=1, bias_tensor=bias, conv_config=conv_config,
                compute_config=self.precision.compute_kernel_config(), dtype=ttnn_dtype(self.output_dtype),
                mirror_kernel=True, return_weights_and_bias=True)
            self._prepared[key] = (weight, bias)
            return FeatureMap(out, x.batch, x.height * k, x.width * k, self.out_channels)
        # linear + depth-to-space
        import torch

        device = x.tensor.device()
        if self._linear is None:
            w2, b2 = self.linear_weights()
            wdt = ttnn_dtype(self.precision.weights)
            self._linear = (
                ttnn.from_torch(torch.from_numpy(w2), dtype=wdt, layout=ttnn.TILE_LAYOUT, device=device),
                ttnn.from_torch(torch.from_numpy(b2.reshape(1, -1)), dtype=wdt, layout=ttnn.TILE_LAYOUT,
                                device=device))
        weight, bias = self._linear
        t = x.tensor if x.tensor.layout == ttnn.TILE_LAYOUT else ttnn.to_layout(x.tensor, ttnn.TILE_LAYOUT)
        y = ttnn.linear(t, weight, bias=bias, activation=unary_with_param(self.activation),
                        compute_kernel_config=self.precision.compute_kernel_config(),
                        memory_config=_memory_config(self.memory_config), dtype=ttnn_dtype(self.output_dtype))
        y_rm = ttnn.to_layout(y, ttnn.ROW_MAJOR_LAYOUT)
        ttnn.deallocate(y, False)
        c = self.out_channels
        y5 = ttnn.reshape(y_rm, (x.batch * x.height, x.width, k, k, c))
        y5 = ttnn.permute(y5, (0, 2, 1, 3, 4))
        out = ttnn.reshape(y5, (1, 1, x.batch * x.height * k * x.width * k, c))
        if _layout(self.output_layout) == ttnn.TILE_LAYOUT:
            out = ttnn.to_layout(out, ttnn.TILE_LAYOUT)
        return FeatureMap(out, x.batch, x.height * k, x.width * k, c)

    def release(self) -> None:
        import ttnn

        tensors = [t for pair in self._prepared.values() for t in pair] + list(self._linear or ())
        for t in tensors:
            if t is not None and t.is_allocated():
                ttnn.deallocate(t)
        self._prepared.clear()
        self._linear = None

    def describe(self) -> Dict[str, Any]:
        return {"name": self.name, "cin": self.in_channels, "cout": self.out_channels, "k": self.k,
                "method": self.method, "activation": self.activation, "precision": self.precision.label,
                "output_dtype": self.output_dtype}