Source code for embedl_deploy._internal.lattice.modules.conv

# Copyright (C) 2026 Embedl AB

"""Lattice-conforming convolution modules.

Lattice hardware accelerators support a restricted set of convolution
parameters. ``LatticeConv2d`` subclasses ``Conv2d`` and snaps an arbitrary
source convolution to the closest configuration allowed by the hardware.
"""

from typing import cast

import torch
import torch.nn.functional as F
from torch import nn

from embedl_deploy._internal.core.modules import FusedModule
from embedl_deploy._internal.core.quantize.config import Precision
from embedl_deploy._internal.core.quantize.stubs import WeightFakeQuantize
from embedl_deploy._internal.lattice.modules.activation import (
    LatticeActivationLike,
)
from embedl_deploy._internal.lattice.modules.quant import LatticeQuant


def _as_pair(value: int | tuple[int, ...]) -> tuple[int, int]:
    """Return `value` as a 2-tuple, broadcasting scalars.

    :param value:
        Value to convert. If an integer, it is broadcast to both
        spatial dimensions; if a tuple, it must have two entries.
    :returns:
        A 2-tuple of integers.
    """
    if isinstance(value, tuple):
        return (value[0], value[1])
    return (value, value)  # pragma: no cover


def _snap(value: int, allowed: tuple[int, ...]) -> int:
    """Return the entry in `allowed` closest to `value`.

    :param value:
        Value to snap.
    :param allowed:
        Allowed values to snap to.
    :returns:
        The entry in `allowed` closest to `value`. In case of ties, the
        smaller value is preferred.
    """
    return min(allowed, key=lambda v: (abs(v - value), v))


def _conv_weight_forward(
    conv: nn.Conv2d,
    weight_fake_quant: WeightFakeQuantize,
    x: torch.Tensor,
) -> torch.Tensor:
    """Run a convolution, fake-quantizing the weight when enabled."""
    # Skip fake-quantize during ONNX export: torch.fake_quantize_* would be
    # lowered to QuantizeLinear/DequantizeLinear, which are incorrect for the
    # Lattice ONNX format.  The toolchain quantizes weights independently.
    weight = (
        conv.weight
        if torch.onnx.is_in_onnx_export()
        else weight_fake_quant(conv.weight)
    )
    # pylint: disable-next=not-callable
    return F.conv2d(
        x,
        weight,
        conv.bias,
        conv.stride,
        conv.padding,
        conv.dilation,
        conv.groups,
    )


