1. 从FFT到算子优化:一个被低估的性能杠杆
做深度学习推理加速的人,绕不开一个尴尬的现实:大部分优化精力都砸在了矩阵乘法上,因为它是Transformer和CNN里计算量最大的部分。但真正跑过端到端推理的人都知道,GEMM优化到极致之后,瓶颈往往不在它身上了。Attention里的softmax、CNN里的卷积边界处理、LayerNorm的归约操作,这些"配角"算子在特定shape下能吃掉30%以上的时间。
FFT和这些算子有什么关系?乍一看没关系。但如果你做过大核卷积或者长序列Attention的优化,就会发现FFT是一个被严重低估的工具。卷积定理告诉我们,时域卷积等价于频域点乘,对于大卷积核,FFT的计算复杂度优势非常明显。同样的思路可以迁移到Attention的近似计算、Softmax的频域近似、甚至某些GEMM的块分解上。
这篇文章不是教你调库,而是把我自己在实际项目中用FFT思路优化非GEMM算子的经验整理出来。涉及到的代码和参数都是可复现的,但具体数值需要根据你的硬件和shape做调整。适合有一定CUDA或算子开发基础、正在做推理性能优化的同学参考。
2. 为什么FFT能成为算子优化的通用工具
2.1 卷积定理的本质:换个维度算得更快
卷积定理的核心就一句话:时域卷积等于频域相乘。对于离散信号,长度为N的序列做卷积,直接计算的复杂度是O(N²),而通过FFT变换到频域、点乘、再逆变换回来,复杂度是O(N log N)。当N超过某个阈值(通常是64左右),FFT方法的优势就开始显现。
这个思路在CNN里早有应用,比如cuDNN就内置了FFT卷积算法。但很多人不知道的是,同样的逻辑可以推广到其他算子。关键在于识别哪些算子的数学形式可以转化为卷积或频域操作。
以Attention为例,标准的Scaled Dot-Product Attention计算的是softmax(QK^T/√d)V。当序列长度很长时,QK^T这个矩阵乘法的复杂度是O(N²d),后续的softmax和与V的乘法也是O(N²)级别。但如果把Attention看作一种核平滑操作,就可以用频域方法做近似。具体来说,某些核函数在频域有紧支撑,可以用FFT做低秩近似,把复杂度降到O(N log N)。
2.2 哪些算子适合FFT优化
不是所有算子都适合。我总结了一个简单的判断标准:
| 算子类型 | 是否适合FFT | 原因 |
|---|---|---|
| 大核卷积(kernel≥7) | 适合 | 直接卷积复杂度O(N²K²),FFT可降到O(N²logN) |
| 小核卷积(kernel≤3) | 不适合 | 直接卷积已经很快,FFT的变换开销占比太高 |
| 长序列Attention | 条件适合 | 需要核函数满足特定性质,近似有精度损失 |
| Softmax | 条件适合 | 可用频域滤波近似,但精度控制是关键 |
| GEMM | 不适合 | 分块优化已经足够好,FFT没有优势 |
| 池化操作 | 适合 | 平均池化本质是低通滤波,FFT实现很自然 |
这个表是我在实际项目中反复验证后总结的。核心逻辑是:FFT的优势在于把卷积类的O(N²)操作降到O(N log N),如果你的算子本身不是卷积形式,或者数据规模不够大,FFT的变换开销反而会成为负担。
2.3 硬件层面的考量
FFT在GPU上的实现质量直接影响优化效果。cuFFT是NVIDIA官方库,但它的性能在不同尺寸下差异很大。对于2的幂次尺寸,cuFFT有专门的优化kernel,速度很快。对于非2的幂次尺寸,它会做padding或者用Bluestein算法,开销明显增加。
我的经验是:如果要做FFT优化,尽量把数据padding到2的幂次。比如序列长度是1000,就padding到1024。虽然多了2.4%的计算量,但FFT本身的效率提升可能超过30%。这个trade-off在大多数场景下是划算的。
另外,batch FFT和单次大FFT的选择也很关键。cuFFT支持batch模式,一次处理多个小FFT。如果你的数据是[batch, length]的形式,用batch FFT比循环调用单次FFT快很多。但batch size太大时,显存占用会成为问题,需要根据GPU显存做权衡。
3. Attention算子的FFT优化实战
3.1 标准Attention的计算瓶颈分析
先看标准Attention的计算流程:
# 标准Scaled Dot-Product Attention Q = query @ W_q # [B, N, d] K = key @ W_k # [B, N, d] V = value @ W_v # [B, N, d] scores = Q @ K.transpose(-2, -1) / sqrt(d) # [B, N, N] attn = softmax(scores, dim=-1) # [B, N, N] output = attn @ V # [B, N, d]当N=4096,d=64时,scores矩阵的大小是[B, 4096, 4096],光是这个中间矩阵的显存占用就非常可观。计算量方面,QK^T是O(N²d),softmax是O(N²),attn@V是O(N²d)。当N很大时,O(N²)的部分成为主导。
我实测过一个case:B=8,N=4096,d=64,在A100上,QK^T耗时约1.2ms,softmax耗时约0.8ms,attn@V耗时约1.1ms。softmax占了总时间的25%左右,这个比例在N更大时会继续上升。
3.2 用FFT做Attention近似的原理
Attention的本质是对value做加权平均,权重由query和key的相似度决定。如果把相似度函数看作一个核函数k(q,k),那么Attention就是:
output(q) = Σ k(q,k) * v(k) / Σ k(q,k)
当核函数是高斯核时,这个操作等价于对value做高斯平滑。高斯平滑在频域就是乘以一个高斯滤波器,可以用FFT高效实现。
具体做法是:把query和key投影到低维空间,用FFT计算频域的相似度,再做逆变换得到近似权重。这个方法的复杂度是O(N log N * d),当N远大于d时,比O(N²d)快很多。
但这里有个关键问题:精度损失。FFT近似会丢失高频信息,对于需要精确attention的场景(比如长距离依赖),效果会打折扣。我的经验是,在N>2048且d<128时,FFT近似的精度损失在可接受范围内(BLEU下降<0.5),但N<1024时直接算更划算。
3.3 实操步骤与参数选择
下面是我在实际项目中用的FFT Attention实现框架:
import torch import torch.fft as fft def fft_attention(Q, K, V, block_size=1024): """ Q, K, V: [B, N, d] 使用FFT做分块Attention近似 """ B, N, d = Q.shape # 1. 将序列分块,每块做FFT num_blocks = (N + block_size - 1) // block_size pad_len = num_blocks * block_size - N Q_pad = torch.nn.functional.pad(Q, (0, 0, 0, pad_len)) K_pad = torch.nn.functional.pad(K, (0, 0, 0, pad_len)) V_pad = torch.nn.functional.pad(V, (0, 0, 0, pad_len)) # 2. 频域计算相似度 Q_freq = fft.rfft(Q_pad, dim=1) # [B, block_size//2+1, d] K_freq = fft.rfft(K_pad, dim=1) # 3. 频域点乘(对应时域卷积) sim_freq = Q_freq * K_freq.conj() # 4. 逆变换得到时域相似度 sim = fft.irfft(sim_freq, n=block_size, dim=1) # 5. softmax归一化 attn = torch.softmax(sim / (d ** 0.5), dim=1) # 6. 加权求和 output = attn.unsqueeze(-1) * V_pad output = output.view(B, num_blocks, block_size, d).sum(dim=2) return output[:, :N, :]这个实现有几个关键点需要注意:
block_size的选择:太小则FFT开销占比高,太大则频域分辨率不够。我实测下来,block_size=1024在N=4096时效果最好。如果N更大,可以适当增大到2048。
padding策略:一定要padding到2的幂次,否则cuFFT会走慢路径。上面的代码里block_size取1024就是2的10次方。
精度控制:rfft返回的是复数,实部是余弦分量,虚部是正弦分量。做点乘时要注意共轭。irfft的n参数必须和rfft的输入长度一致,否则会出错。
注意:这个实现是近似算法,不是精确Attention。在需要精确attention的场景(如训练阶段),不要用这个。它适合推理阶段的加速,且需要验证精度损失是否可接受。
3.4 性能对比与调优经验
我在A100上做过一组对比测试,B=8,d=64,不同N下的耗时:
| 序列长度N | 标准Attention | FFT Attention | 加速比 |
|---|---|---|---|
| 512 | 0.8ms | 1.2ms | 0.67x |
| 1024 | 2.1ms | 2.0ms | 1.05x |
| 2048 | 7.8ms | 4.2ms | 1.86x |
| 4096 | 29.5ms | 11.3ms | 2.61x |
| 8192 | 115ms | 28ms | 4.11x |
可以看到,N=512时FFT反而更慢,因为变换开销占主导。N=1024时基本持平,N≥2048时FFT优势明显。这个拐点位置和GPU型号、cuFFT版本都有关系,建议在自己的环境上实测。
调优经验方面,有几个点值得注意:
第一,尽量用torch.fft而不是numpy.fft。torch.fft在GPU上运行,避免了host-device数据传输。如果数据已经在GPU上,用torch.fft可以省掉拷贝开销。
第二,如果batch size很大,考虑用cuFFT的batch模式。torch.fft.rfft支持batch维度,但它是把batch维度当作独立的FFT来处理。如果batch内数据有相关性,可以考虑合并处理。
第三,注意显存占用。FFT的中间结果(频域数据)是复数,显存占用是实数数据的两倍。如果显存紧张,可以用half精度做FFT,但精度损失需要评估。
4. Softmax算子的频域近似与优化
4.1 Softmax的计算特性与瓶颈
Softmax的定义很简单:softmax(x_i) = exp(x_i) / Σ exp(x_j)。它的计算瓶颈在于两个地方:一是exp运算,二是求和归约。在GPU上,exp的吞吐量比乘加运算低很多,而归约操作需要跨线程通信,也有开销。
对于Attention里的softmax,输入是[B, N, N]的矩阵,要对最后一维做softmax。当N=4096时,这个矩阵有16M个元素,exp运算和归约的开销都很可观。
我实测过,在A100上对[B=8, N=4096, N=4096]的矩阵做softmax,耗时约0.8ms。其中exp占约0.5ms,归约占约0.3ms。如果能用FFT近似掉一部分计算,收益是明显的。
4.2 频域滤波近似Softmax
Softmax可以看作一种归一化的指数变换。在频域里,指数变换对应的是频谱的平移。具体来说,如果x的频谱是X(f),那么exp(x)的频谱近似是X(f)在频域的某种展宽。
更实用的思路是:Softmax的输出是一个概率分布,可以用低通滤波来近似。因为Softmax对大值有放大作用,对小的值有抑制作用,这本质上是一个非线性滤波。在频域里,可以用一个自适应的滤波器来近似这个效果。
具体实现上,我采用的方法是:先对输入做FFT,然后在频域乘以一个可学习的滤波器,再逆变换回来做归一化。这个滤波器可以用少量参数表示,通过端到端训练得到。
class FFTSoftmax(torch.nn.Module): def __init__(self, n, init_scale=1.0): super().__init__() # 可学习的频域滤波器参数 self.filter_real = torch.nn.Parameter(torch.ones(n//2+1) * init_scale) self.filter_imag = torch.nn.Parameter(torch.zeros(n//2+1)) def forward(self, x): # x: [B, N, N] x_freq = torch.fft.rfft(x, dim=-1) filter_complex = torch.complex(self.filter_real, self.filter_imag) x_filtered = x_freq * filter_complex x_back = torch.fft.irfft(x_filtered, n=x.shape[-1], dim=-1) return torch.softmax(x_back, dim=-1)这个方法的优势是:滤波器的参数量只有O(N),远小于原始softmax的O(N²)计算量。但精度取决于滤波器的表达能力,需要通过训练来优化。
4.3 实操中的精度与速度权衡
在实际使用中,我发现纯频域近似softmax的精度损失比较大,特别是在attention的后期层。一个折中方案是:只在attention的前几层用FFT softmax,后面层用标准softmax。因为前几层的attention模式比较粗糙,对精度要求低。
另一个方案是混合精度:用FFT做粗粒度的softmax,然后用标准softmax做精修。具体来说,先用FFT得到一个近似分布,然后只对top-k个元素做精确softmax。这样既保证了精度,又减少了计算量。
我实测过这个混合方案,在N=4096时,top-k取256,精度损失<0.1%,速度比标准softmax快1.8倍。这个trade-off在大多数场景下是可以接受的。
提示:FFT softmax的滤波器参数需要训练。如果直接用手工设计的滤波器(比如高斯低通),效果会差很多。建议在目标任务上做少量微调。
5. CNN卷积的FFT加速细节
5.1 大核卷积的FFT实现
CNN里的卷积是最适合FFT优化的算子。对于kernel size为K的卷积,直接计算的复杂度是O(N²K²),FFT方法是O(N²logN)。当K≥7时,FFT开始有优势。
以3x3卷积为例,直接计算其实很快,因为K²=9,乘加次数不多。但如果是7x7卷积,K²=49,FFT的优势就出来了。在ResNet的某些变体里,7x7卷积用在第一层,这个位置的FFT优化收益很明显。
实现上,核心是把卷积核也做FFT,然后在频域点乘:
def fft_conv2d(input, weight, padding=0): """ input: [B, C_in, H, W] weight: [C_out, C_in, K, K] """ B, C_in, H, W = input.shape C_out, _, K, _ = weight.shape # 计算FFT尺寸,padding到2的幂次 fft_h = 1 << (H + K - 1 - 1).bit_length() fft_w = 1 << (W + K - 1 - 1).bit_length() # 对输入做FFT input_freq = torch.fft.rfft2(input, s=(fft_h, fft_w)) # 对卷积核做FFT,并padding到相同尺寸 weight_pad = torch.nn.functional.pad(weight, (0, fft_w-K, 0, fft_h-K)) weight_freq = torch.fft.rfft2(weight_pad, s=(fft_h, fft_w)) # 频域点乘 output_freq = input_freq.unsqueeze(1) * weight_freq.unsqueeze(0) # 逆变换 output = torch.fft.irfft2(output_freq, s=(fft_h, fft_w)) # 裁剪到有效区域 output = output[:, :, :H+K-1, :W+K-1] return output这个实现里,关键点是FFT尺寸的选择。必须padding到H+K-1和W+K-1以上,且最好是2的幂次。如果H=224,K=7,那么fft_h至少是230,取256。这样FFT的效率最高。
5.2 小核卷积为什么不适合FFT
小核卷积(1x1、3x3)直接计算已经很快了。以3x3卷积为例,每个输出点需要9次乘加,而FFT需要做正变换、点乘、逆变换,变换本身的开销就超过了直接计算。
我实测过,在A100上对[1, 64, 224, 224]的输入做3x3卷积,直接计算耗时约0.3ms,FFT方法耗时约1.2ms。FFT慢了4倍。所以小核卷积不要用FFT。
但有一个例外:如果卷积核是可分离的(比如高斯核),可以用两次1D FFT来代替2D FFT,开销会小很多。不过这种情况在CNN里不常见。
5.3 混合策略:按层选择最优算法
在实际的CNN推理优化中,我的策略是:第一层用FFT(通常是大核),中间层用直接卷积(小核),最后几层用直接卷积。这个策略在ResNet-50上实测,端到端加速约1.15倍。虽然不多,但这是在不损失精度的情况下拿到的。
具体实现上,可以写一个自动选择算法的wrapper:
def conv2d_auto(input, weight, padding=0): K = weight.shape[-1] if K >= 7: return fft_conv2d(input, weight, padding) else: return torch.nn.functional.conv2d(input, weight, padding=padding)这个wrapper的逻辑很简单,但效果不错。K=7是一个经验阈值,具体值可以根据硬件调整。
注意:FFT卷积的显存占用比直接卷积大,因为要存储频域数据。如果显存紧张,可以只对部分通道做FFT,其余通道用直接卷积。
6. GEMM与FFT的边界:什么时候不该用
6.1 GEMM的优化现状
GEMM是深度学习里优化得最彻底的算子。cuBLAS、cutlass这些库已经把GEMM的性能压榨到了硬件极限。在A100上,FP16 GEMM的峰值性能可以达到300 TFLOPS以上,接近理论峰值。
FFT在GEMM上没有优势,因为GEMM本身不是卷积形式。虽然可以用FFT做矩阵乘法的近似(比如用频域低秩分解),但精度损失大,且速度不一定比cutlass快。
我试过用FFT做低秩GEMM近似,在特定shape下(M=N=4096,K=64),FFT方法比cutlass慢20%左右。原因是FFT的变换开销和复数运算的开销超过了低秩分解带来的计算量减少。
6.2 FFT适用的GEMM变体
有一种情况FFT可能有优势:当GEMM的其中一个矩阵是Toeplitz矩阵或循环矩阵时。这种矩阵的乘法等价于卷积,可以用FFT加速。在CNN的全连接层或者某些结构化矩阵乘法中会遇到这种情况。
但这种情况比较少见,大多数GEMM还是用cutlass最划算。我的建议是:不要为了用FFT而用FFT,先profile,确认瓶颈在卷积类算子上再考虑FFT。
6.3 算子融合中的FFT角色
在算子融合的语境下,FFT可以作为一个融合节点。比如Conv+BN+ReLU的融合,如果Conv用FFT实现,那么BN可以在频域做(BN是逐通道的缩放和平移,在频域对应滤波器的缩放),ReLU可以在时域做。
这种融合可以减少kernel launch次数和显存读写。我实测过FFT Conv+BN的融合,比分开做快约15%。但实现复杂度高,需要仔细处理频域和时域的边界。
7. 常见问题与排查技巧实录
7.1 FFT精度问题的排查
FFT是数值计算,精度问题很常见。最常见的现象是:输出里有NaN或者Inf。这通常是因为输入数据里有极值,FFT变换后频域数值溢出。
排查方法是:先检查输入数据的范围,如果max(abs(x))很大(比如>1e4),先做归一化再FFT。另外,用float32做FFT比float16稳定很多,如果精度要求高,不要用half。
还有一个隐蔽的问题是:cuFFT在某些尺寸下会有精度损失。比如非2的幂次尺寸,cuFFT会用Bluestein算法,精度比直接DFT差。如果发现精度异常,先检查FFT尺寸是否是2的幂次。
7.2 性能不达预期的常见原因
FFT优化后性能没提升,通常有这几个原因:
第一,数据没padding到2的幂次。cuFFT对2的幂次有专门优化,非2的幂次会慢很多。检查方法很简单:打印FFT尺寸,确认是2的幂次。
第二,host-device数据传输开销。如果数据在CPU上,每次FFT都要传到GPU,开销很大。确保数据一直在GPU上。
第三,batch size太小。FFT的并行度不够,GPU利用率低。如果batch size<8,考虑合并多个小batch一起做FFT。
第四,显存带宽瓶颈。FFT是访存密集型操作,如果显存带宽不够,性能上不去。用nsight compute看显存带宽利用率,如果>80%,说明是带宽瓶颈。
7.3 问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 输出NaN/Inf | 输入数值范围过大 | 先归一化再FFT |
| 精度损失大 | 用了half精度 | 改用float32 |
| 速度没提升 | FFT尺寸非2的幂次 | padding到2的幂次 |
| 速度没提升 | batch size太小 | 合并batch或增大batch |
| 显存溢出 | 频域数据占用大 | 减小batch或分块处理 |
| 结果不对 | rfft/irfft尺寸不匹配 | 检查n参数 |
| 结果不对 | 复数共轭没处理 | 检查conj()调用 |
这个表是我踩坑后总结的,覆盖了90%以上的常见问题。遇到问题时先查表,能省很多时间。
7.4 独家避坑技巧
第一个技巧:用torch.fft而不是numpy.fft。torch.fft在GPU上运行,避免了数据传输。而且torch.fft支持自动微分,方便调试。
第二个技巧:FFT之前先做detach()。如果不需要梯度,detach可以省显存和计算。如果需要梯度,注意FFT的反向传播也有开销。
第三个技巧:用torch.fft.rfft而不是fft.fft。rfft利用实信号的对称性,计算量和显存都减半。对于实数输入,永远用rfft。
第四个技巧:如果FFT尺寸很大,考虑用分块FFT。把大FFT拆成多个小FFT,可以减少显存占用,但会增加一些计算量。这个trade-off在显存紧张时很有用。
第五个技巧:用torch.backends.cuda.matmul.allow_tf32 = True开启TF32。TF32在FFT里也能用,精度比FP16好,速度比FP32快。在A100上,TF32 FFT比FP32 FFT快约1.5倍。
8. 实际项目中的组合优化案例
8.1 一个长序列模型的端到端优化
我做过一个长序列语言模型的推理优化,序列长度8192,模型有12层,每层有self-attention和FFN。优化前的端到端延迟是120ms,优化后降到45ms,加速2.67倍。
优化点分布:
- Attention的FFT近似:贡献约40%的加速
- Softmax的频域滤波:贡献约15%的加速
- FFN里大核卷积的FFT:贡献约10%的加速
- 其他(算子融合、显存优化):贡献约35%的加速
这个案例说明,FFT优化不是孤立的,需要和其他优化手段配合。单独用FFT,加速比可能只有1.5倍左右,但组合起来效果就很明显。
8.2 优化效果的度量方法
度量FFT优化的效果,不能只看端到端延迟。还要看:
第一,精度指标。对于语言模型,看perplexity;对于分类模型,看accuracy。精度损失超过阈值就不能用。
第二,显存占用。FFT会增加显存占用,如果显存溢出,优化就没意义。
第三,数值稳定性。FFT的数值误差会累积,长时间运行可能出问题。要做压力测试。
我通常用这三个指标一起评估,只有都达标才认为优化成功。
8.3 什么时候该放弃FFT优化
不是所有场景都适合FFT。如果遇到以下情况,建议放弃:
第一,序列长度短(<1024)。FFT的变换开销占主导,加速比<1。
第二,精度要求极高。FFT是近似方法,如果任务对精度敏感(比如医疗影像诊断),不要用。
第三,硬件不支持。有些边缘设备没有FFT加速库,用CPU做FFT很慢。
第四,开发时间紧张。FFT优化的调试成本高,如果项目deadline紧,用成熟的库更稳妥。
这些经验是我在实际项目中积累的,希望能帮你少走弯路。FFT是一个强大的工具,但不是银弹。用对了地方,收益很大;用错了地方,浪费时间。