第1章:数据流与内存层次优化:从寄存器到HBM

大家好,我是你们的AI芯片架构课讲师。今天咱们聊一个特别实在的话题——数据搬移的代价。

说实话,我做了这么多年AI芯片,见过太多团队在算力上堆得飞起,结果性能就是上不去。为什么?说白了,数据没喂饱计算单元。你想想看,一个MAC阵列再强,如果数据搬不过来,那就是在空转。

1.1 存储层次:从寄存器到HBM的距离

先看一张图,这是我个人习惯画的存储层次结构:

AI芯片存储层次与数据搬移代价 寄存器 (Register) 容量: 几十KB | 延迟: ~1 cycle ~10x 代价 局部存储 (Local SRAM/Scratchpad) 容量: 几百KB~几MB | 延迟: ~10 cycles ~100x 代价 共享缓存 (Shared Cache / LLC) 容量: 几MB~几十MB | 延迟: ~100 cycles ~1000x 代价 HBM / DDR (片外存储) 容量: 几GB~几十GB | 延迟: ~1000 cycles 能量代价对比: 寄存器访问: 1x 局部SRAM: 10x 共享缓存: 100x HBM访问: 1000x+

这张图我每次讲课都会放。为什么?因为数据搬移的代价是指数级增长的。从寄存器到HBM,延迟差了三个数量级。我在项目中遇到过,有人把权重放在HBM里,每次计算都去读,结果功耗直接爆表。

1.2 数据搬移的真实代价

咱们算笔账。一个典型的卷积层,假设输入是224x224x3,权重是3x3x3x64。如果每次计算都从HBM读数据:

  • 输入特征图:224*224*3 ≈ 150KB
  • 权重:3*3*3*64 ≈ 1.7KB
  • 输出特征图:224*224*64 ≈ 3.1MB

你想想看,3.1MB的数据从HBM搬到计算单元,一次就要几千个cycle。一个ResNet-50有上百层,这得搬多少次?

核心观点:AI芯片的性能瓶颈,80%在数据搬移,20%在计算。别把精力都花在堆算力上。

1.3 数据复用策略:三种固定模式

怎么解决?说白了就是让数据在片上多待一会儿。我总结了三种经典的数据复用模式:

1.3.1 权重固定(Weight Stationary)

这是我最常用的策略。把权重加载到局部存储里,然后反复使用。比如一个3x3的卷积核,权重只有9个数,加载一次后可以跟整个输入特征图做卷积。

// 权重固定伪代码
for (int oc = 0; oc < output_channels; oc++) {
    // 只加载一次权重
    load_weight_to_local(weight[oc], local_weight_buf);
    
    for (int oh = 0; oh < output_height; oh++) {
        for (int ow = 0; ow < output_width; ow++) {
            // 从局部存储读权重,从HBM读输入
            int sum = 0;
            for (int kh = 0; kh < kernel_h; kh++) {
                for (int kw = 0; kw < kernel_w; kw++) {
                    sum += local_weight_buf[kh][kw] * 
                           input[oh+kh][ow+kw];
                }
            }
            output[oc][oh][ow] = sum;
        }
    }
}

这样做的好处是权重只搬一次,后面都在局部存储里。我在做第一代NPU时,就是用这个策略把带宽需求降了10倍。

1.3.2 输入固定(Input Stationary)

反过来,把输入特征图固定在片上。适合那种输入通道数少、输出通道数多的场景。比如depthwise卷积,输入就一个通道,但输出通道很多。

我的经验:输入固定模式在MobileNet这类轻量级网络上特别好用。我曾经在一个项目里,用输入固定把DDR带宽占用从80%降到了30%。

1.3.3 输出固定(Output Stationary)

把部分和(partial sum)留在片上累加。这个在矩阵乘法里特别常见。比如做矩阵乘法C = A * B,每次算出一部分结果,先存在局部存储里,等所有部分和算完再写回HBM。

// 输出固定伪代码
for (int i = 0; i < M; i += tile_m) {
    for (int j = 0; j < N; j += tile_n) {
        // 初始化局部输出缓冲区
        clear_local_output(local_out_buf);
        
        for (int k = 0; k < K; k += tile_k) {
            // 加载A和B的分块
            load_tile(A, i, k, local_a_buf);
            load_tile(B, k, j, local_b_buf);
            
            // 在局部累加
            matmul_tile(local_a_buf, local_b_buf, local_out_buf);
        }
        
        // 只写回一次最终结果
        store_output(local_out_buf, C, i, j);
    }
}

注意:输出固定模式对局部存储容量要求高。我曾经踩过坑,局部存储不够大,结果部分和溢出,不得不频繁写回HBM,性能反而更差。

1.4 如何选择复用策略?

没有银弹。我一般根据这几个维度来选:

策略 适用场景 局部存储需求 带宽节省效果
权重固定 卷积层、全连接层 中等(存权重) ★★★★★
输入固定 Depthwise卷积、逐点卷积 大(存输入特征图) ★★★★
输出固定 矩阵乘法、大卷积核 中等(存部分和) ★★★★

1.5 实战中的避坑指南

我做了这么多年,踩过的坑不少。分享几个:

  • 别盲目追求复用:我曾经在一个项目里,为了最大化数据复用,把局部存储塞得满满当当。结果调度器复杂度爆炸,控制逻辑的功耗比数据搬移还高。
  • 注意数据生命周期:权重固定模式下,权重在局部存储里待多久?如果网络层数太多,权重可能被覆盖,那就白费功夫了。
  • 混合策略才是王道:我现在的做法是,不同层用不同策略。比如前几层用权重固定,中间层用输出固定,最后几层用输入固定。灵活搭配,效果最好。

一句话总结:数据搬移是AI芯片的第一性原理。理解存储层次,用好数据复用,你的芯片性能至少能翻一倍。

好了,这一章就到这里。记住,别光看理论,回去看看你的设计里,数据搬移占了多大比例。动手优化一下,效果立竿见影。

可可精品资料库