[docs] class LatticeConv2d(nn.Conv2d): """``Conv2d`` snapped to Lattice's supported set. Lattice hardware accepts only ``1×1`` and ``3×3`` convolutions with stride 1 or 2 (and stride 1 is mandatory for the ``1×1`` kernel). The constructor takes an arbitrary source :class:`~torch.nn.Conv2d` and forwards its ``in_channels``, ``out_channels``, ``dilation``, ``groups``, and bias presence; kernel size and stride are snapped to the nearest support values. A ``1×1`` kernel is promoted to ``3×3`` whenever its stride exceeds 1. Padding is set to ``kernel_size // 2`` on each spatial axis to preserve the output shape under the common ``"same"``-style convention used by ResNet-family stems and downsamples. Weights and bias are copied from the source convolution whenever the snapped weight tensor has the same shape as the source's (i.e., only stride and/or padding changed). If the kernel size was snapped — and the weight tensor shape therefore changed — the instance keeps freshly initialized weights, since there is no well-defined way to reuse the original kernel values. """ #: Permitted spatial kernel sizes. KERNEL_SIZES: tuple[int, ...] = (1, 3) #: Permitted spatial strides. STRIDES: tuple[int, ...] = (1, 2)
[docs] @classmethod def snapped_params( cls, conv: nn.Conv2d ) -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]: """Return ``(kernel_size, stride, padding)`` after Lattice snapping. :param conv: Source convolution whose parameters are snapped to the nearest values accepted by Lattice hardware. :returns: A three-tuple ``(kernel_size, stride, padding)`` of the snapped parameters where each element is itself an ``(h, w)`` pair. """ kh, kw = _as_pair(conv.kernel_size) sh, sw = _as_pair(conv.stride) new_sh = _snap(sh, cls.STRIDES) new_sw = _snap(sw, cls.STRIDES) new_kh = _snap(kh, cls.KERNEL_SIZES) new_kw = _snap(kw, cls.KERNEL_SIZES) if new_kh == 1 and new_sh != 1: new_kh = 3 if new_kw == 1 and new_sw != 1: new_kw = 3 return ( (new_kh, new_kw), (new_sh, new_sw), (new_kh // 2, new_kw // 2), )
[docs] @classmethod def is_compatible(cls, conv: nn.Conv2d) -> bool: """Return ``True`` when `conv` already matches Lattice's supported set. A convolution is compatible when its kernel size, stride, and padding equal what ``snapped_params`` would return for it. :param conv: Convolution to check. :returns: ``True`` when `conv` already conforms to Lattice constraints; ``False`` otherwise. """ kernel_size, stride, padding = cls.snapped_params(conv) return ( _as_pair(conv.kernel_size) == kernel_size and _as_pair(conv.stride) == stride and _as_pair(conv.padding) == padding # type: ignore[arg-type] )
def __init__(self, conv: nn.Conv2d) -> None: """Create a ``LatticeConv2d`` from an arbitrary ``Conv2d``. :param conv: Source convolution. Its ``in_channels``, ``out_channels``, ``dilation``, ``groups``, and bias presence are forwarded unchanged; kernel size, stride, and padding are snapped to Lattice's supported set. """ kernel_size, stride, padding = self.snapped_params(conv) super().__init__( conv.in_channels, conv.out_channels, kernel_size=kernel_size, stride=stride, padding=padding, dilation=_as_pair(conv.dilation), groups=conv.groups, bias=conv.bias is not None, ) # Move to the same device/dtype as the source conv before copying. self.to(device=conv.weight.device, dtype=conv.weight.dtype) # Preserve weights/bias when the snapped weight tensor has the # same shape as the source's (i.e., only stride/padding # changed). If the kernel size was snapped, weight shapes # differ and we cannot meaningfully reuse the source kernel. if self.weight.shape == conv.weight.shape: with torch.no_grad(): self.weight.copy_(conv.weight) if conv.bias is not None and self.bias is not None: self.bias.copy_(conv.bias)
[docs] class LatticeConv2dAdvanced(LatticeConv2d): """``Conv2d`` snapped to Lattice's advanced-kernel set. Lattice advanced hardware supports ``1×1``, ``3×3``, ``5×5``, and ``7×7`` convolutions. ``3×3`` allows stride 1 or 2; all other kernel sizes require stride 1. Padding is always ``kernel_size // 2``. Like :class:`LatticeConv2d`, weights and bias are preserved when only stride and/or padding changed, and freshly initialized when the kernel size was snapped. """ #: Permitted spatial kernel sizes. KERNEL_SIZES: tuple[int, ...] = (1, 3, 5, 7)
[docs] @classmethod def snapped_params( cls, conv: nn.Conv2d ) -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]: """Return ``(kernel_size, stride, padding)`` after snapping. Kernel size is snapped to the nearest square kernel from the supported set (``1×1``, ``3×3``, ``5×5``, ``7×7``) using the larger spatial dimension. Only ``3×3`` allows stride 2; all other kernel sizes are restricted to stride 1. """ kh, kw = _as_pair(conv.kernel_size) sh, sw = _as_pair(conv.stride) new_k = _snap(max(kh, kw), cls.KERNEL_SIZES) # Only 3×3 allows stride 2; all other kernels require stride 1. allowed_strides = (1, 2) if new_k == 3 else (1,) new_s = _snap(max(sh, sw), allowed_strides) return ( (new_k, new_k), (new_s, new_s), (new_k // 2, new_k // 2), )
def _validate_conv_params( conv: nn.Conv2d, name: str, allowed_kernels: tuple[int, ...], allowed_strides: tuple[int, ...], ) -> None: """Shared validation for Lattice CBSR convolution parameters.""" kh, kw = _as_pair(conv.kernel_size) sh, sw = _as_pair(conv.stride) dh, dw = _as_pair(conv.dilation) if kh != kw: raise ValueError( f"{name}: unsupported non-square kernel_size ({kh}, {kw}). " f"Supported kernels are square with sizes: {allowed_kernels}." ) if sh != sw: raise ValueError( f"{name}: unsupported non-square stride ({sh}, {sw}). " f"Supported strides are square with sizes: {allowed_strides}." ) if kh not in allowed_kernels or kw not in allowed_kernels: raise ValueError( f"{name}: unsupported kernel_size ({kh}, {kw}). " f"Supported sizes: {allowed_kernels}." ) if sh not in allowed_strides or sw not in allowed_strides: raise ValueError( f"{name}: unsupported stride ({sh}, {sw}). " f"Supported strides: {allowed_strides}." ) if kh != 3 and sh != 1: raise ValueError( f"{name}: kernel {kh}x{kw} requires stride 1, got ({sh}, {sw})." ) if kw != 3 and sw != 1: # pragma: no cover — redundant with kh check raise ValueError( f"{name}: kernel {kh}x{kw} requires stride 1, got ({sh}, {sw})." ) if dh != 1 or dw != 1: raise ValueError( f"{name}: unsupported dilation ({dh}, {dw}). " f"Only dilation=1 is supported." ) def validate_conv(conv: nn.Conv2d) -> None: """Raise ``ValueError`` when `conv` is outside the supported set. Lattice CBSR blocks support only ``1×1`` and ``3×3`` kernels with stride 1 or 2, and dilation must be 1. A ``1×1`` kernel with stride != 1 is not allowed. """ _validate_conv_params( conv, "LatticeCBSR", LatticeConv2d.KERNEL_SIZES, LatticeConv2d.STRIDES )
[docs] class LatticeCBSR(FusedModule): """Fused ``Conv2d -> [BatchNorm2d] -> [Activation]`` for Lattice hardware. The Lattice accelerator implements convolution, optional batch normalization, and optional activation as a single fused CBSR block. When an activation is present it must be ``ReLU`` or ``LatticeLeakyReLU`` (negative slope fixed at 1/16). This module validates that the convolution parameters are within the hardware's supported set and raises :class:`ValueError` otherwise. Supported convolution parameters: - Kernel sizes: ``1×1``, ``3×3`` - Strides: ``1``, ``2`` (stride > 1 requires ``3×3`` kernel) - Dilation: ``1`` only :param conv: The convolution layer to fuse. :param bn: Optional batch normalization layer. :param act: Optional ``ReLU`` or ``LatticeLeakyReLU`` activation. :param output_precision: Precision for the output quantizer. :param output_fixed_calibration: Optional fixed ``(scale, zero_point)`` pair for the output quantizer. :raises ValueError: If `conv` has unsupported kernel size, stride, or dilation. """ inputs_to_quantize: set[int] = set() def __init__( self, conv: LatticeConv2d, bn: nn.BatchNorm2d | None = None, act: LatticeActivationLike | None = None, *, output_precision: Precision = Precision.INT8, output_fixed_calibration: tuple[float, int] | None = None, ) -> None: super().__init__() self._validate_conv(conv) self.conv = conv self.bn = bn self.act = act self.weight_fake_quant = WeightFakeQuantize({self}) self.output_quant_stub = LatticeQuant( {self}, precision=output_precision, fixed_calibration=output_fixed_calibration, ) def _validate_conv(self, conv: nn.Conv2d) -> None: validate_conv(conv) @property def quantized_weight(self) -> torch.Tensor | None: return self.conv.weight
[docs] def forward(self, x: torch.Tensor) -> torch.Tensor: """Apply ``conv -> [bn] -> [act]``.""" x = _conv_weight_forward(self.conv, self.weight_fake_quant, x) if self.bn is not None: x = cast(torch.Tensor, self.bn(x)) if self.act is not None: x = cast(torch.Tensor, self.act(x)) return x
def __repr__(self) -> str: bn_info = "" if self.bn is not None: bn_info = f", bn={self.bn.num_features} (foldable)" return ( f"{type(self).__name__}(" f"{self.conv.in_channels}->{self.conv.out_channels}, " f"k={self.conv.kernel_size}, s={self.conv.stride}" f"{bn_info})" )
def validate_conv_advanced(conv: nn.Conv2d) -> None: """Raise ``ValueError`` when `conv` is outside the advanced set. Lattice advanced CBSR blocks support ``1×1``, ``3×3``, ``5×5``, and ``7×7`` kernels. Only ``3×3`` allows stride 2; all other kernel sizes require stride 1. Dilation must be 1. """ _validate_conv_params( conv, "LatticeCBSRAdvanced", LatticeConv2dAdvanced.KERNEL_SIZES, LatticeConv2dAdvanced.STRIDES, )
[docs] class LatticeCBSRAdvanced(LatticeCBSR): """Fused ``Conv2d -> [BatchNorm2d] -> [Activation]`` for advanced CNN IP. Like :class:`~embedl_deploy._internal.lattice.modules.conv.LatticeCBSR` but accepts the broader kernel set supported by advanced Lattice accelerators: ``1×1``, ``3×3``, ``5×5``, and ``7×7``. Only ``3×3`` allows stride 2; all other kernel sizes require stride 1. Dilation must be 1. :param conv: The convolution layer to fuse. :param bn: Optional batch normalization layer. :param act: Optional ``ReLU`` or ``LatticeLeakyReLU`` activation. :param output_precision: Precision for the output quantizer. :param output_fixed_calibration: Optional fixed ``(scale, zero_point)`` pair for the output quantizer. :raises ValueError: If `conv` has unsupported kernel size, stride, or dilation. """ def _validate_conv(self, conv: nn.Conv2d) -> None: validate_conv_advanced(conv)