# 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,)