存储系统是NPU性能的关键瓶颈之一。本章深入探讨NPU片上存储系统的架构与设计要点,包括SRAM设计、Memory Banking策略、数据预取机制、缓存一致性、DMA设计以及内存压缩技术。
在现代NPU设计中,有一个残酷的事实:数据搬运的能耗是计算的10-100倍。从外部DRAM读取一个32位数据需要约640pJ的能量,而执行一次32位乘法仅需3.1pJ(在45nm工艺下)。这意味着,如果我们不能有效地管理数据移动,即使拥有世界上最快的计算单元也毫无意义。这就是为什么顶级NPU设计团队会花费超过50%的精力在存储系统优化上。
本章将揭示NPU存储系统设计的艺术与科学。我们将从片上SRAM的物理设计开始,探讨如何通过巧妙的Banking策略实现高带宽访问,如何设计智能的预取机制来隐藏访存延迟,以及如何通过压缩技术在有限的片上存储中塞入更多数据。更重要的是,我们将学习如何为脉动阵列这样的规则计算模式设计最优的存储层次结构。通过本章的学习,你将掌握设计高效NPU存储系统的核心技术,理解为什么Google TPU要配备如此巨大的片上存储,以及为什么NVIDIA在每一代GPU中都在不断增加缓存容量。
片上SRAM是NPU存储层次结构的核心,为计算单元提供超低延迟、超高带宽的数据访问。
在NPU大规模采用Scratchpad内存之前,CPU领域已经有了丰富的探索历史:
CPU Scratchpad的历史演进
- IBM Cell BE (2005): PlayStation 3的处理器,每个SPE(协处理器)配备256KB的本地存储(Local Store),完全由软件管理。这是最早的大规模商用Scratchpad设计。
- TI DSP系列: 德州仪器的DSP从C6000系列开始就采用了L1P(程序)和L1D(数据)分离的Scratchpad设计,专门用于信号处理的确定性延迟。
- ARM TCM (Tightly Coupled Memory): ARM Cortex-R系列处理器的标配,提供单周期访问延迟,广泛用于汽车电子和实时控制。
- Intel Xeon Phi: 每个核心配备512KB的本地内存,可配置为缓存或Scratchpad模式。
NPU从这些先驱中学到的关键经验:
SRAM设计就像建造一个高效的仓库系统——容量、速度、成本之间存在着根本性的权衡。理解这些权衡是设计高效存储系统的基础。
SRAM设计的关键权衡:
1. 容量 vs. 面积/功耗
- SRAM面积密度:~0.2 MB/mm² (7nm工艺)
- 静态功耗:~1mW/MB
- 动态功耗:与访问频率成正比
2. 端口设计
- 单端口:面积最小,但限制并行访问
- 真双端口(1R1W):面积增加~70%,支持一个读操作和一个写操作同时进行
- 多端口(nRmW):面积随端口数超线性增长
3. 访问延迟
- 容量增大 → 延迟增加(解码器、字线、位线延迟)
- 典型延迟:32KB ~1 cycle, 256KB ~2-3 cycles
1. 为什么SRAM这么”贵”?
一个SRAM单元需要6个晶体管(6T),而DRAM只需要1个晶体管+1个电容(1T1C)。这意味着:
2. 多端口的代价
每增加一个端口,需要:
设计陷阱:盲目追求多端口
很多设计师认为端口越多越好,但实际上:
- 4端口SRAM的面积是单端口的3-4倍
- 大多数访问模式并不需要真正的多端口
- 通过Banking和时分复用,往往能达到类似效果
1. 近阈值电压(Near-Threshold)SRAM: 通过降低工作电压接近晶体管阈值电压,可以大幅降低功耗(减少50-70%),但代价是速度降低和稳定性挑战。适用于边缘AI设备。
2. 基于MRAM/ReRAM的”类SRAM”: 新型非易失性存储器正在模糊SRAM和存储的界限。例如,台积电的22nm eMRAM已经可以提供接近SRAM的速度,但密度提升3-4倍。
3. 3D SRAM: 将SRAM堆叠在逻辑电路上方,通过TSV(硅通孔)连接。这种技术已经在某些高端GPU中使用,可以将存储密度提升2-3倍。
典型的三级存储层次设计:
L0寄存器文件的关键设计要点:
L0寄存器文件的Chisel实现特点:
RegNext实现流水线寄存器,自动处理时序Mux选择器实现旁路逻辑,简洁高效VecInit创建寄存器数组,支持参数化深度L1集群缓存设计要点:
转置SRAM的设计动机与实现:
转置SRAM是NPU中的特殊存储结构,专门优化矩阵转置操作:
mem[ROWS][COLS]row_mode信号切换访问模式generate块并行处理所有列的读写Memory Banking通过将SRAM划分为多个独立的Bank,实现并行访问,成倍提升有效带宽。
Banking的本质是”分而治之”——将一个大的存储器拆分成多个可以独立访问的小存储器。这就像将一个大超市拆分成多个专柜,顾客可以同时在不同专柜购物,而不会相互阻塞。
理想情况下,N个Bank可以提供N倍的带宽。但现实中,由于访问模式的不均匀性,会产生Bank冲突。关键是找到最优的Bank数量和地址映射策略。
Bank数量选择的黄金法则
- 质数法则: Bank数量选择质数(如7、11、13),可以减少规则访问模式的冲突
- 2的幂次: 便于硬件实现(简单的位操作),但容易产生冲突
- 混合策略: 2的幂次×质数(如8×3=24),平衡实现复杂度和冲突率
Bank冲突的主要场景:
1. 卷积中的步长访问
- 3×3卷积,stride=2时的访问模式
- Bank数量需要考虑GCD(stride, bank_num)
2. 矩阵转置访问
- 行访问:连续地址
- 列访问:地址间隔为矩阵宽度
3. 稀疏访问模式
- 不规则的访问地址
- 需要动态仲裁机制
让我们分析一个3×3卷积在8-Bank系统中的访问模式:
假设输入特征图宽度W=64,使用简单的模8映射:
Bank_ID = Address % 8
第一行访问地址:[0, 1, 2] → Banks: [0, 1, 2] ✓ 无冲突
第二行访问地址:[64, 65, 66] → Banks: [0, 1, 2] ✗ 全部冲突!
第三行访问地址:[128, 129, 130] → Banks: [0, 1, 2] ✗ 全部冲突!
问题:所有访问都集中在前3个Bank,其他5个Bank完全空闲!
解决方案:XOR-Based Banking
// 改进的地址映射
Bank_ID = (Address[2:0]) XOR (Address[8:6])
这种映射将高位地址位混入Bank选择,打散了规则的访问模式
实战经验:Banking不是越多越好
- Bank数量翻倍,仲裁器复杂度增加4倍
- 过多的Bank会导致每个Bank容量太小,增加miss率
- 典型的sweet spot:8-16个Bank
// 优化的多Bank SRAM控制器 - SystemVerilog版本(带流水线和仲裁)
module MultiBank_SRAM #(
parameter NUM_BANKS = 8,
parameter BANK_SIZE = 8192, // 每个Bank 8KB
parameter DATA_WIDTH = 256,
parameter ADDR_WIDTH = 16
)(
input wire clk,
input wire rst_n,
// 请求接口(支持多个并行请求)
input wire [3:0] req_valid,
input wire [ADDR_WIDTH-1:0] req_addr [3:0],
input wire [3:0] req_wr,
input wire [DATA_WIDTH-1:0] req_wdata [3:0],
output reg [3:0] req_ready,
output reg [DATA_WIDTH-1:0] resp_data [3:0],
output reg [3:0] resp_valid
);
// 第一级流水线:地址解码
reg [3:0] req_valid_r1;
reg [2:0] bank_id_r1 [3:0];
reg [12:0] bank_addr_r1 [3:0];
reg [3:0] req_wr_r1;
reg [DATA_WIDTH-1:0] req_wdata_r1 [3:0];
reg [3:0] req_id_r1; // 请求者ID
// 地址解码逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
req_valid_r1 <= 0;
for (int i = 0; i < 4; i++) begin
bank_id_r1[i] <= 0;
bank_addr_r1[i] <= 0;
req_wr_r1[i] <= 0;
req_wdata_r1[i] <= 0;
req_id_r1[i] <= i;
end
end else begin
req_valid_r1 <= req_valid;
for (int i = 0; i < 4; i++) begin
// 交织映射:低位作为bank索引
bank_id_r1[i] <= req_addr[i][2:0];
bank_addr_r1[i] <= req_addr[i][ADDR_WIDTH-1:3];
req_wr_r1[i] <= req_wr[i];
req_wdata_r1[i] <= req_wdata[i];
end
end
end
// 第二级流水线:Bank仲裁
reg [3:0] bank_grant_r2 [NUM_BANKS-1:0];
reg [3:0] grant_id_r2 [NUM_BANKS-1:0]; // 被授权的请求者ID
// 改进的仲裁逻辑(轮询优先级)
reg [1:0] priority_ptr [NUM_BANKS-1:0]; // 每个Bank的优先级指针
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int j = 0; j < NUM_BANKS; j++) begin
bank_grant_r2[j] <= 0;
grant_id_r2[j] <= 0;
priority_ptr[j] <= 0;
end
end else begin
// 对每个Bank进行仲裁
for (int j = 0; j < NUM_BANKS; j++) begin
bank_grant_r2[j] <= 0;
// 从优先级指针开始轮询
for (int k = 0; k < 4; k++) begin
int req_idx = (priority_ptr[j] + k) % 4;
if (req_valid_r1[req_idx] && bank_id_r1[req_idx] == j && |bank_grant_r2[j] == 0) begin
bank_grant_r2[j][req_idx] <= 1;
grant_id_r2[j] <= req_idx;
priority_ptr[j] <= (req_idx + 1) % 4; // 更新优先级
end
end
end
end
end
// Bank SRAM实例和第三级流水线
wire [DATA_WIDTH-1:0] bank_rdata [NUM_BANKS-1:0];
reg [3:0] resp_valid_r3;
reg [3:0] resp_id_r3 [NUM_BANKS-1:0];
genvar i;
generate
for (i = 0; i < NUM_BANKS; i = i + 1) begin : bank_gen
// 选择授权的请求
wire bank_en = |bank_grant_r2[i];
wire [3:0] grant_onehot = bank_grant_r2[i];
wire [1:0] grant_idx = grant_id_r2[i];
// Mux选择授权请求的信号
wire bank_wr = req_wr_r1[grant_idx] & bank_en;
wire [12:0] bank_addr = bank_addr_r1[grant_idx];
wire [DATA_WIDTH-1:0] bank_wdata = req_wdata_r1[grant_idx];
// Bank SRAM实例
BankSRAM #(
.SIZE(BANK_SIZE),
.WIDTH(DATA_WIDTH)
) bank_inst (
.clk(clk),
.en(bank_en),
.wr(bank_wr),
.addr(bank_addr),
.wdata(bank_wdata),
.rdata(bank_rdata[i])
);
// 响应ID寄存
always @(posedge clk) begin
resp_id_r3[i] <= grant_id_r2[i];
end
end
endgenerate
// 第四级流水线:响应汇集
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
resp_valid <= 0;
for (int i = 0; i < 4; i++)
resp_data[i] <= 0;
end else begin
resp_valid <= 0;
// 将Bank响应路由回请求者
for (int j = 0; j < NUM_BANKS; j++) begin
if (|bank_grant_r2[j]) begin
int resp_idx = resp_id_r3[j];
resp_data[resp_idx] <= bank_rdata[j];
resp_valid[resp_idx] <= 1;
end
end
end
end
// Ready信号(考虑仲裁结果)
always @(*) begin
req_ready = 4'b1111; // 默认都ready,实际使用时可根据Bank忙碌状态调整
end
endmodule
// Bank SRAM模块
module BankSRAM #(
parameter SIZE = 8192,
parameter WIDTH = 256,
parameter ADDR_WIDTH = 13
)(
input wire clk,
input wire en,
input wire wr,
input wire [ADDR_WIDTH-1:0] addr,
input wire [WIDTH-1:0] wdata,
output reg [WIDTH-1:0] rdata
);
reg [WIDTH-1:0] mem [0:SIZE-1];
always @(posedge clk) begin
if (en) begin
if (wr)
mem[addr] <= wdata;
else
rdata <= mem[addr];
end
end
endmodule
Chisel版本的多Bank SRAM控制器:
import chisel3._
import chisel3.util._
class MultiBankSRAM(numBanks: Int = 8, bankSize: Int = 8192,
dataWidth: Int = 256, numPorts: Int = 4) extends Module {
val addrWidth = log2Ceil(numBanks * bankSize)
val bankAddrWidth = log2Ceil(bankSize)
val io = IO(new Bundle {
val req = Vec(numPorts, new Bundle {
val valid = Input(Bool())
val addr = Input(UInt(addrWidth.W))
val wr = Input(Bool())
val wdata = Input(UInt(dataWidth.W))
val ready = Output(Bool())
})
val resp = Vec(numPorts, new Bundle {
val data = Output(UInt(dataWidth.W))
val valid = Output(Bool())
})
})
// 第一级流水线:地址解码
val reqValidR1 = RegNext(VecInit(io.req.map(_.valid)))
val bankIdR1 = io.req.map(r => RegNext(r.addr(log2Ceil(numBanks)-1, 0)))
val bankAddrR1 = io.req.map(r => RegNext(r.addr >> log2Ceil(numBanks)))
val reqWrR1 = RegNext(VecInit(io.req.map(_.wr)))
val reqWdataR1 = RegNext(VecInit(io.req.map(_.wdata)))
// 仲裁器(每个Bank一个)
val arbiters = Seq.fill(numBanks)(Module(new RRArbiter(numPorts)))
val banks = Seq.fill(numBanks)(Module(new BankSRAM(bankSize, dataWidth)))
// 连接请求到仲裁器
for (i <- 0 until numPorts) {
for (j <- 0 until numBanks) {
arbiters(j).io.req(i).valid := reqValidR1(i) && (bankIdR1(i) === j.U)
arbiters(j).io.req(i).bits := Cat(reqWdataR1(i), bankAddrR1(i), reqWrR1(i))
}
}
// 连接仲裁器到Bank
for (j <- 0 until numBanks) {
banks(j).io.en := arbiters(j).io.chosen.valid
banks(j).io.wr := arbiters(j).io.chosen.bits(0)
banks(j).io.addr := arbiters(j).io.chosen.bits(bankAddrWidth, 1)
banks(j).io.wdata := arbiters(j).io.chosen.bits >> (bankAddrWidth + 1)
}
// 响应路由
for (i <- 0 until numPorts) {
io.resp(i).valid := RegNext(arbiters.map(a => a.io.grant(i)).reduce(_ || _))
io.resp(i).data := RegNext(MuxCase(0.U,
banks.zipWithIndex.map { case (bank, j) =>
(arbiters(j).io.grant(i) -> bank.io.rdata)
}
))
io.req(i).ready := true.B // 简化:始终ready
}
}
// 轮询仲裁器
class RRArbiter(n: Int) extends Module {
val io = IO(new Bundle {
val req = Vec(n, Flipped(Valid(UInt())))
val chosen = Valid(UInt())
val grant = Vec(n, Output(Bool()))
})
val priority = RegInit(0.U(log2Ceil(n).W))
// 轮询逻辑
val reqVec = VecInit(io.req.map(_.valid))
val shiftReq = VecInit((0 until n).map(i => reqVec((i + priority) % n)))
val shiftGrant = PriorityEncoderOH(shiftReq)
// 输出
io.grant := VecInit((0 until n).map(i => shiftGrant((i - priority + n) % n)))
io.chosen.valid := reqVec.reduce(_ || _)
io.chosen.bits := Mux1H(io.grant, io.req.map(_.bits))
// 更新优先级
when(io.chosen.valid) {
priority := (priority + PriorityEncoder(io.grant) + 1.U) % n.U
}
}
// 专用于卷积的Bank映射
module ConvBankMapping #(
parameter BANK_BITS = 3, // 8个Bank
parameter CHANNEL_BITS = 6 // 64个通道
)(
input wire [15:0] h_idx, // Height坐标
input wire [15:0] w_idx, // Width坐标
input wire [CHANNEL_BITS-1:0] c_idx, // Channel坐标
output wire [BANK_BITS-1:0] bank_id,
output wire [15:0] bank_offset
);
// 斜对角映射,避免3×3卷积的Bank冲突
wire [BANK_BITS-1:0] skew;
assign skew = (h_idx + w_idx) & ((1 << BANK_BITS) - 1);
assign bank_id = (c_idx[BANK_BITS-1:0] + skew) & ((1 << BANK_BITS) - 1);
// Bank内偏移地址
assign bank_offset = {c_idx[CHANNEL_BITS-1:BANK_BITS], h_idx[7:0], w_idx[7:0]};
endmodule
Bank冲突解决的硬件实现:
带冲突缓冲的Bank访问调度器是处理多请求者竞争的关键组件:
数据预取通过提前将数据从DRAM加载到片上SRAM,隐藏内存访问延迟,是提升NPU性能的关键技术。
预取的艺术在于”料敌于先”——在计算单元需要数据之前,就已经将数据准备好。这就像一个优秀的助手,总能在老板需要文件之前就放在桌上。
挑战与解决方案
- 挑战1:预取太早 → 数据被驱逐出缓存 → 解决:基于计算进度的动态预取
- 挑战2:预取太晚 → 无法隐藏延迟 → 解决:多级预取队列
- 挑战3:预取错误数据 → 浪费带宽和功耗 → 解决:基于编译器提示的确定性预取
NPU的预取相比CPU有独特优势:
预取机制的核心要素:
1. 预取时机
- 基于计算进度的预取
- 基于地址模式的预取
- 软件控制的显式预取
2. 预取粒度
- 细粒度:单个Tile (如16×16)
- 粗粒度:整个Feature Map
- 自适应粒度:根据可用空间动态调整
3. 预取深度
- Double Buffering: 计算当前数据时预取下一批
- Triple Buffering: 更深的流水线,容忍更大延迟
智能预取引擎的设计要点:
该预取引擎采用4级状态机和自适应预取算法:
prefetch_distance = queue_count × 16双缓冲预取控制器设计:
双缓冲是NPU中最常用的隐藏访存延迟技术:
buffer_sel信号控制当前活跃缓冲区buffer_ready标志数据就绪// 缓冲区实例 wire [DATA_WIDTH-1:0] buffer_rdata [1:0];
genvar i; generate for (i = 0; i < 2; i = i + 1) begin SimpleDualPortRAM #( .DEPTH(BUFFER_SIZE/32), .WIDTH(DATA_WIDTH) ) buffer ( .clk(clk), .wr_en(dram_resp_valid && (buffer_sel != i)), .wr_addr(buffer_write_addr[i]), .wr_data(dram_resp_data), .rd_addr(compute_addr), .rd_data(buffer_rdata[i]) ); end endgenerate
// 计算接口 assign compute_data = buffer_rdata[buffer_sel]; assign compute_ready = buffer_ready[buffer_sel];
// 缓冲切换逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin buffer_sel <= 0; end else if (/* 当前buffer计算完成 && 另一个buffer预取完成 */) begin buffer_sel <= ~buffer_sel; end end endmodule ```
软件控制预取的关键设计:
wait_prefetch_complete确保数据就绪在多核NPU系统中,缓存一致性确保不同核心看到的数据是一致的,这对正确性至关重要。
NPU缓存一致性的特点:
1. 软件管理为主
- 神经网络计算流程确定
- 编译器可以精确分析数据依赖
- 显式同步点插入
2. 简化的硬件支持
- 基本的Cache刷新/失效指令
- DMA与Cache的协同
- 全局同步屏障
3. 常见场景
- 多核协同计算大矩阵乘法
- Pipeline并行中的数据传递
- 模型参数的广播更新
缓存控制单元的硬件实现:
NPU缓存控制单元主要提供软件管理的一致性支持:
软件管理一致性协议的设计要点:
owner_vector:记录数据的独占拥有者sharer_vector:记录所有共享者// 处理核心请求 always @(posedge clk) begin for (int i = 0; i < 4; i++) begin if (core_req[i]) begin reg [9:0] idx = addr_to_dir_idx(core_addr[i]);
case (core_op[i])
2'b00: begin // Read
// 添加到共享者列表
sharer_vector[idx][i] <= 1;
end
2'b01: begin // Write
// 需要独占访问
if (sharer_vector[idx] != 0 && sharer_vector[idx] != (1 << i)) begin
// 失效其他共享者
invalidate_req <= sharer_vector[idx] & ~(1 << i);
end
owner_vector[idx] <= (1 << i);
sharer_vector[idx] <= (1 << i);
end
2'b10: begin // Exclusive (for RMW)
// 获取独占权限
invalidate_req <= sharer_vector[idx] & ~(1 << i);
if (owner_vector[idx] != 0 && owner_vector[idx] != (1 << i)) begin
writeback_req <= owner_vector[idx];
end
owner_vector[idx] <= (1 << i);
sharer_vector[idx] <= (1 << i);
end
endcase
end
end end endmodule ```
DMA(Direct Memory Access)是NPU中实现高效数据传输的关键组件,它允许数据在不占用处理器的情况下在内存之间移动。
高性能DMA引擎的架构设计:
现代NPU的DMA引擎通常采用8通道并行架构:
支持复杂数据布局的DMA通道设计:
现代NPU的DMA需要支持多种数据布局传输模式:
addr = base + offsetaddr = base + y*pitch + x*widthaddr = base + z*slice_pitch + y*pitch + x*widthaddr = base + index[i]*element_size张量布局转换DMA的设计:
张量布局转换是NPU中的重要功能,支持NCHW、NHWC等不同数据格式间的高效转换:
linear_addr = base + Σ(index[d] × stride[d] × element_size)
内存压缩可以显著提高NPU的有效带宽,特别是对于稀疏或冗余的数据。
结构化稀疏权重压缩器设计:
结构化稀疏是现代NPU中常用的压缩技术,特别是2:4稀疏模式:
权重量化压缩器设计:
量化是将高精度权重转换为低位宽表示的技术:
(max-min)/(2^bits-1)round(-min/scale)quantized = round((weight - zero_point) / scale)
dequantized = quantized * scale + zero_point
支持批量处理
// 计算量化参数
scale = (max_val - min_val) >> OUT_WIDTH;
zero_point = min_val;
// 量化
for (int i = 0; i < NUM_WEIGHTS; i++) begin
reg signed [IN_WIDTH-1:0] shifted = weights_in[i] - zero_point;
weights_out[i] = shifted / scale;
end
valid_out <= 1; end else begin
valid_out <= 0; end end endmodule ```
动态激活值压缩器设计:
激活值压缩针对神经网络推理中的中间结果,采用多种压缩策略:
自适应压缩选择器设计:
自适应压缩根据数据特征动态选择最优压缩算法:
压缩算法选择策略:
权重压缩:
激活值压缩:
梯度压缩:
// 简化的自适应压缩选择器框架
module AdaptiveCompressionSelector #(
parameter DATA_WIDTH = 256,
parameter SAMPLE_SIZE = 64
)(
input wire clk,
input wire rst_n,
// 数据特征输入
input wire [1:0] data_type, // 0: Weight, 1: Activation, 2: Gradient
input wire [2:0] layer_type, // 0: Conv, 1: FC, 2: BN, 3: Attention
// 数据采样输入
input wire sample_valid,
input wire [DATA_WIDTH-1:0] sample_data,
// 压缩算法选择输出
output reg [2:0] selected_algorithm,
output reg [7:0] algorithm_params,
output reg selection_done
);
// 算法定义
localparam NONE = 0, QUANTIZE = 1, RLE = 2,
SPARSE = 3, HUFFMAN = 4, DELTA = 5;
// 统计信息
reg [31:0] zero_count;
reg [31:0] unique_values;
reg [31:0] max_run_length;
reg [31:0] value_range;
reg signed [31:0] min_value, max_value;
reg [31:0] sample_count;
// 统计收集
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
zero_count <= 0;
sample_count <= 0;
min_value <= 32'h7FFFFFFF;
max_value <= 32'h80000000;
end else if (sample_valid && sample_count < SAMPLE_SIZE) begin
sample_count <= sample_count + 1;
// 统计零值
for (int i = 0; i < DATA_WIDTH/32; i++) begin
if (sample_data[i*32 +: 32] == 0) begin
zero_count <= zero_count + 1;
end
// 更新最大最小值
signed [31:0] val = sample_data[i*32 +: 32];
if (val < min_value) min_value <= val;
if (val > max_value) max_value <= val;
end
end
end
// 算法选择逻辑
always @(posedge clk) begin
if (sample_count >= SAMPLE_SIZE && !selection_done) begin
// 计算统计指标
reg [15:0] sparsity = (zero_count * 100) / (sample_count * DATA_WIDTH/32);
value_range = max_value - min_value;
case (data_type)
2'b00: begin // 权重
case (layer_type)
3'b000: begin // Conv层权重
if (sparsity > 60) begin
selected_algorithm <= SPARSE;
algorithm_params <= 8'h24; // 2:4稀疏
end else begin
selected_algorithm <= QUANTIZE;
algorithm_params <= 8'h08; // INT8量化
end
end
3'b001: begin // FC层权重
// FC层通常稀疏性更高
if (sparsity > 70) begin
selected_algorithm <= SPARSE;
algorithm_params <= 8'h48; // 4:8稀疏
end else if (unique_values < 256) begin
selected_algorithm <= HUFFMAN;
algorithm_params <= 8'h00;
end else begin
selected_algorithm <= QUANTIZE;
algorithm_params <= 8'h04; // INT4量化
end
end
3'b011: begin // Attention层权重
// Attention通常需要更高精度
selected_algorithm <= QUANTIZE;
algorithm_params <= 8'h10; // INT16量化
end
endcase
end
2'b01: begin // 激活值
if (layer_type == 3'b000 || layer_type == 3'b001) begin
// ReLU后激活值有大量零
if (sparsity > 50) begin
selected_algorithm <= RLE;
algorithm_params <= 8'hFF; // 最大游程255
end else begin
// 动态量化
selected_algorithm <= QUANTIZE;
algorithm_params <= 8'h88; // 动态INT8
end
end else if (layer_type == 3'b010) begin // BN层
// BN后数据分布较均匀
selected_algorithm <= DELTA;
algorithm_params <= 8'h01; // 一阶差分
end
end
2'b10: begin // 梯度
// 梯度通常很小且稀疏
if (sparsity > 80) begin
selected_algorithm <= SPARSE;
algorithm_params <= 8'h11; // 1:1稀疏(只传非零)
end else if (value_range < 65536) begin
// 小范围梯度用差分编码
selected_algorithm <= DELTA;
algorithm_params <= 8'h02; // 二阶差分
end else begin
selected_algorithm <= QUANTIZE;
algorithm_params <= 8'h10; // FP16量化
end
end
endcase
selection_done <= 1;
end
end
// 压缩比预测
reg [15:0] predicted_ratio;
always @(*) begin
case (selected_algorithm)
QUANTIZE: begin
case (algorithm_params[3:0])
4'h4: predicted_ratio = 16'h0800; // 8x (INT4)
4'h8: predicted_ratio = 16'h0400; // 4x (INT8)
default: predicted_ratio = 16'h0200; // 2x
endcase
end
RLE: begin
// 基于稀疏性预测
if (sparsity > 75) predicted_ratio = 16'h0600; // 6x
else if (sparsity > 50) predicted_ratio = 16'h0300; // 3x
else predicted_ratio = 16'h0150; // 1.5x
end
SPARSE: begin
// 基于稀疏模式
case (algorithm_params)
8'h24: predicted_ratio = 16'h0200; // 2x (2:4)
8'h48: predicted_ratio = 16'h0200; // 2x (4:8)
8'h11: predicted_ratio = sparsity * 16'h0010; // 可变
endcase
end
default: predicted_ratio = 16'h0100; // 1x
endcase
end
endmodule
压缩策略总结:
| 数据类型 | 特征 | 推荐算法 | 预期压缩比 |
|---|---|---|---|
| Conv权重 | 中等稀疏性,分布集中 | INT8量化/2:4稀疏 | 2-4x |
| FC权重 | 高稀疏性,可剪枝 | 4:8稀疏/INT4量化 | 4-8x |
| 激活值(ReLU后) | 大量零值,正值分布 | RLE/动态量化 | 3-6x |
| 梯度 | 极稀疏,小值 | Top-K稀疏/差分编码 | 10-100x |
问题:设计一个存储带宽监控和优化系统,动态调整各个模块的带宽分配。
问题:为CNN推理设计一个三级存储层次(L0/L1/L2),优化数据复用。
问题:设计一个完整的NPU存储子系统,支持8×8 MAC阵列,目标是在7nm工艺下达到1TOPS@1GHz。要求: 1) 设计存储层次结构 2) 实现高效的数据搬运 3) 支持INT8/INT16混合精度 4) 功耗预算2W
NPU设计中的一个关键决策是选择Cache还是Scratchpad存储器。两者各有优势,理解其特点对优化NPU存储系统至关重要。
Cache与Scratchpad的根本区别:
1. Cache(硬件管理)
- 自动的数据加载/替换
- 透明的地址映射
- 需要标签存储和比较逻辑
- 访问延迟不确定(命中/未命中)
2. Scratchpad(软件管理)
- 显式的数据搬移(DMA)
- 直接的地址映射
- 无需标签,面积效率高
- 访问延迟固定且低
3. NPU的典型选择
- 主流NPU多采用Scratchpad
- 原因:可预测的访问模式
- 软件可精确控制数据布局
// Cache实现示例
module SimpleCache #(
parameter CACHE_SIZE = 32768, // 32KB
parameter LINE_SIZE = 64, // 64字节缓存行
parameter WAYS = 4 // 4路组相联
)(
input wire clk,
input wire rst_n,
input wire [31:0] addr,
input wire req_valid,
output reg [511:0] data_out,
output reg hit,
output reg miss
);
localparam SETS = CACHE_SIZE / (LINE_SIZE * WAYS);
localparam SET_BITS = $clog2(SETS);
localparam TAG_BITS = 32 - SET_BITS - $clog2(LINE_SIZE);
// 标签存储(开销:~10-15%容量)
reg [TAG_BITS-1:0] tags [WAYS-1:0][SETS-1:0];
reg valid [WAYS-1:0][SETS-1:0];
reg [1:0] lru [SETS-1:0]; // LRU替换
// 数据存储
reg [LINE_SIZE*8-1:0] data [WAYS-1:0][SETS-1:0];
// 地址解码
wire [TAG_BITS-1:0] tag = addr[31:32-TAG_BITS];
wire [SET_BITS-1:0] set = addr[32-TAG_BITS-1:6];
// 标签比较(关键路径)
always @(posedge clk) begin
hit <= 0;
miss <= 0;
if (req_valid) begin
for (int i = 0; i < WAYS; i++) begin
if (valid[i][set] && tags[i][set] == tag) begin
hit <= 1;
data_out <= data[i][set];
// 更新LRU
end
end
if (!hit) begin
miss <= 1;
// 触发缺失处理
end
end
end
endmodule
// Scratchpad实现示例
module Scratchpad #(
parameter SIZE = 32768, // 32KB
parameter WIDTH = 512 // 512位宽
)(
input wire clk,
input wire en,
input wire wr,
input wire [13:0] addr, // 直接地址
input wire [WIDTH-1:0] wdata,
output reg [WIDTH-1:0] rdata
);
// 简单的SRAM阵列
reg [WIDTH-1:0] mem [0:SIZE/(WIDTH/8)-1];
always @(posedge clk) begin
if (en) begin
if (wr)
mem[addr] <= wdata;
else
rdata <= mem[addr]; // 固定1周期延迟
end
end
endmodule
在NPU广泛采用Scratchpad之前,这种可编程的片上存储已经在各种CPU架构中有着丰富的应用历史。理解这些历史案例,有助于我们更好地把握NPU存储设计的演进脉络。
CPU架构中的Scratchpad应用案例
1. Cell Broadband Engine (2006) - Sony/IBM/Toshiba Cell处理器是Scratchpad在通用处理器中最著名的应用案例。每个SPE(Synergistic Processing Element)配备256KB的本地存储(Local Store),完全由软件管理:
- 架构特点:每个SPE只能访问自己的Local Store,不能直接访问主存或其他SPE的存储
- 数据传输:通过DMA引擎在Local Store和主存之间传输数据,支持双缓冲
- 编程模型:程序员需要显式管理数据布局和传输,类似今天的GPU编程
- 性能优势:确定性的访问延迟,无Cache miss,峰值性能达204.8 GFLOPS
- 应用困境:编程复杂度高,难以移植传统代码,最终限制了其广泛应用
2. TI C6000 DSP系列 德州仪器的DSP广泛使用L1/L2 Scratchpad(称为TCM - Tightly Coupled Memory):
- 分级设计:L1P/L1D各32KB,L2统一256KB,可配置为Cache或SRAM
- 灵活配置:L2可以部分配置为Cache,部分配置为Scratchpad
- DMA协处理器:EDMA3支持复杂的2D/3D传输模式,自动处理数据重排
- 实时保证:Scratchpad模式下访问延迟固定,满足硬实时系统需求
3. ARM TCM(Tightly Coupled Memory) ARM在Cortex-R和部分Cortex-M系列中提供TCM选项:
- ITCM/DTCM分离:指令TCM和数据TCM独立,避免冲突
- 零等待访问:单周期访问,比通过AXI总线访问快5-10倍
- 典型应用:中断处理程序、关键数据结构、实时控制算法
- 容量限制:通常较小(16KB-256KB),仅用于最关键的代码和数据
基于历史经验,现代NPU普遍采用混合存储架构,结合Scratchpad的确定性和Cache的灵活性:
混合存储架构设计原则
1. 主体采用Scratchpad(90%以上容量)
- 用途:存储可预测的张量数据(权重、激活值、中间结果)
- 管理:编译器静态分配,DMA预取,双缓冲或多缓冲
- 优势:零冲突、确定延迟、功耗最优、利用率可达100%
- 典型容量:256KB-4MB,取决于目标应用和工艺节点
2. 辅助小容量Cache(5-10%容量)
- 用途:处理不规则访问模式:
- 稀疏网络的索引查找
- 动态形状网络的元数据
- 激活函数的查找表
- 归一化层的统计量
- 设计:通常采用简单的直接映射或2路组相联
- 容量:8KB-64KB,够用即可,避免复杂度
3. 统一的地址空间和访问仲裁
- 地址划分:Scratchpad和Cache映射到不同地址范围
- 访问优先级:
- 计算单元访问Scratchpad:最高优先级
- DMA传输:中等优先级,可被计算抢占
- Cache访问:最低优先级,用于辅助功能
- 带宽分配:保证Scratchpad带宽,Cache使用剩余带宽
4. 编译器和运行时协同
- 静态分析:编译时识别规则和不规则访问模式
- 数据布局:将规则数据分配到Scratchpad,不规则数据通过Cache访问
- 预取调度:生成DMA指令序列,隐藏数据传输延迟
- 动态调整:运行时根据Cache命中率调整数据分配策略
实践案例:NVIDIA Tensor Core的存储层次 虽然NVIDIA GPU主要使用Cache层次,但在Tensor Core的设计中也体现了混合思想:
// 性能对比表
/*
┌─────────────────┬────────────────┬────────────────┬────────────────┐
│ 指标 │ Cache │ Scratchpad │ 混合方案 │
├─────────────────┼────────────────┼────────────────┼────────────────┤
│ 面积效率 │ 低 │ 高 │ 中 │
│ (数据/总面积) │ (~85%) │ (~95%) │ (~92%) │
├─────────────────┼────────────────┼────────────────┼────────────────┤
│ 访问延迟 │ 1-3周期 │ 1周期 │ 1周期(SP) │
│ │ (变化) │ (固定) │ 1-3周期(Cache)│
├─────────────────┼────────────────┼────────────────┼────────────────┤
│ 功耗 │ 高 │ 低 │ 中 │
│ │ (标签比较) │ (直接访问) │ │
├─────────────────┼────────────────┼────────────────┼────────────────┤
│ 编程复杂度 │ 低 │ 高 │ 中 │
│ │ (自动) │ (手动DMA) │ (混合) │
├─────────────────┼────────────────┼────────────────┼────────────────┤
│ 适用场景 │ 不规则访问 │ 规则访问 │ 通用NPU │
│ │ 通用处理器 │ 专用加速器 │ 灵活性+效率 │
└─────────────────┴────────────────┴────────────────┴────────────────┘
*/
Chisel版本的混合存储系统:
import chisel3._
import chisel3.util._
class HybridMemorySystem(
scratchpadSize: Int = 256 * 1024,
cacheSize: Int = 8 * 1024,
dataWidth: Int = 256
) extends Module {
val io = IO(new Bundle {
// Scratchpad接口
val sp = new Bundle {
val en = Input(Bool())
val wr = Input(Bool())
val addr = Input(UInt(log2Ceil(scratchpadSize/(dataWidth/8)).W))
val wdata = Input(UInt(dataWidth.W))
val rdata = Output(UInt(dataWidth.W))
}
// Cache接口
val cache = new Bundle {
val req = Input(Bool())
val addr = Input(UInt(32.W))
val hit = Output(Bool())
val data = Output(UInt(dataWidth.W))
}
})
// Scratchpad模块
val scratchpad = Module(new Scratchpad(scratchpadSize, dataWidth))
scratchpad.io <> io.sp
// Cache模块
val cache = Module(new SimpleCache(cacheSize, dataWidth))
cache.io.req := io.cache.req
cache.io.addr := io.cache.addr
io.cache.hit := cache.io.hit
io.cache.data := cache.io.data
}
// 优化建议实现
class OptimizedNPUMemory extends Module {
val io = IO(new Bundle {
val cmd = Input(new MemoryCommand)
val status = Output(new MemoryStatus)
})
// 根据访问模式自动选择存储类型
val accessPatternDetector = Module(new AccessPatternDetector)
val memoryAllocator = Module(new DynamicMemoryAllocator)
// 自适应分配策略
when(accessPatternDetector.io.isRegular) {
// 规则访问 -> Scratchpad
memoryAllocator.io.allocType := MemType.Scratchpad
}.elsewhen(accessPatternDetector.io.isRandom) {
// 随机访问 -> Cache
memoryAllocator.io.allocType := MemType.Cache
}.otherwise {
// 混合访问 -> 智能分配
memoryAllocator.io.allocType := MemType.Hybrid
}
}
问题:为一个处理稀疏矩阵乘法的NPU设计存储系统。稀疏数据访问不规则,但计算密集部分访问规则。如何设计?