Source code for embedl_deploy._internal.lattice.patterns.conversions.pooling

# Copyright (C) 2026 Embedl AB

"""Pooling conversion patterns for Lattice."""

import copy
import logging
from collections.abc import Callable, Sequence
from functools import partial

import torch
from torch import fx, nn

from embedl_deploy._internal.core.patterns.main import Pattern, Phase
from embedl_deploy._internal.core.tree.replace import get_auto_name
from embedl_deploy._internal.core.tree.state import get_replaced_nodes
from embedl_deploy._internal.core.tree.types import (
    Graft,
    NodeInserter,
    ReplacementMaker,
    Tree,
    TreeMatch,
)
from embedl_deploy._internal.core.tree.utils import (
    get_input_shape,
    get_module,
    resolve_module,
)
from embedl_deploy._internal.lattice.modules.pool import (
    LatticeAdaptiveAvgPool2d,
    LatticeMaxPool2d,
)

_LOG = logging.getLogger(__name__)


def _is_stride2_conv_bn_relu_conv(node: fx.Node) -> bool:
    """Return ``True`` for stride-2 ``Conv2d`` nodes in ``Conv->BN->ReLU``."""
    mod = get_module(node)
    if not isinstance(mod, nn.Conv2d):
        return False
    return mod.stride == (2, 2)


def _make_stride2_conv_bn_relu_with_pool(
    tree_match: TreeMatch,
) -> tuple[nn.Module, ...]:
    """Build ``Conv(stride=1)->BN->ReLU->MaxPool2d(2,2)`` replacements."""
    conv_node = tree_match.get_node(0)
    conv = resolve_module(conv_node, nn.Conv2d)
    bn = resolve_module(tree_match.get_node(1), nn.BatchNorm2d)
    relu = resolve_module(tree_match.get_node(2), nn.ReLU)

    new_conv = copy.deepcopy(conv)
    new_conv.stride = (1, 1)
    _LOG.warning(
        "%s: Conv2d(stride=%s) in Conv->BN->ReLU replaced with "
        "Conv2d(stride=(1, 1)) + MaxPool2d(kernel_size=2, stride=2) "
        "— output will differ; retraining recommended.",
        conv_node.name,
        conv.stride,
    )
    return (
        new_conv,
        copy.deepcopy(bn),
        copy.deepcopy(relu),
        LatticeMaxPool2d(
            nn.MaxPool2d(kernel_size=2, stride=2, padding=0, ceil_mode=True)
        ),
    )


