4. SM4 性能瓶颈分析:软件实现中的计算密集点与内存访问模式

做 SM4 性能调优,第一步不是动手改代码,而是先搞清楚——时间到底耗在哪儿了。我个人习惯是拿到一个加解密实现,先做一轮 perf 采样,看看热点在哪。结果十有八九集中在三个地方:S 盒查表、轮函数循环、内存访问模式。这三块就像木桶的三块板,哪块短了,吞吐量就被卡在哪。

你可能会问:SM4 算法本身不复杂啊,为什么跑不快?其实原因就在于——算法简单不等于实现高效。真正拖后腿的,往往是那些看起来不起眼的细节。下面我一个一个拆开聊。

4.1 S 盒查表——被低估的“隐形开销”

SM4 的 S 盒是一个 8×8 的置换表,输入 8 位,输出 8 位。说白了就是 256 个字节的查找表。每次查表就是一次内存读取,听起来没啥对吧?

但你算一下账:SM4 每轮要做 4 次 S 盒查表,32 轮就是 128 次。一次查表在 L1 缓存命中时只要 1–2 个周期,但一旦 miss,代价直接飙到 50–100 个周期。高速缓存可不认你是谁,它只认局部性。

关键数据:

  • SM4 单次加解密:128 次 S 盒查表
  • L1 命中延迟:≈ 1–2 周期
  • L2 miss 延迟:≈ 30–50 周期
  • 内存访问延迟:≈ 100+ 周期

我在项目中遇到过一种情况:某平台把 S 盒放在了一个跨页缓存行的位置,结果每次查表都触发缓存行分裂,吞吐直接腰斩。查了好久才发现,就是一个字节对齐的问题。嗯,这种坑踩过一次就不会忘了。

小技巧:把 S 盒声明为 static const uint8_t __attribute__((aligned(64))),强制 64 字节对齐,保证它不会被拆到两个缓存行里。

4.2 轮函数循环——计算热区中的热区

SM4 的轮函数包含异或、S 盒查表、循环左移,这些操作单看都不重。但 32 轮迭代滚下来,累积效应非常明显。

每轮循环体里,数据依赖链是这样的:

// SM4 轮函数核心(单轮)
uint32_t sm4_round(uint32_t x0, uint32_t x1, uint32_t x2, uint32_t x3, uint32_t rk) {
    uint32_t t = x1 ^ x2 ^ x3 ^ rk;
    // 4 次 S 盒查表 + 1 次循环左移
    t = (SBOX[t & 0xFF]) ^
        (SBOX[(t >> 8) & 0xFF] << 8) ^
        (SBOX[(t >> 16) & 0xFF] << 16) ^
        (SBOX[(t >> 24) & 0xFF] << 24);
    t = t ^ (t << 2) ^ (t << 10) ^
        (t >> 6) ^ (t >> 18);
    return x0 ^ t;
}

单看这段代码,指令数不多。但问题在于:每次循环的结果都依赖上一轮的输出,流水线没办法乱序执行。CPU 的指令级并行在这里基本使不出力。

我建议你在做吞吐测试时,重点关注 cycles-per-byte 这个指标。如果 SM4 的 CPB 超过 8–10,大概率是轮函数循环没优化好。可以考虑循环展开 2–4 路,让编译器有更多调度空间。

避坑:我曾经试过把 32 轮全展开,结果代码体积暴增,指令缓存压力变大,反而更慢了。后来我控制在 4 路展开,效果最好。不是展开越多越好,要平衡。

4.3 内存访问模式——吞吐量的“隐形天花板”

这部分容易被忽视,但恰恰是决定吞吐上限的关键。SM4 的密钥调度和加解密过程涉及多个数组:S 盒、轮密钥数组、加解密状态数组。这些数据在内存中怎么布局,CPU 怎么访问,直接影响缓存命中率。

我总结一下常见的几种内存访问模式及其影响:

访问模式 典型场景 缓存友好性 对吞吐的影响
顺序访问 读取轮密钥数组 ★★★★ 很好 几乎无影响
随机访问 S 盒查表(输入随机时) ★★ 较差 缓存 miss 频发,吞吐下降 30–50%
跨缓存行访问 S 盒未对齐到 64B ★ 很差 每次查表可能触发 2 次缓存访问
大跨步访问 状态数组间隔较大 ★★ 较差 TLB 缺失增加,TLB miss 代价 ≈ 100+ 周期

我记得有一次在 ARM 平台上调 SM4,发现同样的代码在 x86 上跑 3 GB/s,在 ARM 上只有 1.2 GB/s。一开始以为是 CPU 主频差异,后来一查——ARM 的 L1 缓存只有 32 KB,S 盒虽然只有 256 字节,但周围的数据把缓存行污染了,导致查表频繁 miss。把 S 盒单独放到一个 cacheline 里,并做了预取,吞吐直接翻倍。

经验:做 SM4 优化时,先用 perf stat -e cache-misses,cache-references 跑一轮,看看缓存 miss 率。如果超过 5%,先搞定内存访问模式,再去折腾循环展开。

4.4 性能瓶颈全景图

下面这张图把这三大瓶颈的关系画出来了。你看,根节点是 SM4 加解密流程,三个瓶颈分支出去,每个分支又带出具体的底层问题。三者不是孤立的:S 盒查表 miss 会加重轮函数循环的延迟,内存访问模式又决定了 miss 发生的频率。优化得从整体着眼。

SM4 软件实现性能瓶颈全景图 SM4 加解密流程 S 盒查表 8×8 置换表 每轮 4 次查表 缓存未命中 轮函数循环 32 轮迭代 异或 + 移位 流水线停顿 内存访问模式 数组访问模式 数据局部性 TLB 缺失 三大瓶颈相互关联:S 盒查表触发缓存未命中,轮函数循环放大访存次数, 内存访问模式决定了整体吞吐量的上限。优化需从这三方面协同入手。

4.5 各瓶颈量化对比

下面这张表,我结合项目实测数据,把三个瓶颈的典型影响范围列出来。你可以对照自己的测试结果,快速定位瓶颈在哪。

瓶颈类别 典型延迟占比 优化潜力 建议优先级
S 盒查表(缓存 miss) 40% – 55% 高(对齐 + 预取) 第一优先
轮函数循环(数据依赖) 25% – 35% 中(循环展开 + SIMD) 第二优先
内存访问模式(TLB/跨行) 15% – 25% 高(内存布局 + 大页) 第三优先

注意:这三块不是独立优化的。你修好了 S 盒对齐,轮函数循环的压力会相对变大;你优化了循环,内存访问模式可能成为新瓶颈。建议每改一轮,做一次 perf 采样,别凭感觉猜。

做 SM4 吞吐调优,说白了就是跟 CPU 的缓存系统打交道。S 盒查表、轮函数循环、内存访问模式——这三板斧砍下去,大部分性能问题都能解决。剩下的,就是细节上的打磨了。