spikingjelly.activation_based.ann2snn.recipes.sta_transformer 源代码
from __future__ import annotations
import copy
import math
import operator
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import fx
from tqdm import tqdm
from spikingjelly.activation_based import base
from spikingjelly.activation_based.ann2snn.operators import (
TDConv2d,
TDGELU,
TDLayerNorm,
TDLinear,
TDMultiheadAttention,
)
from spikingjelly.activation_based.ann2snn.recipes.base import ConversionRecipe
from spikingjelly.activation_based.ann2snn.recipes.step_mode_adapters import (
_TRANSFORMER_SAFE_MODULE_TYPES,
adapt_step_mode_graph,
)
if TYPE_CHECKING:
from spikingjelly.activation_based.ann2snn.converter import Converter
__all__ = [
"STATransformerRecipe",
]
_STATIC_TENSOR_KWARGS = frozenset(
{
"attention_mask",
"attn_mask",
"causal_mask",
"decoder_attention_mask",
"encoder_attention_mask",
"key_padding_mask",
"mask",
"padding_mask",
}
)
def _clone_parameter(parameter: nn.Parameter) -> nn.Parameter:
return nn.Parameter(
parameter.detach().clone(),
requires_grad=parameter.requires_grad,
)
def _make_td_multihead_attention(source: nn.MultiheadAttention) -> TDMultiheadAttention:
if source.in_proj_weight is None:
raise ValueError(
"STATransformerRecipe step-mode backend requires a fused "
"MultiheadAttention.in_proj_weight. Models with separate K/V "
"projections (kdim != embed_dim or vdim != embed_dim) are not "
"currently supported."
)
if source.bias_k is not None or source.bias_v is not None:
raise ValueError(
"STATransformerRecipe step-mode backend does not support "
"MultiheadAttention add_bias_kv."
)
if source.add_zero_attn:
raise ValueError(
"STATransformerRecipe step-mode backend does not support "
"MultiheadAttention add_zero_attn."
)
replacement = TDMultiheadAttention(
source.embed_dim,
source.num_heads,
dropout=source.dropout,
bias=source.in_proj_bias is not None,
batch_first=source.batch_first,
device=source.in_proj_weight.device,
dtype=source.in_proj_weight.dtype,
)
with torch.no_grad():
q_weight, k_weight, v_weight = source.in_proj_weight.chunk(3, dim=0)
replacement.q_proj.weight.copy_(q_weight)
replacement.k_proj.weight.copy_(k_weight)
replacement.v_proj.weight.copy_(v_weight)
if source.in_proj_bias is not None:
q_bias, k_bias, v_bias = source.in_proj_bias.chunk(3, dim=0)
replacement.q_proj.bias.copy_(q_bias)
replacement.k_proj.bias.copy_(k_bias)
replacement.v_proj.bias.copy_(v_bias)
replacement.out_proj.weight.copy_(source.out_proj.weight)
if source.out_proj.bias is not None:
replacement.out_proj.bias.copy_(source.out_proj.bias)
for proj in (replacement.q_proj, replacement.k_proj, replacement.v_proj):
proj.weight.requires_grad = source.in_proj_weight.requires_grad
if source.in_proj_bias is not None:
proj.bias.requires_grad = source.in_proj_bias.requires_grad
replacement.out_proj.weight.requires_grad = source.out_proj.weight.requires_grad
if source.out_proj.bias is not None:
replacement.out_proj.bias.requires_grad = source.out_proj.bias.requires_grad
return replacement
def _broadcast_channel_vector(
value: torch.Tensor, output: torch.Tensor, channel_dim: int
) -> torch.Tensor:
if channel_dim < 0:
channel_dim += output.dim()
shape = [1] * output.dim()
shape[channel_dim] = value.numel()
return value.to(device=output.device, dtype=output.dtype).view(shape)
class _STAThresholdObserver:
def __init__(
self,
time_steps: int,
channel_dim: int,
mode: str,
momentum: float,
eps: float,
) -> None:
self.time_steps = time_steps
self.channel_dim = channel_dim
self.mode = mode
self.momentum = momentum
self.eps = eps
self.threshold: Optional[torch.Tensor] = None
self.num_batches_tracked = 0
@staticmethod
def _normalize_channel_dim(x: torch.Tensor, channel_dim: int) -> int:
if x.dim() < 2:
raise ValueError("STA calibration requires activations with rank >= 2.")
if channel_dim < 0:
channel_dim += x.dim()
if channel_dim < 0 or channel_dim >= x.dim():
raise ValueError("channel_dim is out of range.")
return channel_dim
def __call__(self, output: torch.Tensor) -> None:
if isinstance(output, (tuple, list)):
output = output[0]
if not torch.is_tensor(output):
raise TypeError("STA observer output must be a tensor.")
with torch.no_grad():
threshold = self._compute_threshold(output.detach())
if self.threshold is None:
self.threshold = threshold
else:
self.threshold = (
(1 - self.momentum)
* self.threshold.to(device=threshold.device, dtype=threshold.dtype)
+ self.momentum * threshold
).detach()
self.num_batches_tracked += 1
def _compute_threshold(self, x: torch.Tensor) -> torch.Tensor:
channel_dim = self._normalize_channel_dim(x, self.channel_dim)
if x.dtype in [torch.float16, torch.bfloat16]:
x = x.to(torch.float32)
values = x.movedim(channel_dim, 0).reshape(x.shape[channel_dim], -1)
max_abs = values.abs().max(dim=1).values
max_abs = torch.clamp(max_abs, min=self.eps)
if self.mode == "max":
return (max_abs / self.time_steps).detach()
if self.mode != "mse":
raise ValueError("STA threshold mode must be 'mse' or 'max'.")
best_threshold = max_abs
best_error = torch.full_like(max_abs, float("inf"))
for factor in torch.linspace(
0.125,
2.0,
steps=31,
).tolist():
threshold = torch.clamp(max_abs * factor, min=self.eps)
step_threshold = threshold[:, None] / self.time_steps
quantized = torch.trunc(values / step_threshold) * step_threshold
error = (values - quantized).square().mean(dim=1)
update = error < best_error
best_threshold = torch.where(update, threshold, best_threshold)
best_error = torch.where(update, error, best_error)
return (best_threshold / self.time_steps).detach()
def compute_threshold(self) -> torch.Tensor:
if self.threshold is None:
raise ValueError("No STA calibration activations have been recorded.")
if not torch.isfinite(self.threshold).all() or (self.threshold <= 0).any():
raise ValueError("STA thresholds must be finite positive values.")
return self.threshold.detach()
class _STATimeStepMixin:
def _reset_sta_state(self) -> None:
self.t = 0
class _STASpikeMixin(_STATimeStepMixin):
def _reset_sta_state(self) -> None:
super()._reset_sta_state()
self.mem = None
@staticmethod
def _broadcast_threshold(
threshold: torch.Tensor, output: torch.Tensor, channel_dim: int
) -> torch.Tensor:
return _broadcast_channel_vector(threshold, output, channel_dim)
def _spike(self, analog: torch.Tensor, channel_dim: int) -> torch.Tensor:
threshold = self._broadcast_threshold(self.v_threshold, analog, channel_dim)
if (
self.mem is None
or self.mem.shape != analog.shape
or self.mem.device != analog.device
or self.mem.dtype != analog.dtype
):
self.mem = torch.zeros_like(analog)
self.mem = self.mem + analog
spike_count = torch.trunc(self.mem / threshold)
spike = spike_count * threshold
self.mem = self.mem - spike
return spike
class _STASpikeLinear(nn.Module, _STASpikeMixin):
def __init__(self, source: nn.Linear, threshold: torch.Tensor) -> None:
super().__init__()
self.in_features = source.in_features
self.out_features = source.out_features
self.weight = _clone_parameter(source.weight)
if source.bias is None:
self.register_parameter("bias", None)
else:
self.bias = _clone_parameter(source.bias)
self.register_buffer("v_threshold", threshold.detach().clone())
self.mem: Optional[torch.Tensor] = None
self.t = 0
def forward(self, x: torch.Tensor) -> torch.Tensor:
bias = self.bias if self.t == 0 else None
self.t += 1
return self._spike(F.linear(x, self.weight, bias), channel_dim=-1)
class _STASpikeConv2d(nn.Module, _STASpikeMixin):
def __init__(self, source: nn.Conv2d, threshold: torch.Tensor) -> None:
super().__init__()
self.in_channels = source.in_channels
self.out_channels = source.out_channels
self.kernel_size = source.kernel_size
self.stride = source.stride
self.padding = source.padding
self.dilation = source.dilation
self.groups = source.groups
self.padding_mode = source.padding_mode
self._sta_reversed_padding_repeated_twice = tuple(
source._reversed_padding_repeated_twice
)
self.weight = _clone_parameter(source.weight)
if source.bias is None:
self.register_parameter("bias", None)
else:
self.bias = _clone_parameter(source.bias)
self.register_buffer("v_threshold", threshold.detach().clone())
self.mem: Optional[torch.Tensor] = None
self.t = 0
def forward(self, x: torch.Tensor) -> torch.Tensor:
bias = self.bias if self.t == 0 else None
self.t += 1
if self.padding_mode != "zeros":
x = F.pad(
x,
self._sta_reversed_padding_repeated_twice,
mode=self.padding_mode,
)
analog = F.conv2d(
x,
self.weight,
bias,
self.stride,
(0, 0),
self.dilation,
self.groups,
)
else:
analog = F.conv2d(
x,
self.weight,
bias,
self.stride,
self.padding,
self.dilation,
self.groups,
)
return self._spike(analog, channel_dim=1)
class _STASpikeEncoder(base.MemoryModule):
def __init__(
self,
threshold: torch.Tensor,
channel_dim: int = -1,
step_mode: str = "s",
) -> None:
super().__init__()
self.channel_dim = channel_dim
self.register_buffer("v_threshold", threshold.detach().clone())
self.register_memory("mem", None)
self.step_mode = step_mode
@staticmethod
def _broadcast_threshold(
threshold: torch.Tensor, output: torch.Tensor, channel_dim: int
) -> torch.Tensor:
return _broadcast_channel_vector(threshold, output, channel_dim)
def single_step_forward(self, x: torch.Tensor) -> torch.Tensor:
threshold = self._broadcast_threshold(self.v_threshold, x, self.channel_dim)
threshold = torch.clamp(threshold, min=torch.finfo(threshold.dtype).eps)
if (
self.mem is None
or self.mem.shape != x.shape
or self.mem.device != x.device
or self.mem.dtype != x.dtype
):
self.mem = torch.zeros_like(x)
self.mem = self.mem + x
spike_count = torch.trunc(self.mem / threshold)
spike = spike_count * threshold
self.mem = self.mem - spike
return spike
def multi_step_forward(self, x_seq: torch.Tensor) -> torch.Tensor:
if x_seq.dim() < 2:
raise ValueError(
"STA spike encoder multi-step forward expects an input sequence "
"with a time dimension and at least one data dimension."
)
if x_seq.shape[0] == 0:
raise ValueError("STA spike encoder expects a non-empty time dimension.")
outputs = []
for x in x_seq:
outputs.append(self.single_step_forward(x))
return torch.stack(outputs, dim=0)
def _reset_sta_state(self) -> None:
self.reset()
def extra_repr(self) -> str:
return f"channel_dim={self.channel_dim}, step_mode={self.step_mode}"
class _STAAnalogLinear(nn.Module, _STATimeStepMixin):
def __init__(self, source: nn.Linear) -> None:
super().__init__()
self.in_features = source.in_features
self.out_features = source.out_features
self.weight = _clone_parameter(source.weight)
if source.bias is None:
self.register_parameter("bias", None)
else:
self.bias = _clone_parameter(source.bias)
self.t = 0
def forward(self, x: torch.Tensor) -> torch.Tensor:
bias = self.bias if self.t == 0 else None
self.t += 1
return F.linear(x, self.weight, bias)
class _STAAnalogConv2d(nn.Module, _STATimeStepMixin):
def __init__(self, source: nn.Conv2d) -> None:
super().__init__()
self.in_channels = source.in_channels
self.out_channels = source.out_channels
self.kernel_size = source.kernel_size
self.stride = source.stride
self.padding = source.padding
self.dilation = source.dilation
self.groups = source.groups
self.padding_mode = source.padding_mode
self._sta_reversed_padding_repeated_twice = tuple(
source._reversed_padding_repeated_twice
)
self.weight = _clone_parameter(source.weight)
if source.bias is None:
self.register_parameter("bias", None)
else:
self.bias = _clone_parameter(source.bias)
self.t = 0
def forward(self, x: torch.Tensor) -> torch.Tensor:
bias = self.bias if self.t == 0 else None
self.t += 1
if self.padding_mode != "zeros":
x = F.pad(
x,
self._sta_reversed_padding_repeated_twice,
mode=self.padding_mode,
)
return F.conv2d(
x,
self.weight,
bias,
self.stride,
(0, 0),
self.dilation,
self.groups,
)
return F.conv2d(
x,
self.weight,
bias,
self.stride,
self.padding,
self.dilation,
self.groups,
)
class _STAOnlineLayerNorm(nn.Module):
def __init__(
self,
source: nn.LayerNorm,
encoder_threshold: Optional[torch.Tensor] = None,
) -> None:
super().__init__()
self.normalized_shape = source.normalized_shape
self.eps = source.eps
self.elementwise_affine = source.elementwise_affine
if source.weight is None:
self.register_parameter("weight", None)
else:
self.weight = _clone_parameter(source.weight)
if source.bias is None:
self.register_parameter("bias", None)
else:
self.bias = _clone_parameter(source.bias)
self.mem: Optional[torch.Tensor] = None
self.prev_output: Optional[torch.Tensor] = None
self.encoder = (
None
if encoder_threshold is None
else _STASpikeEncoder(encoder_threshold, channel_dim=-1, step_mode="s")
)
def _reset_sta_state(self) -> None:
self.mem = None
self.prev_output = None
if self.encoder is not None:
self.encoder._reset_sta_state()
def forward(self, x: torch.Tensor) -> torch.Tensor:
if (
self.mem is None
or self.mem.shape != x.shape
or self.mem.device != x.device
or self.mem.dtype != x.dtype
):
self.mem = torch.zeros_like(x)
self.prev_output = torch.zeros_like(x)
self.mem = self.mem + x
output = F.layer_norm(
self.mem,
self.normalized_shape,
self.weight,
self.bias,
self.eps,
)
delta = output - self.prev_output
self.prev_output = output
if self.encoder is not None:
delta = self.encoder(delta)
return delta
class _STAOnlineGELU(nn.Module):
def __init__(
self,
source: nn.GELU,
encoder_threshold: Optional[torch.Tensor] = None,
) -> None:
super().__init__()
self.approximate = getattr(source, "approximate", "none")
self.mem: Optional[torch.Tensor] = None
self.prev_output: Optional[torch.Tensor] = None
self.encoder = (
None
if encoder_threshold is None
else _STASpikeEncoder(encoder_threshold, channel_dim=-1, step_mode="s")
)
def _reset_sta_state(self) -> None:
self.mem = None
self.prev_output = None
if self.encoder is not None:
self.encoder._reset_sta_state()
def forward(self, x: torch.Tensor) -> torch.Tensor:
if (
self.mem is None
or self.mem.shape != x.shape
or self.mem.device != x.device
or self.mem.dtype != x.dtype
):
self.mem = torch.zeros_like(x)
self.prev_output = torch.zeros_like(x)
self.mem = self.mem + x
output = F.gelu(self.mem, approximate=self.approximate)
delta = output - self.prev_output
self.prev_output = output
if self.encoder is not None:
delta = self.encoder(delta)
return delta
class _STAOnlineMultiheadAttention(nn.Module):
def __init__(
self,
source: nn.MultiheadAttention,
encoder_threshold: Optional[torch.Tensor] = None,
) -> None:
super().__init__()
# MultiheadAttention carries fused projection parameters and option
# fields; deepcopy preserves that full internal layout.
self.attn = copy.deepcopy(source)
self.q_mem: Optional[torch.Tensor] = None
self.k_mem: Optional[torch.Tensor] = None
self.v_mem: Optional[torch.Tensor] = None
self.prev_output: Optional[torch.Tensor] = None
self.prev_weights: Optional[torch.Tensor] = None
self.encoder = (
None
if encoder_threshold is None
else _STASpikeEncoder(encoder_threshold, channel_dim=-1, step_mode="s")
)
def _reset_sta_state(self) -> None:
self.q_mem = None
self.k_mem = None
self.v_mem = None
self.prev_output = None
self.prev_weights = None
if self.encoder is not None:
self.encoder._reset_sta_state()
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
key_padding_mask: Optional[torch.Tensor] = None,
need_weights: bool = True,
attn_mask: Optional[torch.Tensor] = None,
average_attn_weights: bool = True,
is_causal: bool = False,
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
if (
self.q_mem is None
or self.k_mem is None
or self.v_mem is None
or self.q_mem.shape != query.shape
or self.k_mem.shape != key.shape
or self.v_mem.shape != value.shape
or self.q_mem.device != query.device
or self.k_mem.device != key.device
or self.v_mem.device != value.device
or self.q_mem.dtype != query.dtype
or self.k_mem.dtype != key.dtype
or self.v_mem.dtype != value.dtype
):
self.q_mem = torch.zeros_like(query)
self.k_mem = torch.zeros_like(key)
self.v_mem = torch.zeros_like(value)
self.prev_output = None
self.prev_weights = None
self.q_mem = self.q_mem + query
self.k_mem = self.k_mem + key
self.v_mem = self.v_mem + value
output, weights = self.attn(
self.q_mem,
self.k_mem,
self.v_mem,
key_padding_mask=key_padding_mask,
need_weights=need_weights,
attn_mask=attn_mask,
average_attn_weights=average_attn_weights,
is_causal=is_causal,
)
if (
self.prev_output is None
or self.prev_output.shape != output.shape
or self.prev_output.device != output.device
or self.prev_output.dtype != output.dtype
):
self.prev_output = torch.zeros_like(output)
delta = output - self.prev_output
self.prev_output = output
if self.encoder is not None:
delta = self.encoder(delta)
if weights is None:
weights_delta = None
self.prev_weights = None
else:
if (
self.prev_weights is None
or self.prev_weights.shape != weights.shape
or self.prev_weights.device != weights.device
or self.prev_weights.dtype != weights.dtype
):
self.prev_weights = torch.zeros_like(weights)
weights_delta = weights - self.prev_weights
self.prev_weights = weights
return delta, weights_delta
class _STAConstant(base.MemoryModule):
def __init__(
self,
value: torch.Tensor,
time_steps: int,
step_mode: str = "s",
) -> None:
super().__init__()
self.time_steps = time_steps
if isinstance(value, nn.Parameter):
self.value = nn.Parameter(
value.detach().clone(), requires_grad=value.requires_grad
)
else:
self.register_buffer("value", value.detach().clone())
self.register_memory("t", 0)
self.step_mode = step_mode
def single_step_forward(self) -> torch.Tensor:
if self.t == 0:
output = self.value
else:
output = torch.zeros_like(self.value)
self.t += 1
return output
def multi_step_forward(self) -> torch.Tensor:
if self.t == 0:
zeros = torch.zeros_like(self.value).expand(
self.time_steps - 1, *self.value.shape
)
output = torch.cat((self.value.unsqueeze(0), zeros), dim=0)
else:
output = torch.zeros_like(self.value).expand(
self.time_steps, *self.value.shape
)
self.t += self.time_steps
return output
[文档]
class STATransformerRecipe(ConversionRecipe):
def __init__(
self,
dataloader: Optional[Iterable] = None,
time_steps: int = 32,
mode: str = "equivalent",
threshold_mode: str = "mse",
threshold_scale: float = 1.0,
spike_linear: Optional[bool] = None,
spike_conv2d: Optional[bool] = None,
spike_classifier: bool = False,
momentum: float = 0.1,
num_calibration_batches: Optional[int] = None,
show_progress: bool = False,
eps: float = 1e-6,
) -> None:
r"""
**API Language** - :ref:`中文 <STATransformerRecipe.__init__-cn>` | :ref:`English <STATransformerRecipe.__init__-en>`
----
.. _STATransformerRecipe.__init__-cn:
* **中文**
实现基于 Spatio-Temporal Approximation (STA) [#sta_transformer]_ 思路的
training-free Transformer 转换 recipe。该 recipe 将 Transformer 中的
Linear、Conv2d、LayerNorm、GELU、``MultiheadAttention`` 等算子替换为
支持 STA 差分时间传播的 step-mode 模块。
转换过程中会以 ``eval`` 模式 trace 和校准原 ANN;``Converter`` 会在
转换结束后恢复原 ANN 的 ``training`` 标志。
``mode="equivalent"`` 是默认的在线累计-差分基线:Linear、Conv2d、
LayerNorm、GELU、``MultiheadAttention`` 和 FX tensor 常量都按时间步
保持与原 ANN 的累计输出等价。该模式用于建立 Transformer 图形态和
模型级接受基线,不宣称 fully spike-driven。
``mode="spiking_encoder"`` 会在 LayerNorm、GELU 和
``MultiheadAttention`` 输出侧插入校准后的有状态 spike encoder,同时
保持主干 affine 在线等价。``mode="spiking_affine"`` 会为选中的
Linear/Conv2d 统计阈值并替换为有状态 bipolar IF / burst affine 模块;
LayerNorm、GELU 和 ``MultiheadAttention`` 仍使用在线累计-差分浮点
模块。当前 step-mode 对齐后端暂不支持 ``mode="spiking_affine"``、
``spike_linear=True`` 或 ``spike_conv2d=True``;这些配置会明确报错。
``time_steps`` 参与阈值搜索和图中常量的多步展开,因而是转换 recipe
的一部分,而不仅是外部评估循环参数。
转换产物是普通 ``nn.Module`` / ``fx.GraphModule``。用户通过
:func:`spikingjelly.activation_based.functional.set_step_mode` 递归设置
内部 step-mode 模块。``step_mode="s"`` 时,用户自己按时间步调用转换
后的模型;``step_mode="m"`` 时,模型接收第 0 维为时间维的序列 tensor
并返回输出序列。最终累计 readout 由用户显式执行,例如对时间维求和。
:param dataloader: 校准数据加载器。每个 batch 可为单输入 tensor、
``(input, target)`` 风格的 tuple/list,或传递给模型的 kwargs
dict。``mode="equivalent"`` 默认不执行校准,可传 ``None``;显式
启用 ``spike_linear`` 或 ``spike_conv2d`` 时也需要提供。
:type dataloader: Optional[Iterable]
:param time_steps: STA 内部推理时间步数,也用于阈值搜索。
:type time_steps: int
:param mode: 转换模式,支持 ``"equivalent"``、
``"spiking_encoder"`` 和 ``"spiking_affine"``。
:type mode: str
:param threshold_mode: 阈值统计模式,支持 ``"mse"`` 和 ``"max"``。
:type threshold_mode: str
:param threshold_scale: 校准阈值的正数缩放因子。
:type threshold_scale: float
:param spike_linear: 是否替换 Linear 为 spiking affine。若为 ``None``,
在 ``mode="spiking_affine"`` 时启用。
:type spike_linear: Optional[bool]
:param spike_conv2d: 是否替换 Conv2d 为 spiking affine。若为 ``None``,
默认不启用。
:type spike_conv2d: Optional[bool]
:param spike_classifier: ``spike_linear=True`` 时是否也转换分类头。
:type spike_classifier: bool
:param momentum: 阈值 observer 的动量。
:type momentum: float
:param num_calibration_batches: 最多使用的校准 batch 数;``None`` 表示
使用整个 dataloader。
:type num_calibration_batches: Optional[int]
:param show_progress: 是否显示校准进度条。
:type show_progress: bool
:param eps: 阈值数值下界。
:type eps: float
:raises ValueError: 当校验发现不支持的转换模式、阈值模式、非正
time step、非正缩放因子、非法动量、非法校准 batch 上限、非法
模式组合,或布尔选项类型错误时抛出。
.. [#sta_transformer] Y. Jiang, K. Hu, T. Zhang, H. Gao, Y. Liu,
Y. Fang, and F. Chen, "Spatio-Temporal Approximation: A
Training-Free SNN Conversion for Transformers," ICLR 2024.
https://openreview.net/forum?id=XrunSYwoLr
----
.. _STATransformerRecipe.__init__-en:
* **English**
Implement a training-free Transformer conversion recipe based on
Spatio-Temporal Approximation (STA) [#sta_transformer]_. The recipe
replaces Transformer Linear, Conv2d, LayerNorm, GELU,
``MultiheadAttention`` and related operators with step-mode modules
that support STA differential temporal propagation.
Conversion traces and calibrates the original ANN in ``eval`` mode;
``Converter`` restores the original ANN ``training`` flags after
conversion finishes.
``mode="equivalent"`` is the default online cumulative-difference
baseline: Linear, Conv2d, LayerNorm, GELU, ``MultiheadAttention`` and
FX tensor constants preserve the original ANN cumulative output across
time. This mode establishes the Transformer graph shape and model-level
acceptance baseline; it does not claim to be fully spike-driven.
``mode="spiking_encoder"`` inserts calibrated stateful spike encoders
after LayerNorm, GELU and ``MultiheadAttention`` outputs while keeping
main affine projections online-equivalent. ``mode="spiking_affine"``
calibrates thresholds for selected Linear/Conv2d modules and replaces
them with stateful bipolar IF / burst affine modules; LayerNorm, GELU
and ``MultiheadAttention`` remain online cumulative-difference
floating-point modules. The current step-mode-aligned backend does
not support ``mode="spiking_affine"``, ``spike_linear=True`` or
``spike_conv2d=True``; these configurations raise a clear error.
``time_steps`` is used by threshold search and multi-step expansion of
graph constants, so it belongs to the conversion recipe rather than only
to an external evaluation loop.
The converted model is a plain ``nn.Module`` / ``fx.GraphModule``. Users
call :func:`spikingjelly.activation_based.functional.set_step_mode` to
recursively configure internal step-mode modules. With
``step_mode="s"``, users call the converted model once per timestep.
With ``step_mode="m"``, the model consumes sequence tensors whose first
dimension is time and returns output sequences. Final accumulated
readout is explicit, e.g. summing the time dimension.
:param dataloader: Calibration dataloader. Each batch can be a
single-input tensor, a ``(input, target)``-style tuple/list, or a
kwargs dict passed to the model. ``mode="equivalent"`` skips
calibration by default and can use ``None``; explicitly enabling
``spike_linear`` or ``spike_conv2d`` still requires a dataloader.
:type dataloader: Optional[Iterable]
:param time_steps: Number of STA internal inference timesteps. It is
also used by threshold search.
:type time_steps: int
:param mode: Conversion mode. Supported values are ``"equivalent"``,
``"spiking_encoder"`` and ``"spiking_affine"``.
:type mode: str
:param threshold_mode: Threshold statistics mode. Supported values are
``"mse"`` and ``"max"``.
:type threshold_mode: str
:param threshold_scale: Positive scale factor applied to calibrated
thresholds.
:type threshold_scale: float
:param spike_linear: Whether to replace Linear with spiking affine
modules. If ``None``, enable it for ``mode="spiking_affine"``.
:type spike_linear: Optional[bool]
:param spike_conv2d: Whether to replace Conv2d with spiking affine
modules. If ``None``, disable it by default.
:type spike_conv2d: Optional[bool]
:param spike_classifier: Whether to convert classifier heads when
``spike_linear=True``.
:type spike_classifier: bool
:param momentum: Momentum used by threshold observers.
:type momentum: float
:param num_calibration_batches: Maximum number of calibration batches;
``None`` uses the full dataloader.
:type num_calibration_batches: Optional[int]
:param show_progress: Whether to show a calibration progress bar.
:type show_progress: bool
:param eps: Numeric lower bound for thresholds.
:type eps: float
:raises ValueError: If validation finds an unsupported mode, threshold
mode, non-positive timestep count, non-positive scale, invalid
momentum, invalid calibration batch limit, unsupported mode
combination, or invalid type for a boolean option.
"""
self.dataloader = dataloader
self.time_steps = time_steps
self.mode = mode
self.threshold_mode = (
threshold_mode.lower()
if isinstance(threshold_mode, str)
else threshold_mode
)
self.threshold_scale = threshold_scale
if spike_linear is None:
spike_linear = mode == "spiking_affine"
if spike_conv2d is None:
spike_conv2d = False
self.spike_linear = spike_linear
self.spike_conv2d = spike_conv2d
self.spike_classifier = spike_classifier
self.momentum = momentum
self.num_calibration_batches = num_calibration_batches
self.show_progress = show_progress
self.eps = eps
self._observers: Dict[str, _STAThresholdObserver] = {}
self._hook_handles: List[torch.utils.hooks.RemovableHandle] = []
[文档]
def validate(self, converter: "Converter") -> None:
needs_calibration = (
self.mode == "spiking_encoder" or self.spike_linear or self.spike_conv2d
)
if needs_calibration and self.dataloader is None:
raise ValueError(
"STATransformerRecipe requires a dataloader when calibration "
"is enabled. "
"Pass dataloader to STATransformerRecipe."
)
if (
not isinstance(self.time_steps, int)
or isinstance(self.time_steps, bool)
or self.time_steps <= 0
):
raise ValueError("time_steps must be a positive integer.")
if not isinstance(self.mode, str):
raise ValueError("mode must be str.")
if self.mode not in ("equivalent", "spiking_encoder", "spiking_affine"):
raise ValueError(
"mode must be 'equivalent', 'spiking_encoder' or 'spiking_affine'."
)
if not isinstance(self.threshold_mode, str):
raise ValueError("threshold_mode must be str.")
if self.threshold_mode not in ("mse", "max"):
raise ValueError("threshold_mode must be 'mse' or 'max'.")
if not isinstance(self.threshold_scale, (int, float)) or isinstance(
self.threshold_scale, bool
):
raise ValueError("threshold_scale must be a positive number.")
if self.threshold_scale <= 0 or not math.isfinite(self.threshold_scale):
raise ValueError("threshold_scale must be positive.")
if not isinstance(self.spike_linear, bool):
raise ValueError("spike_linear must be bool.")
if not isinstance(self.spike_conv2d, bool):
raise ValueError("spike_conv2d must be bool.")
if not isinstance(self.spike_classifier, bool):
raise ValueError("spike_classifier must be bool.")
if self.mode == "spiking_affine" or self.spike_linear or self.spike_conv2d:
raise ValueError(
"STATransformerRecipe step-mode backend currently supports only "
"equivalent and spiking_encoder modes without spiking affine "
"Linear/Conv2d replacements."
)
if not isinstance(self.momentum, (int, float)) or isinstance(
self.momentum, bool
):
raise ValueError("momentum must be a number in [0, 1].")
if not (0.0 <= self.momentum <= 1.0):
raise ValueError("momentum must be in [0, 1].")
if self.num_calibration_batches is not None and (
not isinstance(self.num_calibration_batches, int)
or isinstance(self.num_calibration_batches, bool)
or self.num_calibration_batches <= 0
):
raise ValueError("num_calibration_batches must be positive or None.")
if not isinstance(self.show_progress, bool):
raise ValueError("show_progress must be bool.")
if (
not isinstance(self.eps, (int, float))
or isinstance(self.eps, bool)
or self.eps <= 0
or not math.isfinite(self.eps)
):
raise ValueError("eps must be a positive number.")
[文档]
def before_trace(self, converter: "Converter", ann: nn.Module) -> nn.Module:
ann.eval()
return ann
[文档]
def insert_observers(
self, converter: "Converter", fx_model: fx.GraphModule
) -> fx.GraphModule:
self._remove_hooks()
self._observers = {}
modules = dict(fx_model.named_modules())
for node in fx_model.graph.nodes:
if node.op != "call_module":
continue
if not isinstance(node.target, str):
continue
module = modules.get(node.target)
if isinstance(module, nn.Linear):
if not self._should_spike_linear(node.target):
continue
observer = _STAThresholdObserver(
self.time_steps,
channel_dim=-1,
mode=self.threshold_mode,
momentum=self.momentum,
eps=self.eps,
)
elif isinstance(module, nn.Conv2d):
if not self.spike_conv2d:
continue
observer = _STAThresholdObserver(
self.time_steps,
channel_dim=1,
mode=self.threshold_mode,
momentum=self.momentum,
eps=self.eps,
)
elif self.mode == "spiking_encoder" and isinstance(
module, (nn.LayerNorm, nn.GELU, nn.MultiheadAttention)
):
observer = _STAThresholdObserver(
self.time_steps,
channel_dim=-1,
mode=self.threshold_mode,
momentum=self.momentum,
eps=self.eps,
)
else:
continue
self._observers[node.target] = observer
handle = module.register_forward_hook(
lambda _module, _inputs, output, obs=observer: obs(output)
)
self._hook_handles.append(handle)
return fx_model
[文档]
def calibrate(
self, converter: "Converter", fx_model: fx.GraphModule
) -> fx.GraphModule:
if not self._observers:
return fx_model
fx_model.eval()
iterator = self.dataloader
if self.show_progress:
iterator = tqdm(iterator, desc="STA calibration")
try:
with torch.no_grad():
for batch_index, batch in enumerate(iterator):
if (
self.num_calibration_batches is not None
and batch_index >= self.num_calibration_batches
):
break
args, kwargs = self._batch_to_args(batch, converter.device)
fx_model(*args, **kwargs)
finally:
self._remove_hooks()
return fx_model
[文档]
def replace(
self, converter: "Converter", fx_model: fx.GraphModule
) -> fx.GraphModule:
return self._replace_sequence(fx_model)
def _replace_sequence(self, fx_model: fx.GraphModule) -> fx.GraphModule:
self._remove_hooks()
modules = dict(fx_model.named_modules())
for node in fx_model.graph.nodes:
if node.op != "call_module" or not isinstance(node.target, str):
continue
module = modules.get(node.target)
if isinstance(module, nn.Linear):
replacement = self._make_td_linear(module)
elif isinstance(module, nn.Conv2d):
replacement = self._make_td_conv2d(module)
else:
continue
replacement.train(module.training)
self._replace_submodule(fx_model, node.target, replacement)
modules[node.target] = replacement
self._wrap_time_constants(fx_model)
modules = dict(fx_model.named_modules())
for node in fx_model.graph.nodes:
if node.op != "call_module" or not isinstance(node.target, str):
continue
module = modules.get(node.target)
threshold = None
if isinstance(module, nn.LayerNorm):
observer = self._observers.get(node.target)
threshold = self._compute_scaled_threshold(observer)
replacement = self._make_td_layer_norm(module)
elif isinstance(module, nn.GELU):
observer = self._observers.get(node.target)
threshold = self._compute_scaled_threshold(observer)
replacement = self._make_td_gelu(module)
elif isinstance(module, nn.MultiheadAttention):
self._validate_sequence_mha_node(node)
observer = self._observers.get(node.target)
threshold = self._compute_scaled_threshold(observer)
replacement = _make_td_multihead_attention(module)
else:
continue
replacement.train(module.training)
self._replace_submodule(fx_model, node.target, replacement)
modules[node.target] = replacement
if threshold is not None:
if isinstance(replacement, TDMultiheadAttention):
encoder_target = self._insert_sequence_mha_output_encoder(
fx_model, node, threshold
)
else:
encoder_target = self._insert_sequence_encoder(
fx_model, node, threshold, channel_dim=-1
)
modules[encoder_target] = fx_model.get_submodule(encoder_target)
return adapt_step_mode_graph(
fx_model,
context="STATransformerRecipe step-mode backend",
safe_module_types=_TRANSFORMER_SAFE_MODULE_TYPES,
)
[文档]
def finalize(self, converter: "Converter", fx_model: fx.GraphModule) -> nn.Module:
object.__setattr__(fx_model, "time_steps", self.time_steps)
return fx_model
@staticmethod
def _make_td_linear(module: nn.Linear) -> TDLinear:
replacement = TDLinear(
module.in_features,
module.out_features,
bias=module.bias is not None,
device=module.weight.device,
dtype=module.weight.dtype,
)
with torch.no_grad():
replacement.weight.copy_(module.weight)
if module.bias is not None:
replacement.bias.copy_(module.bias)
replacement.weight.requires_grad = module.weight.requires_grad
if module.bias is not None:
replacement.bias.requires_grad = module.bias.requires_grad
return replacement
@staticmethod
def _make_td_conv2d(module: nn.Conv2d) -> TDConv2d:
replacement = TDConv2d(
module.in_channels,
module.out_channels,
module.kernel_size,
stride=module.stride,
padding=module.padding,
dilation=module.dilation,
groups=module.groups,
bias=module.bias is not None,
padding_mode=module.padding_mode,
device=module.weight.device,
dtype=module.weight.dtype,
)
with torch.no_grad():
replacement.weight.copy_(module.weight)
if module.bias is not None:
replacement.bias.copy_(module.bias)
replacement.weight.requires_grad = module.weight.requires_grad
if module.bias is not None:
replacement.bias.requires_grad = module.bias.requires_grad
return replacement
@staticmethod
def _make_td_layer_norm(module: nn.LayerNorm) -> TDLayerNorm:
replacement = TDLayerNorm(
module.normalized_shape,
eps=module.eps,
elementwise_affine=module.elementwise_affine,
bias=module.bias is not None,
device=module.weight.device if module.weight is not None else None,
dtype=module.weight.dtype if module.weight is not None else None,
)
with torch.no_grad():
if module.weight is not None:
replacement.weight.copy_(module.weight)
replacement.weight.requires_grad = module.weight.requires_grad
if module.bias is not None:
replacement.bias.copy_(module.bias)
replacement.bias.requires_grad = module.bias.requires_grad
return replacement
@staticmethod
def _make_td_gelu(module: nn.GELU) -> TDGELU:
return TDGELU(approximate=getattr(module, "approximate", "none"))
@staticmethod
def _mha_need_weights_argument(node: fx.Node):
if "need_weights" in node.kwargs:
return node.kwargs["need_weights"]
if len(node.args) > 4:
return node.args[4]
return None
@staticmethod
def _mha_attention_weights_output_is_used(node: fx.Node) -> bool:
for user in node.users:
if (
user.op == "call_function"
and user.target is operator.getitem
and len(user.args) >= 2
and user.args[1] == 1
and len(user.users) > 0
):
return True
return False
@staticmethod
def _validate_sequence_mha_node(node: fx.Node) -> None:
if STATransformerRecipe._mha_key_padding_mask_argument(node) is not None:
raise ValueError(
"STATransformerRecipe step-mode backend does not support "
"MultiheadAttention key_padding_mask. Apply padding masking "
"to the input tensors directly or use attn_mask instead."
)
need_weights = STATransformerRecipe._mha_need_weights_argument(node)
if need_weights is True:
raise ValueError(
"STATransformerRecipe step-mode backend requires "
"MultiheadAttention calls with need_weights=False. Use "
"a custom online wrapper when attention weights are required."
)
if need_weights not in (None, False):
raise ValueError(
"STATransformerRecipe step-mode backend only supports "
"literal need_weights=False for MultiheadAttention."
)
if (
need_weights is None
and STATransformerRecipe._mha_attention_weights_output_is_used(node)
):
raise ValueError(
"STATransformerRecipe step-mode backend does not support "
"using MultiheadAttention attention weights. Pass "
"need_weights=False."
)
@staticmethod
def _mha_key_padding_mask_argument(node: fx.Node):
if "key_padding_mask" in node.kwargs:
return node.kwargs["key_padding_mask"]
if len(node.args) > 3:
return node.args[3]
return None
@staticmethod
def _insert_sequence_encoder(
fx_model: fx.GraphModule,
node: fx.Node,
threshold: torch.Tensor,
channel_dim: int,
) -> str:
modules = dict(fx_model.named_modules())
index = 0
target_base = node.target if isinstance(node.target, str) else node.name
target = f"{target_base}_sta_encoder"
while target in modules:
index += 1
target = f"{target_base}_sta_encoder_{index}"
fx_model.add_submodule(
target,
_STASpikeEncoder(threshold, channel_dim=channel_dim, step_mode="m"),
)
with fx_model.graph.inserting_after(node):
encoder_node = fx_model.graph.call_module(target, args=(node,))
for user in list(node.users):
if user is not encoder_node:
user.replace_input_with(node, encoder_node)
return target
@staticmethod
def _insert_sequence_mha_output_encoder(
fx_model: fx.GraphModule,
node: fx.Node,
threshold: torch.Tensor,
) -> str:
output_node = None
for user in node.users:
if (
user.op == "call_function"
and user.target is operator.getitem
and len(user.args) >= 2
and user.args[1] == 0
):
output_node = user
break
if output_node is None:
raise ValueError(
"STATransformerRecipe step-mode backend with "
"spiking_encoder requires MultiheadAttention output to be "
"unpacked before use."
)
return STATransformerRecipe._insert_sequence_encoder(
fx_model, output_node, threshold, channel_dim=-1
)
@staticmethod
def _batch_to_args(
batch: Any, device: torch.device
) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
if isinstance(batch, dict):
kwargs = {
key: STATransformerRecipe._to_device(value, device)
for key, value in batch.items()
}
return (), kwargs
if isinstance(batch, (tuple, list)):
if len(batch) == 0:
raise ValueError("Calibration batch must not be empty.")
if isinstance(batch[0], (tuple, list)):
args = tuple(
STATransformerRecipe._to_device(value, device) for value in batch[0]
)
kwargs = (
STATransformerRecipe._to_device(batch[1], device)
if len(batch) > 1 and isinstance(batch[1], dict)
else {}
)
return args, kwargs
return (STATransformerRecipe._to_device(batch[0], device),), {}
return (STATransformerRecipe._to_device(batch, device),), {}
@staticmethod
def _to_device(value: Any, device: torch.device) -> Any:
if torch.is_tensor(value):
return value.to(device)
if isinstance(value, tuple):
return tuple(STATransformerRecipe._to_device(v, device) for v in value)
if isinstance(value, list):
return [STATransformerRecipe._to_device(v, device) for v in value]
if isinstance(value, dict):
return {
key: STATransformerRecipe._to_device(v, device)
for key, v in value.items()
}
return value
@staticmethod
def _replace_submodule(
fx_model: torch.fx.GraphModule, target: str, module: nn.Module
) -> None:
parent_name, _, child_name = target.rpartition(".")
parent = fx_model.get_submodule(parent_name) if parent_name else fx_model
setattr(parent, child_name, module)
@staticmethod
def _get_attr_value(fx_model: fx.GraphModule, target: str) -> Any:
value: Any = fx_model
for atom in target.split("."):
value = getattr(value, atom)
return value
def _wrap_time_constants(self, fx_model: fx.GraphModule) -> None:
modules = dict(fx_model.named_modules())
existing_modules = set(modules.keys())
constant_index = 0
for node in list(fx_model.graph.nodes):
if node.op != "get_attr" or not isinstance(node.target, str):
continue
value = self._get_attr_value(fx_model, node.target)
if not torch.is_tensor(value):
continue
if not value.is_floating_point():
continue
if self._is_static_control_attr(node, modules):
continue
if self._is_functional_parameter_attr(node):
continue
target = f"sta_time_constant_{constant_index}"
while target in existing_modules:
constant_index += 1
target = f"sta_time_constant_{constant_index}"
constant_index += 1
module = _STAConstant(value, self.time_steps)
fx_model.add_submodule(target, module)
existing_modules.add(target)
with fx_model.graph.inserting_after(node):
constant_node = fx_model.graph.call_module(target, args=())
for user in list(node.users):
user.replace_input_with(node, constant_node)
fx_model.graph.erase_node(node)
@staticmethod
def _is_static_control_attr(node: fx.Node, modules: Dict[str, nn.Module]) -> bool:
for user in node.users:
if STATransformerRecipe._is_static_control_arg(user, node, modules):
return True
for key, value in user.kwargs.items():
if key in _STATIC_TENSOR_KWARGS and value is node:
return True
return False
@staticmethod
def _is_static_control_arg(
user: fx.Node,
node: fx.Node,
modules: Optional[Dict[str, nn.Module]] = None,
) -> bool:
args = tuple(user.args)
if user.op == "call_module":
target = getattr(user, "target", None)
if not isinstance(target, str) or modules is None:
return False
module = modules.get(target)
if not isinstance(
module,
(
nn.MultiheadAttention,
_STAOnlineMultiheadAttention,
TDMultiheadAttention,
),
):
return False
return (
(len(args) > 3 and args[3] is node)
or (len(args) > 5 and args[5] is node)
or user.kwargs.get("key_padding_mask") is node
or user.kwargs.get("attn_mask") is node
)
if user.op != "call_function":
return False
if user.target is F.scaled_dot_product_attention:
return len(args) > 3 and args[3] is node
return False
@staticmethod
def _is_functional_parameter_attr(node: fx.Node) -> bool:
weighted_function_targets = {
F.linear,
F.conv1d,
F.conv2d,
F.conv3d,
}
matmul_targets = {
torch.matmul,
operator.matmul,
}
for user in node.users:
if user.op != "call_function":
continue
args = tuple(user.args)
if user.target in weighted_function_targets:
if len(args) > 1 and node is args[1]:
return True
if user.kwargs.get("weight") is node:
return True
elif user.target in matmul_targets:
if node in args:
return True
if user.kwargs.get("input") is node or user.kwargs.get("other") is node:
return True
return False
def _should_spike_linear(self, target: str) -> bool:
if not self.spike_linear:
return False
if self.spike_classifier:
return True
parts = target.split(".")
return not any(part in {"head", "heads", "classifier"} for part in parts)
def _compute_scaled_threshold(
self, observer: Optional[_STAThresholdObserver]
) -> Optional[torch.Tensor]:
if observer is None:
return None
if observer.threshold is None:
raise RuntimeError(
"STA threshold observer did not collect calibration data."
)
return observer.compute_threshold() * self.threshold_scale
def _remove_hooks(self) -> None:
for handle in self._hook_handles:
handle.remove()
self._hook_handles = []