4、硬件抽象层设计:存算阵列的抽象模型,PE与Crossbar结构,数据流与指令集设计
各位同学,今天我们来聊聊硬件抽象层。说实话,这是整个存算一体系统里最考验「抽象能力」的一环。你想想看,底层是密密麻麻的模拟单元、晶体管、电阻阵列,上层是算法工程师写的矩阵乘法、卷积算子——中间这层抽象做不好,上下就脱节了。
我个人习惯,做硬件抽象层设计时,脑子里始终要绷着一根弦:我是在给软件工程师造「假硬件」。这个假硬件要足够简单、规整,让他们能轻松写出算子;同时又要足够精确,不能丢失底层的关键性能信息。
4.1 存算阵列的抽象模型
先说说存算阵列。我在项目中遇到过不少新人,一上来就盯着RRAM单元的I-V曲线、ADC的量化精度看,结果写出来的抽象模型要么太细(仿真跑不动),要么太粗(精度损失严重)。
我的建议是:三层抽象。
| 抽象层级 | 粒度 | 用途 | 仿真速度 |
|---|---|---|---|
| 行为级 | 整个阵列 | 算法验证、算子原型 | 极快 |
| 周期精确级 | 行/列 | 指令调度、流水线分析 | 中等 |
| 电路级 | 单个单元 | 精度分析、良率评估 | 极慢 |
行为级模型长什么样?说白了就是一个矩阵乘法器。我给你看个简化版:
// 行为级存算阵列抽象
class ComputeArray {
int rows, cols;
float weight[rows][cols]; // 存储的权重矩阵
// 输入向量,输出向量
vector<float> matmul(vector<float> input) {
vector<float> output(cols, 0);
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
output[j] += input[i] * weight[i][j];
}
}
return output;
}
};
嗯,这里要注意:行为级模型里我故意省略了ADC量化、非线性的影响。为什么?因为早期算法验证阶段,这些细节只会拖慢迭代速度。等到算子调通了,再逐步加入非理想因素。
核心原则:抽象层级的选取,取决于你当前要回答的问题。问「算法能不能收敛」用行为级;问「指令调度会不会冲突」用周期精确级;问「良率够不够」才用电路级。
4.2 PE与Crossbar结构
接下来是PE(处理单元)和Crossbar。这两个概念经常被混为一谈,我简单区分一下:
- PE:一个完整的计算节点,包含存算阵列、输入输出缓冲、控制逻辑
- Crossbar:PE内部的互连结构,负责把输入信号路由到各个存算单元
我曾经在一个项目里吃过亏——把Crossbar设计成全连接的,结果面积爆炸。后来才明白,存算阵列的Crossbar不需要全连接。你想想看,矩阵乘法里,输入向量的每个元素只需要跟对应行的权重相乘,根本不需要跨行连接。
所以实际设计中,Crossbar通常是这样的结构:
// Crossbar 路由配置
// 输入总线 -> 行解码器 -> 字线驱动 -> 存算单元阵列 -> 位线读出 -> ADC
struct CrossbarConfig {
int input_mux_sel; // 输入选择:来自前级PE还是全局总线
int row_decoder_mask; // 行选通掩码,支持部分行激活
int adc_ref_sel; // ADC参考电压选择,控制量化精度
};
这里我画一张图,帮你把整个结构串起来:
设计技巧:PE内部的Crossbar其实可以复用。我习惯把Crossbar设计成「可配置的稀疏连接」——通过一组寄存器控制哪些行被激活,哪些列被旁路。这样同一个PE既能做全精度的矩阵乘,也能做稀疏的向量点积。
4.3 数据流与指令集设计
数据流设计,说白了就是回答三个问题:数据从哪来?在PE里怎么转?算完往哪去?
我见过最糟糕的设计是「所有数据都走全局总线」。结果就是总线带宽成为瓶颈,PE利用率不到30%。正确的做法是:让数据尽量在PE内部循环。
举个例子,一个典型的卷积算子映射到存算阵列上,数据流是这样的:
- 输入特征图分块加载到PE的输入缓冲
- 权重已经存储在Crossbar阵列中(存算一体嘛)
- 逐行激活,模拟计算,ADC读出
- 部分和累加,留在PE的输出缓冲里
- 下一个输入块进来,重复1-4,直到卷积窗口滑完
你看,整个过程中,只有第一步需要从外部读数据,后面都在PE内部流转。这就是数据局部性的威力。
指令集设计呢?我建议遵循「RISC哲学」——指令要简单、规整、数量少。存算阵列不需要像CPU那样复杂的指令集,几条核心指令就够了:
| 指令 | 操作码 | 功能 | 周期数 |
|---|---|---|---|
| LOAD | 0x01 | 从全局缓冲加载数据到PE输入缓冲 | 可变 |
| COMPUTE | 0x02 | 执行一次存算阵列的矩阵向量乘 | 1 |
| ACCUM | 0x03 | 累加输出缓冲中的部分和 | 1 |
| STORE | 0x04 | 将输出缓冲结果写回全局缓冲 | 可变 |
| CONFIG | 0x05 | 配置Crossbar路由、ADC参数等 | 1 |
嗯,这里要注意:COMPUTE指令虽然只占1个周期,但实际模拟计算需要等待ADC转换完成。所以指令执行时间其实是「1周期 + ADC延迟」。我在设计指令流水线时,会把ADC延迟隐藏起来——在ADC转换的同时,让PE去处理下一个输入块的加载。
避坑指南:我曾经在一个项目里把CONFIG指令设计成「隐式执行」——每次LOAD指令自动重配Crossbar。结果导致调度器完全不可预测,调试了整整两周。后来改成显式CONFIG指令,所有配置变化都在指令流里清晰可见,问题迎刃而解。
最后,我想强调一点:指令集设计一定要跟数据流配合。你设计出来的指令,最终要能描述出高效的数据流动。我常用的方法是先画数据流图,再根据数据流图反推需要哪些指令。如果一条指令在数据流图里找不到对应的「动作」,那它大概率是多余的。
好了,这一章的内容就到这里。硬件抽象层是连接算法和硬件的桥梁,设计得好,后面的算子映射会非常顺畅;设计得不好,后面每一步都是坑。希望各位在实际项目中,能多花点时间在这层抽象上——磨刀不误砍柴工。
想要更多资料,联系可可