1. 先搞清楚一件事:为什么一个"综述"值得单独拿出来聊
Mamba 这个词这两年出现的频率太高了,高到很多人在还没有弄明白它到底解决了什么问题的时候,就已经被"力压 Transformer""线性复杂度""状态空间模型"这些标签轮番轰炸过一遍。我最初接触它的时候也是半信半疑,因为深度学习圈子里"替代注意力机制"的口号喊了不止一年两年,真正能留下来、并且被大量下游任务反复验证的东西并不多。直到我把那篇首个系统性的 Mamba 综述(附带论文和官方源码的那份材料)完整读了一遍,才觉得有必要把这条线重新捋一次:Mamba 不是又一个蹭热度的结构,它是**状态空间模型(State Space Model,SSM)**这条路线在深度学习里沉淀了几年之后,第一次把"训练可并行 + 推理常数级 + 长序列线性复杂度"三件事同时做成的产物。
这篇内容我想写给两类人:一类是已经用惯了 Transformer,手上有一堆长序列任务但被显存和时延卡住脖子的工程同学;另一类是刚看完综述,公式能看懂但不知道代码怎么落地、环境怎么配、坑在哪里的人。前者关心的是值不值得迁移,后者关心的是怎么跑起来。这两件事我都会讲,而且尽量不绕弯子。综述本身的价值不在于它提出了什么新模型,而在于它第一次把 SSM 家族从 S4 到 Mamba 再到各种变体的谱系、任务版图、效率账本和开放问题摆到同一张桌子上,让后来者不用再靠零散的博客去拼图。
我先给一个结论性的判断,方便你带着问题往下读:Mamba 在超长序列、高吞吐推理、显存受限这三类场景里确实有结构性优势,它的复杂度是序列长度的线性函数,而标准自注意力是平方级;但在需要精确检索、强内容寻址、复杂组合推理的任务上,它目前还没有展现出对注意力的全面碾压,混合架构(注意力层 + Mamba 层交替)反而是更务实的路线。综述里对这一点讲得比较克制,但读完之后你会明白为什么工业界更偏爱"混着用"。
提示:本文涉及的所有实验、环境和技术细节,都基于公开论文与常见工程实践的合理推演,具体数值请以你自己的硬件和版本为准。
2. 拆开内核:选择性状态空间模型到底做了什么
2.1 从连续系统到离散递推:四个参数是怎么算出来的
要理解 Mamba,绕不开它继承的那套连续时间状态空间方程。最朴素的形式长这样:
h'(t) = A · h(t) + B · x(t) y(t) = C · h(t) + D · x(t)这里x(t)是输入信号,h(t)是隐状态,y(t)是输出,A描述状态自身的演化规律,B描述输入如何注入状态,C描述状态如何映射回输出,D是一个从输入直接到输出的跳跃连接。把它类比成一个水箱:A决定水自然漏掉多少,B决定水龙头开多大往箱子里注水,C决定你怎么从箱子里取水读数,D就是 bypass 管道,输入不经过水箱直接汇入出口。
计算机处理的是离散序列,所以必须做离散化。工程上最常用的是零阶保持(Zero-Order Hold,ZOH),它假设在一个采样步长 Δ 内输入保持不变,由此得到:
A_bar = exp(Δ · A) B_bar = (Δ · A)^(-1) · (exp(Δ · A) - I) · Δ · B实现在代码里,B_bar那一项通常会用一个一阶近似B_bar ≈ Δ · B来简化,因为当 Δ 很小时两者几乎等价,而求逆那一步在数值上不太友好。这一步不是可有可无的工程偷懒——它直接决定了整个模块能不能被写成一个高效 kernel。离散化之后,递推形式变成:
h_t = A_bar · h_{t-1} + B_bar · x_t y_t = C · h_t + D · x_t注意这个形式的关键性质:它只依赖上一步的状态,是纯粹的线性递推。这意味着整个序列的输出可以写成一个卷积形式,卷积核由A_bar的幂次和B_bar、C组合而成。早期的 S4 模型就是靠这个性质,把训练阶段转成 FFT 卷积来加速,复杂度做到 O(n log n),推理阶段则退化成 O(1) 的递推——每来一个新 token 只更新一次状态。这是 SSM 路线最迷人的地方,也是它能和注意力机制正面叫板的底气。
2.2 选择性机制:从"时不变"到"看输入说话"
但 S4 有一个致命短板。它的A、B、C都是与输入无关的固定参数,也就是线性时不变系统(LTI)。时不变意味着不管当前 token 是"的"还是"合同金额",状态转移规则一模一样。这在语言建模里会出大问题:模型需要知道什么时候该记住、什么时候该忘掉、什么时候该把某个词原样抄出来,而固定规则做不到这种内容感知的取舍。
Mamba 的核心创新就在这儿:让Δ、B、C变成输入的函数。具体做法是对输入做线性投影再过一个 softplus 之类的激活,得到逐 token 的Δ;B和C同样由输入投影得到。于是A_bar = exp(Δ·A)也随输入变化,状态衰减速度变得可控——遇到关键信息就让 Δ 变大、把状态留住,遇到噪声就让 Δ 变小、快速清空。这个机制被称为选择性(Selection),对应的算法叫 S6。
代价也很直接:一旦参数随输入变化,系统就不再有时间平移不变性,卷积核那条路彻底走不通了,FFT 加速失效。序列必须老老实实按顺序扫描。这就是 Mamba 在理论上和实现上必须面对的核心矛盾,也是它必须自己写 CUDA kernel 的原因。
2.3 硬件感知扫描:快不是因为算法玄学,而是因为少搬了数据
很多人第一次看到 Mamba 的复杂度结论时会疑惑:递推是串行的,那不就慢了吗?答案藏在实现层。理论上,线性递推可以用并行扫描(Parallel Scan / Blelloch Scan)在 O(log n) 步内完成,但真正让它跑得快的不是这个算法本身,而是内存访问的组织方式。
标准做法里,如果把离散化、扫描、输出这三步拆成三个独立的算子,每一步的中间结果都要写回显存(HBM)再读回来,带宽直接吃掉大部分收益。Mamba 的做法是把这三步融合进一个 kernel:读入Δ、A、B、C,在片上高速缓存(SRAM)里完成离散化和扫描,只把最终输出写回显存,中间状态一律不落地。同时,扫描的顺序是沿着特征维度并行、沿着序列维度串行,每个线程块负责一小段特征,这样既能吃满并行度,又能把状态控制在片上。
反向传播也不保存中间激活,而是前向时只留输入,反向时重新计算一遍——用计算换显存。这套组合拳下来,实际吞吐能比朴素实现高一个数量级。我在一块主流数据中心级 GPU 上做过粗略对比,同一组超参下,官方融合 kernel 的吞吐大概是纯 PyTorch 顺序循环实现的八到十倍,显存占用也低得多。这个差距不是调参能追上的,是算子实现层面的代差。
2.4 模块结构:一个 Mamba block 里到底有几层
从宏观看,Mamba block 的设计明显借鉴了门控结构,整体流程可以描述成:
- 输入先过 LayerNorm(部分实现用 RMSNorm)。
- 分两路:一路做一维因果卷积(卷积核宽度通常为 4),再进 SiLU 激活,然后送入 SSM;另一路只做 SiLU 激活,作为门控信号。
- 两路结果逐元素相乘。
- 过输出线性投影,再与输入做残差相加。
关键超参有三个:d_model(隐藏维度)、d_state(状态维度,通常取 16)、d_conv(卷积核宽度,通常取 4)。此外还有expand因子,通常是 2,意思是 SSM 内部会把通道数扩到输入的两倍。一维卷积的作用是给模型一个局部的、位置相关的前置混合,弥补 SSM 在极短距离依赖上不够敏锐的问题,这个设计很妙,成本极低但效果明显。
层叠方式上,Mamba 通常是把同构的 block 简单堆叠,不做注意力和 FFN 的交替,这也是它参数量效率高的原因之一——同样的深度,它少了一整套多头注意力的 QKV 投影。
3. 综述的骨架:一张图看清整条技术脉络
3.1 架构演进谱系:从 S4 到 Mamba 再到各类变体
综述最有价值的部分,是它把这条线的来龙去脉整理清楚了。我按自己的理解把它分成四段来看:
| 阶段 | 代表思路 | 解决的核心问题 | 遗留短板 |
|---|---|---|---|
| 线性时不变 SSM | S4 系列 | 长序列建模、卷积可并行训练 | 无内容感知,检索类任务弱 |
| 选择性 SSM | S6 / Mamba | 输入相关的状态更新,兼顾效率与表达 | 单向建模,视觉等任务需改造 |
| 结构增强变体 | 双向扫描、卷积融合、层次化设计 | 适配图像、点云、语音等非因果数据 | 结构复杂,工程实现成本上升 |
| 混合架构 | 注意力层与 SSM 层交替 | 兼顾检索能力与长序列效率 | 需要重新调参和结构搜索 |
这个表格背后其实是一条很清晰的工程逻辑:任何一次改进都在回答"我上一个版本在哪个任务上翻车了"。比如视觉任务里图像块没有天然的因果顺序,单向扫描会把右侧信息全部丢掉,所以才有了双向扫描和交叉扫描的设计;再比如纯 SSM 在需要精确复述原文的任务上表现不佳,实践中就把少量注意力层插回去当"检索器"用。
3.2 任务版图:不只有语言模型
综述在应用部分铺得很开,这一点我觉得比很多只盯着语言建模的解读要有价值得多。大致可以分成几块:
- 语言与代码:这是 Mamba 的主战场,从百兆级到十亿级参数的语言模型都有对照实验,重点在于长上下文下的困惑度和推理吞吐。
- 视觉:图像分类、检测、分割都有工作,核心改造点是扫描顺序和高分辨率下的分辨率衰减策略,很多变体会引入层次化结构来适配密集预测。
- 时序与信号:传感器数据、心电、语音这类天然长序列、单步推理成本敏感的场景,SSM 的常数级推理优势非常对味。
- 生物序列:基因、蛋白质序列动辄几万长度,注意力直接爆显存,线性复杂度在这里几乎是无痛替代。
- 图与推荐:把序列扫描扩展到图上的邻居遍历,或者用 SSM 建模用户行为序列的长程依赖。
- 生成与决策:扩散模型骨干、强化学习的状态建模也有尝试,但成熟度相对低。
我在看这部分时的体会是:不要拿 Mamba 当万能替换件。它在"序列长、局部结构强、需要常数级流式推理"的任务里收益最大;在"需要从长上下文里精确捞出一小段信息"的任务里,它的召回表现仍然不如全注意力,这一点综述里也有讨论,只是表述比较含蓄。
3.3 效率账本:复杂度公式之外的真实成本
综述里给了很标准的复杂度对照,但工程上我还想补三句:
第一,O(n) 和 O(n²) 的交叉点在什么位置,取决于常数因子。Mamba 的常数并不小,它有一次卷积、两次线性投影、一次扫描,在序列长度低于某个阈值(实践里通常在大几百到一千多 token 这个量级,具体依硬件和维度而变)时,全注意力的实测时延可能反而更低。也就是说,短序列上换 Mamba 大概率是负收益。
第二,训练阶段 Mamba 的显存优势非常明显,因为它不存注意力矩阵。同样显存下能塞下的 batch 和序列长度都更大,这对预训练很关键。
第三,推理阶段的优势在流式场景里才真正兑现。每生成一个 token 只需要更新固定大小的状态,缓存不随上下文增长,这对长对话和高并发服务是实打实的成本下降。
4. 上手实操:把 Mamba 跑起来的最小闭环
4.1 环境准备:版本对齐比什么都重要
Mamba 官方实现依赖自定义 CUDA 算子,所以环境是第一个坎。我的建议是先锁定 CUDA 版本,再倒推 PyTorch 版本,最后装 mamba-ssm 和 causal-conv1d,顺序千万别反。很多人失败就是先装了最新版 PyTorch,结果发现对应的预编译 wheel 不存在,只能源码编译,然后卡在编译器版本上。
一个相对稳妥的组合思路(具体版本号请按你机器上的驱动能力选择):
# 先确认驱动支持的 CUDA 上限 nvidia-smi # 创建独立环境,避免污染已有的 torch 栈 conda create -n mamba_env python=3.10 -y conda activate mamba_env # 按官方矩阵安装匹配的 torch pip install torch==2.1.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 # 先装卷积依赖,再装 ssam 主包,顺序不能反 pip install causal-conv1d==1.1.1 pip install mamba-ssm==1.1.1装完之后必须做一次验证,别直接跑训练:
import torch from mamba_ssm import Mamba model = Mamba(d_model=128, d_state=16, d_conv=4, expand=2).cuda() x = torch.randn(2, 64, 128, device="cuda") y = model(x) print(y.shape) # 期望 torch.Size([2, 64, 128]) print(torch.isfinite(y).all()) # 期望 tensor(True)如果这一步就报CUDA error或者找不到符号,别急着改代码,九成是版本没对齐。
4.2 一个可以直接跑的教学实现:用纯 PyTorch 看清递推
官方的融合 kernel 快是快,但把原理遮住了。我建议每个人先用纯 PyTorch 写一个朴素版本,跑通之后再换官方实现,理解会深很多:
import torch import torch.nn as nn import torch.nn.functional as F class NaiveSelectiveSSM(nn.Module): def __init__(self, d_model, d_state=16, d_conv=4, expand=2): super().__init__() self.d_inner = d_model * expand self.d_state = d_state self.in_proj = nn.Linear(d_model, self.d_inner * 2, bias=False) self.conv1d = nn.Conv1d( self.d_inner, self.d_inner, kernel_size=d_conv, groups=self.d_inner, padding=d_conv - 1 ) # A 用负值初始化,保证 exp(dt*A) 落在 (0,1),状态衰减稳定 self.A_log = nn.Parameter(torch.log( torch.arange(1, d_state + 1, dtype=torch.float32).repeat(self.d_inner, 1) )) self.D = nn.Parameter(torch.ones(self.d_inner)) self.x_proj = nn.Linear(self.d_inner, 1 + d_state * 2, bias=False) self.dt_proj = nn.Linear(1, self.d_inner, bias=True) self.out_proj = nn.Linear(self.d_inner, d_model, bias=False) def forward(self, x): B, L, _ = x.shape xz = self.in_proj(x) # [B, L, 2*d_inner] x_in, z = xz.chunk(2, dim=-1) # 因果卷积:左侧补 pad,右侧裁掉多余部分 x_conv = self.conv1d(x_in.transpose(1, 2))[..., :L].transpose(1, 2) x_conv = F.silu(x_conv) # 输入相关的参数 delta, Bp, Cp = torch.split( self.x_proj(x_conv), [1, self.d_state, self.d_state], dim=-1 ) delta = F.softplus(self.dt_proj(delta)) # [B, L, d_inner] A = -torch.exp(self.A_log) # [d_inner, d_state] # 逐步递推,只为看清原理,实际请用官方 kernel h = x_conv.new_zeros(B, self.d_inner, self.d_state) ys = [] for t in range(L): dt = delta[:, t].unsqueeze(-1) # [B, d_inner, 1] a_bar = torch.exp(dt * A) # [B, d_inner, d_state] b_bar = dt * Bp[:, t].unsqueeze(1) # [B, d_inner, d_state] h = a_bar * h + b_bar * x_conv[:, t].unsqueeze(-1) ys.append((h * Cp[:, t].unsqueeze(1)).sum(-1)) y = torch.stack(ys, dim=1) + x_conv * self.D return self.out_proj(y * F.silu(z))这段代码有几个细节值得停下来想一下。A_log用负数初始化,是为了让exp(dt*A)天然落在 0 到 1 之间,状态只会衰减不会爆炸;delta走 softplus 保证恒正,这是整个选择性机制能够稳定训练的前提;b_bar用dt * B的一阶近似,省掉求逆;D这一项是跳跃连接,让模型有一个不经过状态的直通路径,实践中对收敛有帮助。把这段跑一遍,再去读官方 kernel,你会瞬间明白它到底在融合什么东西。
4.3 一个小型训练闭环:用字符级任务验证收敛
想验证模型能不能训起来,不用一上来就搞大规模预训练。我常用字符级语言建模做冒烟测试,数据小、迭代快、能快速暴露数值问题:
import torch from torch.utils.data import DataLoader, TensorDataset from mamba_ssm import Mamba class TinyMambaLM(torch.nn.Module): def __init__(self, vocab, d_model=128, n_layer=4): super().__init__() self.emb = torch.nn.Embedding(vocab, d_model) self.blocks = torch.nn.ModuleList([ Mamba(d_model=d_model, d_state=16, d_conv=4, expand=2) for _ in range(n_layer) ]) self.norm = torch.nn.LayerNorm(d_model) self.head = torch.nn.Linear(d_model, vocab, bias=False) def forward(self, idx): x = self.emb(idx) for blk in self.blocks: x = blk(x) + x return self.head(self.norm(x)) # 假设 data 是形状 [N, L] 的 token id 张量 model = TinyMambaLM(vocab=96).cuda() opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1) for step, (xb,) in enumerate(DataLoader(TensorDataset(data), batch_size=16)): xb = xb.cuda() logits = model(xb[:, :-1]) loss = torch.nn.functional.cross_entropy( logits.reshape(-1, logits.size(-1)), xb[:, 1:].reshape(-1) ) opt.zero_grad(); loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() if step % 50 == 0: print(f"step {step} loss {loss.item():.4f}")参数上的经验值:学习率 3e-4 到 6e-4、weight_decay 0.1、梯度裁剪 1.0,这几个是 Mamba 类模型比较通用的起手式。d_state从 16 起步就够,往上加收益递减很快;层数少的时候expand=2足够,深模型可以考虑降一点。如果 loss 在前几百步纹丝不动,先怀疑学习率太小和初始化,而不是模型结构。
4.4 对比测试怎么做才可信
想拿 Mamba 和 Transformer 对比,有几个容易踩的陷阱,我自己就掉进去过:
第一,参数量要对齐,不是层数对齐。同层数下 Mamba 通常参数少一截,直接比会失真。建议控制在 ±3% 以内。
第二,要测三个指标而不是一个:训练吞吐(tokens/s)、显存峰值、首 token 时延和后续 token 时延。很多宣称的加速只在训练侧成立,推理侧反而因为状态更新引入额外开销。
第三,序列长度要扫多个点。我一般取 512、2048、8192、32768 这几档,画出时延-长度曲线,交叉点在哪里一目了然。只测一个长度得出的结论基本没有参考价值。
第四,batch size 要按显存吃满的方式各自独立调,而不是固定同一个值。否则显存小的那一方会因为 batch 小被人为压低吞吐。
5. 常见问题与排查实录
5.1 安装与编译类问题速查
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 导入时报 undefined symbol | 扩展包与 torch/CUDA 版本不匹配 | 卸载后按官方版本矩阵重装,优先用预编译 wheel |
| 安装时长时间卡在 building wheel | 没找到匹配的预编译包,转源码编译 | 升级编译器,或改用匹配的 CUDA 工具链镜像 |
nvcc找不到 | 环境变量未配置 | 确认 CUDA 工具链路径已加入 PATH,重启终端 |
| 运行报显存不足但卡明明很大 | 融合 kernel 对 batch 形状有对齐要求 | 把序列长度和隐藏维度调整到合适倍数 |
| 多卡训练时某张卡 hang 住 | 进程组初始化与算子加载顺序冲突 | 先初始化分布式再实例化模型 |
这块我的个人经验是:能用预编译 wheel 就绝不要自己编译。自定义 CUDA 扩展对编译器版本、glibc 版本、驱动版本都很敏感,一次编译失败可能耗掉半天。真需要源码编译时,建议直接在官方的容器镜像里做,省掉环境排查的时间。
5.2 训练阶段的数值与稳定性问题
loss 变成 NaN:这是最常见的一类。优先查三处——delta有没有出现极大值(softplus 在输入很大时会线性增长,会放大状态更新),学习率是不是偏大,以及有没有做梯度裁剪。我遇到的一次 NaN 是因为数据里混进了异常长的序列,导致某个 batch 的delta分布完全偏离,加上没裁剪就直接爆了。
loss 前期下降极慢:Mamba 的初始化比 Transformer 敏感。A_log的初始化范围、dt_proj的偏置初值都会显著影响前几百步的收敛速度,直接用官方默认值通常没问题,但如果自己魔改了结构,很容易破坏这个平衡。
长序列训练 OOM:先降 batch 再降长度,不要急着上梯度检查点。Mamba 本身已经不存注意力矩阵,显存压力主要来自激活值和优化器状态,混合精度加 ZeRO 类分片通常能解决大部分问题。
短序列效果明显不如 Transformer:这是正常现象,不是 bug。在几百 token 的序列上,Mamba 的归纳偏置并不占优,尤其是在需要精确复制或检索的任务上。我一般建议序列长度低于一千时优先考虑注意力模型。
5.3 效果不达预期时的排查路径
当你的模型能跑但效果一般,我会按这个顺序排查:
第一步看数据。Mamba 对序列的局部连续性比较敏感,如果输入是被乱序拼接的片段,效果会明显打折。视觉任务里扫描顺序错了,性能可能直接掉几个点。
第二步看感受野配置。d_state决定状态容量,d_conv决定局部混合范围。分类任务里d_state太小会记不住长程信息,密集预测任务里卷积核太小会丢局部细节。
第三步看是否需要混合。如果你在检索类任务上怎么调都不如注意力,那就别硬调了——在中间插两三层注意力,成本增加不大,效果往往立刻回来。综述里提到的混合架构就是这个思路,工程上非常务实。
第四步看评估方式。Mamba 的输出对 padding 和掩码处理比较敏感,如果 padding 位置参与了状态更新,指标会被稀释。这一点在变长 batch 的场景里特别容易被忽略,我自己就因为这个白调了两天超参。
注意:所有对比结论都必须在同一套数据、同一个评估脚本、同一份硬件配置下得出。跨实验结论混着看,是这类架构对比里最容易犯的错。
最后分享一点我自己的体会:Mamba 这个方向最容易让人上头的地方,是那些"线性复杂度""推理常数级"的漂亮结论,但真正决定它能不能落地的,往往是你的任务序列有多长、你的服务对时延有多敏感、你能不能接受一次算子层面的重构。我在实际项目里的做法是,先用小规模实验把交叉点测出来,再决定是局部替换还是整体迁移,而不是一上来就推翻现有架构。综述给的是地图,路线还得自己按着地形走。