[docs] class LatticeStride2ConvBnReLUPattern(Pattern): """Rewrite ``Conv(stride=2)->BN->ReLU`` as ``Conv(stride=1)+MaxPool2d``. This pattern preserves convolution weights by moving the downsampling step into a following ``MaxPool2d(kernel_size=2, stride=2)``. """ is_numerically_equivalent = False phase = Phase.CONVERSION tree: Tree = (_is_stride2_conv_bn_relu_conv, nn.BatchNorm2d, nn.ReLU) graft: Graft = (_make_stride2_conv_bn_relu_with_pool,)
def _is_sppf_maxpool2d(node: fx.Node) -> bool: """Return ``True`` when `node` is a stride-1 ``MaxPool2d`` in an SPPF block. SPPF (Spatial Pyramid Pooling - Fast) uses cascaded ``MaxPool2d(kernel_size=5, stride=1, padding=2)`` operations whose outputs are concatenated. Lattice hardware does not support anything other than the canonical ``MaxPool2d(kernel_size=2, stride=2, padding=0)`` — snapping a stride-1 pool to that canonical form would halve spatial dimensions and break the SPPF structure. To avoid false positives on standalone stride-1 pools that are not part of an SPPF module, this predicate requires at least one user of the node to be a ``torch.cat`` call — the defining structural characteristic of SPPF. """ mod = get_module(node) if not isinstance(mod, nn.MaxPool2d): return False if isinstance(mod, LatticeMaxPool2d): return False stride = mod.stride if isinstance(stride, tuple): stride = stride[0] if stride[0] == stride[1] else -1 if stride != 1: return False if get_input_shape(node) is None: return False return any( u.op == "call_function" and u.target is torch.cat for u in node.users ) def _build_sppf_conv_modules(node: fx.Node) -> tuple[nn.Module, ...]: """Build ``Conv2d(3×3)`` + ``BatchNorm2d`` + ``ReLU`` for one SPPF pool. Replaces a stride-1 ``MaxPool2d`` with a learnable convolution block that preserves spatial dimensions and channel count, always using a 3×3 kernel. """ shape = get_input_shape(node) assert shape is not None channels = int(shape[1]) pool = resolve_module(node, nn.MaxPool2d) _LOG.warning( "%s: MaxPool2d(kernel_size=%s, stride=%s) replaced with " "Conv2d + BatchNorm2d + ReLU — output will differ; " "retraining required.", node.name, pool.kernel_size, pool.stride, ) conv = nn.Conv2d(channels, channels, 3, stride=1, padding=1, bias=False) bn = nn.BatchNorm2d(channels) bn.train(pool.training) relu = nn.ReLU(inplace=True) return (conv, bn, relu) def _build_sppf_conv_modules_advanced(node: fx.Node) -> tuple[nn.Module, ...]: """Build kernel-matched ``Conv2d`` + ``BatchNorm2d`` + ``ReLU`` for one pool. Uses a convolution kernel that matches the original ``MaxPool2d`` kernel size whenever the advanced hardware supports it (``3×3``, ``5×5``, or ``7×7``), preserving the receptive field of the original pool. """ shape = get_input_shape(node) assert shape is not None channels = int(shape[1]) pool = resolve_module(node, nn.MaxPool2d) pool_k = pool.kernel_size if isinstance(pool_k, tuple): pool_k = max(pool_k) conv_k: int = min((3, 5, 7), key=lambda k: (abs(k - pool_k), k)) _LOG.warning( "%s: MaxPool2d(kernel_size=%s, stride=%s) replaced with " "Conv2d(kernel_size=%s) + BatchNorm2d + ReLU — output will differ; " "retraining required.", node.name, pool.kernel_size, pool.stride, conv_k, ) conv = nn.Conv2d( channels, channels, conv_k, stride=1, padding=conv_k // 2, bias=False ) bn = nn.BatchNorm2d(channels) bn.train(pool.training) relu = nn.ReLU(inplace=True) return (conv, bn, relu) def _insert_module_chain( graph_module: fx.GraphModule, modules: Sequence[nn.Module], input_node: fx.Node, ) -> list[fx.Node]: """Insert `modules` as a ``call_module`` chain fed from `input_node`.""" created: list[fx.Node] = [] prev = input_node for module in modules: name = get_auto_name(graph_module, module) graph_module.add_module(name, module) with graph_module.graph.inserting_after(prev): node = graph_module.graph.call_module(name, (prev,)) created.append(node) prev = node return created def _sppf_replacement( tree_match: TreeMatch, build_fn: Callable[[fx.Node], tuple[nn.Module, ...]], ) -> tuple[NodeInserter]: """Build a ``NodeInserter`` that rewrites a three-pool SPPF cascade. `build_fn` produces the ``Conv → BatchNorm2d → ReLU`` modules for one pool (the optimized and advanced patterns differ only in this; :func:`_sppf_maker` binds it to obtain a ``ReplacementMaker``). The inserter grows an independent chain per pool and rewires the ``torch.cat`` slots of the first two pools — the cascade's intermediate outputs — onto their new chains. The third pool is the matched tree's output, so the surrounding ``replace_tree`` call rewires its ``cat`` slot to the final chain and erases all three pools. """ replaced_nodes = get_replaced_nodes() pools = [replaced_nodes.get(n, n) for n in tree_match.get_tree_nodes()] def _insert( graph_module: fx.GraphModule, prev_args: tuple[fx.Node, ...], ) -> list[fx.Node]: created: list[fx.Node] = [] source = prev_args[0] relus: list[fx.Node] = [] for pool in pools: chain = _insert_module_chain(graph_module, build_fn(pool), source) created.extend(chain) relus.append(chain[-1]) source = chain[-1] for pool, relu in zip(pools[:-1], relus[:-1], strict=True): pool.replace_all_uses_with(relu) return created return (_insert,) def _sppf_maker( build_fn: Callable[[fx.Node], tuple[nn.Module, ...]], ) -> ReplacementMaker: """Bind `build_fn` into a ``ReplacementMaker`` for an SPPF pattern.""" return partial(_sppf_replacement, build_fn=build_fn)
[docs] class LatticeSPPFMaxPoolPattern(Pattern): """Replace three cascaded stride-1 ``MaxPool2d`` with ``Conv+BN+ReLU``. SPPF (Spatial Pyramid Pooling - Fast) modules use three cascaded ``MaxPool2d`` operations with stride 1 to expand the receptive field without reducing spatial resolution. Lattice hardware only supports ``MaxPool2d`` with kernel 2, stride 2, and zero padding — snapping a stride-1 pool to that canonical form would halve spatial dimensions and destroy the SPPF structure. This pattern matches the full three-pool cascade at once and replaces all three with ``Conv2d(c, c, 3, stride=1, padding=1)`` + ``BatchNorm2d`` + ``ReLU`` chains. The cascaded 3×3 convolutions provide receptive field expansion (3→5→7) analogous to the original cascaded max pools (5→9→13), but the replacement is **not** mathematically equivalent — retraining is required. Standalone stride-1 pools that do not feed ``torch.cat`` are left for ``LatticeMaxPool2dPattern`` to handle. Requires shape metadata on the input node, propagated by :class:`~torch.fx.passes.shape_prop.ShapeProp`. """ is_numerically_equivalent = False phase = Phase.CONVERSION tree: Tree = (_is_sppf_maxpool2d, _is_sppf_maxpool2d, _is_sppf_maxpool2d) graft: Graft = (_sppf_maker(_build_sppf_conv_modules),)
[docs] class LatticeSPPFMaxPoolAdvancedPattern(Pattern): """Replace three cascaded stride-1 ``MaxPool2d`` with kernel-matched convs. Like ``LatticeSPPFMaxPoolPattern``, this matches the full three-pool SPPF cascade and replaces it with ``Conv2d`` + ``BatchNorm2d`` + ``ReLU`` chains. The difference is the convolution kernel size: instead of always using ``3×3``, this pattern snaps to the nearest kernel from ``{3, 5, 7}`` to match the original pool's receptive field whenever the advanced hardware supports it. For example, a ``MaxPool2d(kernel_size=5, stride=1, padding=2)`` is replaced with ``Conv2d(c, c, 5, stride=1, padding=2)`` — preserving the 5×5 receptive field. The replacement is **not** mathematically equivalent — retraining is required. Requires shape metadata on the input node, propagated by :class:`~torch.fx.passes.shape_prop.ShapeProp`. """ is_numerically_equivalent = False phase = Phase.CONVERSION tree: Tree = (_is_sppf_maxpool2d, _is_sppf_maxpool2d, _is_sppf_maxpool2d) graft: Graft = (_sppf_maker(_build_sppf_conv_modules_advanced),)
def _is_nonconforming_maxpool2d(node: fx.Node) -> bool: """Return ``True`` when `node` is a ``MaxPool2d`` that needs rewriting.""" # SPPF stride-1 pools must be handled by the dedicated SPPF patterns. # If the generic max-pool snap grabs them, spatial dims are halved and # the downstream SPPF concat can break. if _is_sppf_maxpool2d(node): return False mod = get_module(node) if not isinstance(mod, nn.MaxPool2d): return False if isinstance(mod, LatticeMaxPool2d): return False return not LatticeMaxPool2d.is_compatible(mod) def _make_lattice_maxpool(tree_match: TreeMatch) -> tuple[nn.Module, ...]: """Build the replacement ``MaxPool2d`` for the matched node.""" node = tree_match.get_node(0) pool = resolve_module(node, nn.MaxPool2d) _LOG.warning( "%s: MaxPool2d snapped from kernel_size=%s stride=%s padding=%s " "to kernel_size=%s stride=%s padding=%s — output will differ", node.name, pool.kernel_size, pool.stride, pool.padding, LatticeMaxPool2d.KERNEL_SIZE, LatticeMaxPool2d.STRIDE, LatticeMaxPool2d.PADDING, ) return (LatticeMaxPool2d(pool),)
[docs] class LatticeMaxPool2dPattern(Pattern): """Snap an out-of-spec ``MaxPool2d`` to Lattice's supported set. Lattice hardware supports only a ``2×2`` max-pool with stride 2 and zero padding. Any other configuration is replaced by that single canonical form, matching the stem rewrite performed by the Lattice export reference script. """ is_numerically_equivalent = False phase = Phase.CONVERSION tree: Tree = (_is_nonconforming_maxpool2d,) graft: Graft = (_make_lattice_maxpool,)
def _avg_pool_output_is_global(node: fx.Node) -> bool: """Return ``True`` when the pool collapses spatial dims to ``1×1``.""" mod = get_module(node) shape = get_input_shape(node) if shape is None or len(shape) != 4: return False in_h, in_w = int(shape[2]), int(shape[3]) if isinstance(mod, nn.AdaptiveAvgPool2d): if isinstance(mod.output_size, tuple): out_h, out_w = mod.output_size else: out_h = out_w = mod.output_size out_h = out_h if out_h is not None else in_h out_w = out_w if out_w is not None else in_w return out_h == 1 and out_w == 1 if isinstance(mod, nn.AvgPool2d): kh, kw = ( mod.kernel_size if isinstance(mod.kernel_size, tuple) else (mod.kernel_size, mod.kernel_size) ) return kh == in_h and kw == in_w return False def _is_nonconforming_global_avg_pool(node: fx.Node) -> bool: """Return ``True`` when `node` is a global pool not in canonical form.""" mod = get_module(node) if not isinstance(mod, (nn.AdaptiveAvgPool2d, nn.AvgPool2d)): return False if isinstance(mod, LatticeAdaptiveAvgPool2d): return False return _avg_pool_output_is_global(node) def _make_lattice_global_pool(tree_match: TreeMatch) -> tuple[nn.Module, ...]: """Build the replacement global average pool for the matched node.""" del tree_match return (LatticeAdaptiveAvgPool2d(),)
[docs] class LatticeGlobalAvgPoolPattern(Pattern): """Normalize any global average pool to ``AdaptiveAvgPool2d((1, 1))``. Lattice hardware represents the classification-tail pool as an :class:`~torch.nn.AdaptiveAvgPool2d` with ``output_size == (1, 1)``. Any other module that effectively performs a global average pool over a 4-D input (an :class:`~torch.nn.AdaptiveAvgPool2d` with the scalar shorthand ``1`` or with a ``None`` axis equal to the input spatial size, or an :class:`~torch.nn.AvgPool2d` whose kernel covers the full spatial extent) is rewritten to this canonical form. The pattern is a no-op for inputs already in canonical form, so it converges in a single conversion pass without infinite looping. Requires shape metadata on the input node, propagated by :class:`~torch.fx.passes.shape_prop.ShapeProp` (the ``transform`` driver runs this automatically between conversion iterations). """ phase = Phase.CONVERSION tree: Tree = (_is_nonconforming_global_avg_pool,) graft: Graft = (_make_lattice_global_pool,)