【纯Verilog手搓实现SNN脉冲神经网络部署在FPGA】笔记
这是一份代码笔记,代码和视频出处如下:
作者:第524层梦境゜
视频地址:https://www.bilibili.com/video/BV1rh7Az6E4s
代码链接(来源于作者): https://pan.baidu.com/s/1YWjm3OL58MZ7HUTh2m1Z1A?pwd=7b9p
权重链接(来源于作者): https://pan.baidu.com/s/14SkNA5g_1Q1qffcXDaFXVg?pwd=9trq
代码文件分析
全局代码:snn_top(这个文件用于调控全局,调用其他的代码文件,执行流程)
被调用文件:
| 文件名称 | 功能说明 |
|---|---|
image_loader |
从BRAM加载图像数据到缓冲区 |
poisson_encoder |
将输入的模拟信号编码为脉冲序列(泊松编码)。输入值越大,每个时间步内产生脉冲的概率越高。将静态数据转换为时间域上的动态脉冲信号,作为SNN输入。 |
lif_neuron_layer |
由多个LIF组成的神经网络层。每个神经元接收前一层的脉冲输入,对输入加权求和并累积膜电位,膜电位达到阈值时产生输出脉冲,并在每个时间步更新状态。用于模拟生物神经元的动态响应特性。 |
output_logic |
输出结果。输出层神经元在每个时间步发出的脉冲都有被记录,把这些累加,看哪个类别的累加数最大,这个类别就是结果。 |
weights_0 |
存储网络第一层的连接权重。 |
prng_lfsr |
一个LFSR结构的伪随机数生成器,在泊松编码过程中提供随机性。 |
control_unit |
控制整个SNN系统的运行时序和状态流程。 |
文件逐个分析
image_loader
从BRAM加载图像数据到缓冲区
代码翻译
为了方便读代码,我把变量名换成了中文,不习惯的也可以直接看作者的原代码。
// image_loader.v
// 从外部BRAM IP核中读取图像像素数据,并存储到内部寄存器阵列中。
// 采用流水线方式读取BRAM,并按指定顺序填充内部图像缓冲区。
// 假设BRAM的输出数据 每一次从BRAM读出的数据 [63:0] 的打包方式为:
// 每一次从BRAM读出的数据[63:56] 存储该8像素组中的第0个逻辑像素(光栅顺序最靠前)。
// 每一次从BRAM读出的数据[7:0] 存储该8像素组中的第7个逻辑像素(光栅顺序最靠后)。
// 内部图像缓冲区 缓冲区 和输出 完整的图片数据数组 的索引方式为:
// [像素数量-1] (例如[783]) 对应图像的左上角(光栅索引0)。
// [0] 对应图像的右下角(光栅索引 像素数量-1)。
// 输入是按照光栅扫描顺序存取,高位(数字大的索引)对应的是光栅起始也即图片左上角,低位(数字小的索引)对应的是光栅终止也即右下角。
// BRAM字地址(0到BRAM有效存储字数-1)是按照从小到大递增的,对应于图片像素存储的光栅扫描顺序,
// 地址0对应的是最开始的一批图片像素和输入(即图片的左上角部分)。
// 图片像素的存储和读取均是按照图片从左到右从上到下的顺序存和读的,因此在 .coe 文件中第一个数据包(BRAM地址0的内容)是图片左上角前四个权重。
// 每个图片像素格式为两位无符号数十六进制数格式
module image_loader #(
// --- 用户可配置的逻辑参数 ---
parameter 像素数量 = 784, // 图像总像素数 (28x28)
parameter 单个像素位宽 = 8, // 单个像素的位宽
// --- 描述【外部图像】BRAM IP核的固定特性 ---
parameter BRAM端口位宽 = 64, // 图像BRAM的数据输出端口位宽
parameter BRAM有效存储字数 = 98 // 图像BRAM的有效存储字数 (98个64位的字)
) (
input wire clk,
input wire rst_n,
input wire 图像加载信号, // 启动图像加载的脉冲信号
input wire [BRAM端口位宽-1:0] 每一次从BRAM读出的数据, // 每一次从图像BRAM读出的数据
output reg [($clog2(BRAM有效存储字数))-1:0] 输出给图像BRAM的地址, // 输出给图像BRAM的地址
output reg BRAM输出信号, // 输出给图像BRAM的使能信号
output reg [像素数量-1:0] [单个像素位宽-1:0] 完整的图片数据数组, // 完整的图片数据数组
output reg 图像加载忙信号, // 图像加载忙信号
output reg 图像加载完成脉冲信号 // 图像加载完成脉冲信号,单周期脉冲
);
// ------------------- 内部常量和状态定义 (根据参数计算) -------------------
localparam 每BRAM字包含的像素数 = BRAM端口位宽 / 单个像素位宽; // 每BRAM字包含的像素数 (例如 64/8 = 8)
localparam 图像BRAM的最大有效地址 = BRAM有效存储字数 - 1; // 图像BRAM的最大有效地址 (例如 98-1 = 97)
localparam 图像BRAM的读取延迟周期数 = 2; // 图像BRAM的读取延迟周期数 (从发出ena/addr到douta有效)
localparam BRAM字数 = $clog2(BRAM有效存储字数 + 1); // 计算BRAM地址总线和计数到BRAM有效存储字数个事件的计数器的位宽的位宽
// 状态机状态定义
localparam 状态编码位数 = 2; // 状态编码所需的位数 (基于下面定义的4个状态)
localparam 状态【空闲】 = 2'b00; // 00: 空闲状态,等待图像加载信号信号
localparam 状态【BRAM-缓冲区】 = 2'b01; // 01: 从BRAM加载像素到内部缓冲区的流水线处理中
localparam 状态【等待流水线处理】 = 2'b10; // 10: 已停止发起新的BRAM读取,等待流水线中剩余数据处理完毕
localparam 状态【over】 = 2'b11; // 11: 所有数据已加载并写入内部缓冲区,加载完成
reg [状态编码位数-1:0] 当前状态, 下一状态; // FSM当前状态和下一状态寄存器
// ------------ 内部图像缓冲区 ------------
// 用于逐行存储从BRAM加载的像素数据,索引方式: 缓冲区[像素数量-1] 对应光栅扫描的第一个像素(左上角)
reg [单个像素位宽-1:0] 缓冲区 [像素数量-1:0];
// ------------ BRAM地址和有效位流水线寄存器 ------------
// 用于将发往BRAM的地址和其有效性标志打拍,以匹配BRAM的读取延迟,确保数据对齐。
// 深度为 图像BRAM的读取延迟周期数 2级。
// 地址[0] 存储当前周期发出的地址,地址[1] 存储上一周期发出的地址。
// 地址是否有效[0] 存储当前周期发出的地址是否有效,地址是否有效[1] 存储上一周期发出的地址是否有效。
reg [BRAM字数-1:0] 地址 [图像BRAM的读取延迟周期数-1:0];
reg 地址是否有效 [图像BRAM的读取延迟周期数-1:0];
// ------------ 计数器 ------------
// 已发到BRAM的字数: 记录已经向BRAM发出了多少个数据字的读取命令。
// 其值从0增加到BRAM有效存储字数,表示已发出BRAM有效存储字数次请求。
reg [BRAM字数-1:0] 已发到BRAM的字数;
// 已写到缓冲区的字数: 记录已经从BRAM成功读取并写入到内部缓冲区的有效数据字的数量。
// 其值从0增加到BRAM有效存储字数。
reg [BRAM字数-1:0] 已写到缓冲区的字数;
logic [单个像素位宽-1:0] 数据解包; // 用于数据解包
// ------------------- 时序逻辑:状态机和寄存器更新 -------------------
always @(posedge clk or negedge rst_n) begin
integer k_init_loop;
if (!rst_n) begin // 复位逻辑
当前状态 <= 状态【空闲】;
输出给图像BRAM的地址 <= {BRAM字数{0}};
已发到BRAM的字数 <= {BRAM字数{0}};
已写到缓冲区的字数<= {BRAM字数{0}};
图像加载忙信号 <= 0;
图像加载完成脉冲信号 <= 0;
// 初始化内部缓冲区和输出缓冲区为0
for (k_init_loop = 0; k_init_loop < 像素数量; k_init_loop = k_init_loop + 1) begin
完整的图片数据数组[k_init_loop] <= {单个像素位宽{0}};
缓冲区[k_init_loop] <= {单个像素位宽{0}};
end
// 初始化流水线寄存器
for (k_init_loop = 0; k_init_loop < 图像BRAM的读取延迟周期数; k_init_loop = k_init_loop + 1) begin
地址[k_init_loop] <= {BRAM字数{0}};
地址是否有效[k_init_loop] <= 0;
end
end else begin // 时钟有效沿操作
当前状态 <= 下一状态; // 更新当前状态
图像加载完成脉冲信号 <= 0; // 图像加载完成脉冲信号 是单周期脉冲,默认为0
// 当从 状态【空闲】 进入 状态【BRAM-缓冲区】 时,进行初始化操作
if (当前状态 == 状态【空闲】 && 下一状态 == 状态【BRAM-缓冲区】) begin
输出给图像BRAM的地址 <= {BRAM字数{0}}; // BRAM地址从0开始
已发到BRAM的字数 <= {BRAM字数{0}}; // 已发出BRAM字计数清零
已写到缓冲区的字数<= {BRAM字数{0}}; // 已写入缓冲区的BRAM字计数清零
图像加载忙信号 <= 1'b1; // 设置为加载忙状态
// 清空地址和有效位流水线的valid标志
for (k_init_loop = 0; k_init_loop < 图像BRAM的读取延迟周期数; k_init_loop = k_init_loop + 1) begin
地址是否有效[k_init_loop] <= 0;
end
end
// 地址和有效位流水线移位逻辑 (在 状态【BRAM-缓冲区】 或 状态【等待流水线处理】 状态下进行)
if (当前状态 == 状态【BRAM-缓冲区】 || 当前状态 == 状态【等待流水线处理】) begin
for (k_init_loop = 图像BRAM的读取延迟周期数 - 1; k_init_loop > 0; k_init_loop = k_init_loop - 1) begin
地址[k_init_loop] <= 地址[k_init_loop-1]; // 地址[1] <= 地址[0]
地址是否有效[k_init_loop] <= 地址是否有效[k_init_loop-1]; // 地址是否有效[1] <= 地址是否有效[0]
end
地址是否有效[0] <= BRAM输出信号; // 将当前周期的BRAM使能信号存入流水线第一级
地址[0] <= 输出给图像BRAM的地址; // 将当前发往BRAM的地址存入流水线第一级
// 如果 BRAM输出信号 为0,地址[0] 的值不重要(或保持原值),因为 地址是否有效[0] 会是0
end
// 当流水线末端数据有效时,将从BRAM读出的数据解包并写入内部图像缓冲区
// 注意由于数据滞后地址两个时钟,因此 地址是否有效[1] 对应的数据在 每一次从BRAM读出的数据 中
if ((当前状态 == 状态【BRAM-缓冲区】 || 当前状态 == 状态【等待流水线处理】) && 地址是否有效[图像BRAM的读取延迟周期数-1]) begin
/*
当你在 module ... endmodule 之间,但在任何 always 块、initial 块或 assign 语句之外声明一个变量(如 integer my_var; 或 reg my_var;),你实际上是在声明一个可以在整个模块内被访问的静态变量或寄存器。
integer 类型在Verilog中通常被视为一个32位的有符号 reg。
缓冲区base索引 的值只在它被计算出来的那个时钟周期的那个特定代码路径下被用来计算 缓冲区索引。它本身的值并不需要在时钟周期之间保持状态以供其他逻辑在后续周期使用。
如果 缓冲区base索引 的目的仅仅是在那个 if 条件块内部作为一个临时的、用于辅助计算的变量,那么将它声明在模块级别会使其作用域过大,并且可能误导阅读者(或综合器)认为它是一个需要保持状态的全局寄存器
更好的做法:将这种只在特定代码块内使用的临时计算变量声明在该代码块的内部。
我最开始以为所有的声明都必须在always块外面,其实是不对的
*/
integer 缓冲区base索引;
integer 缓冲区i索引;
integer 缓冲区索引;
// 此处 地址是否有效[图像BRAM的读取延迟周期数-1] 为高,表示当前周期的 每一次从BRAM读出的数据 上的数据是有效的,并且它对应于 地址[图像BRAM的读取延迟周期数-1] 中存储的BRAM字地址。
缓冲区base索引 = 地址[图像BRAM的读取延迟周期数-1] * 每BRAM字包含的像素数;
// 循环解包并存储一个BRAM字中包含的所有像素 (8个)
for (缓冲区i索引 = 0; 缓冲区i索引 < 每BRAM字包含的像素数; 缓冲区i索引 = 缓冲区i索引 + 1) begin
// 从每一次从BRAM读出的数据中提取第缓冲区i索引个逻辑像素
// 假设 缓冲区i索引=0 对应 每一次从BRAM读出的数据 的最高8位 (光栅最前)
// 缓冲区i索引=7 对应 每一次从BRAM读出的数据 的最低8位 (光栅最后)
数据解包 = 每一次从BRAM读出的数据[(BRAM端口位宽 - 1 - (缓冲区i索引 * 单个像素位宽)) -: 单个像素位宽];
// 该像素在内部图像缓冲区中的目标索引
// 目标索引计算:高索引存光栅起始像素
缓冲区索引 = 像素数量 - 1 - (缓冲区base索引 + 缓冲区i索引);
if (缓冲区索引 >= 0 && 缓冲区索引 < 像素数量) begin // 边界检查
缓冲区[缓冲区索引] <= 数据解包;
end
end
已写到缓冲区的字数 <= 已写到缓冲区的字数 + 1; // 已写入缓冲区的BRAM字计数增加
end
// BRAM地址更新 和 已发出请求计数更新 (仅在状态【BRAM-缓冲区】且实际发出BRAM使能时)
if (当前状态 == 状态【BRAM-缓冲区】 && BRAM输出信号) begin
已发到BRAM的字数 <= 已发到BRAM的字数 + 1;
if (输出给图像BRAM的地址 < 图像BRAM的最大有效地址) begin // 如果当前地址未达到最大有效地址
输出给图像BRAM的地址 <= 输出给图像BRAM的地址 + 1; // 准备下一个要读取的地址
end
end
// 当加载完成状态时,输出结果并设置完成标志
if (当前状态 == 状态【over】) begin
图像加载完成脉冲信号 <= 1'b1; // 发出加载完成脉冲
图像加载忙信号 <= 0; // 加载不再繁忙
// 将内部缓冲区的图像数据赋给输出端口
for (k_init_loop = 0; k_init_loop < 像素数量; k_init_loop = k_init_loop + 1) begin
完整的图片数据数组[k_init_loop] <= 缓冲区[k_init_loop];
end
end
end
end
// ------------------- 组合逻辑:状态转移控制 和 BRAM使能控制 -------------------
always @(*) begin
下一状态 = 当前状态; // 默认保持当前状态
BRAM输出信号 = 0; // BRAM使能默认为低
case (当前状态)
状态【空闲】: begin
if (图像加载信号) begin
下一状态 = 状态【BRAM-缓冲区】; // 收到启动信号,进入加载状态
end
end
状态【BRAM-缓冲区】: begin
if (已发到BRAM的字数 < BRAM有效存储字数) begin // 如果还有BRAM字需要发出读取命令
BRAM输出信号 = 1'b1; // 使能BRAM进行读取
下一状态 = 状态【BRAM-缓冲区】; // 保持在加载状态
end else begin // 所有BRAM地址的读取命令均已发出
BRAM输出信号 = 0; // 停止使能BRAM
下一状态 = 状态【等待流水线处理】; // 进入冲刷流水线状态,等待剩余数据写入
end
end
状态【等待流水线处理】: begin
BRAM输出信号 = 0; // 在冲刷状态,不再发起新的BRAM读取
if (已写到缓冲区的字数 == BRAM有效存储字数) begin // 等待全数据处理完毕并写入缓冲区
下一状态 = 状态【over】;
end else begin
下一状态 = 状态【等待流水线处理】; // 继续冲刷
end
end
状态【over】: begin
下一状态 = 状态【空闲】; // 完成加载后,返回空闲状态
end
default: 下一状态 = 状态【空闲】;
endcase
end
endmodule
参数定义
parameter P_NUM_INPUT_PIXELS = 784, // 图像总像素数 (28x28)
parameter P_PIXEL_INTENSITY_BITS = 8, // 单个像素位宽(8位灰度值)
parameter P_IMAGE_BRAM_DATA_WIDTH = 64, // BRAM 数据宽度(一次读64位)
parameter P_IMAGE_BRAM_DEPTH = 98 // BRAM 总地址数(共98个64位字)
图像位数 = 28像素 × 28像素 × 8位
BRAM 每次输出 64 位数据,所以一次可读取 8 个像素
所以需要读 28x28÷8 = 98次
状态
数据的流向:图像像素 -> BRAM -> 缓冲区 -> 输出
所以状态有:
- S_IDLE_FSM : 等待启动信号
- S_LOADING_FSM : 正在从 BRAM 读取数据
- S_FLUSH_PIPE_FSM : 已停止发请求,等待流水线中剩余数据写入
- S_DONE_LOAD_FSM : 所有数据加载完毕
注意读取方式:
- 图像 -> BRAM:是一组一组读的,每组是64位(8像素),总共读98组
- BRAM -> 缓冲区:也是一组一组读的,前一个步骤传给BRAM一组(8像素),BRAM就传缓冲区一组(8像素)。存入缓冲区的时候需要把8个像素解包,然后一个像素一个像素地存进缓冲区。
- 缓冲区 -> 输出:一口气输出。就是缓冲区存着每组从BRAM读取的数据,读完98组全部输出。
代码顺序:
- 接收启动信号 → 开始加载图像
- 按地址 0~97 顺序读取 BRAM,每次读 64 位(8 像素)
- 等待 2 周期延迟后,将读出的数据解包为 8 个 8 位像素
- 按光栅顺序逆序存入内部缓冲区(左上角在高索引)
- 累计写入 98 个数据包后,确认所有数据已加载
- 输出完整图像数组,并发出“加载完成”脉冲
- 回到空闲状态,等待下一次加载
第3步中,发现了一个“2周期延迟”,这是什么东西,为什么有延迟。
你给BRAM发出一个信号,说要数据,不会立马得到,BRAM会在一定的延迟后给你(例如这份代码里面延迟是2)。
比如你在T=3请求数据,T=4的时候才能得到数据。
而缓冲区1步之前发出请求,现在得到数据,要做的是解包8个像素,每个像素写入缓冲区。
那这延迟不是1吗?
我问了ai,告诉我:
BRAM 物理延迟:1 个周期(T3 请求 → T4 数据出现)
系统级使用延迟:2 个周期(T3 请求 → T4 才能在逻辑中安全使用)
不知道这是不是正确的。
仿真流程
初始化
时钟周期 0:
【状态】: 空闲
-- 统计: 已发请求=0, 已处理=0
时钟周期 1:
【状态】: 空闲
-- 统计: 已发请求=0, 已处理=0
时钟周期 2:
【状态】: 空闲
-- 收到图像加载信号,开始加载
-- 统计: 已发请求=0, 已处理=0
收到信号后开始发请求
时钟周期 3:
【状态】: BRAM-缓冲区
-- 请求BRAM地址 0
-- 流水线更新: 地址[0, 0], 有效[True, False]
-- 统计: 已发请求=1, 已处理=0
时钟周期 4:
【状态】: BRAM-缓冲区
-- 请求BRAM地址 1
-- 流水线更新: 地址[1, 0], 有效[True, True]
-- 处理BRAM地址 0 的数据:
BRAM[0] -> 缓冲区[0:7]
-- 统计: 已发请求=2, 已处理=1
时钟周期 5:
【状态】: BRAM-缓冲区
-- 请求BRAM地址 2
-- 流水线更新: 地址[2, 1], 有效[True, True]
-- 处理BRAM地址 1 的数据:
BRAM[1] -> 缓冲区[8:15]
-- 统计: 已发请求=3, 已处理=2
……
结束
时钟周期 99:
【状态】: BRAM-缓冲区
-- 请求BRAM地址 96
-- 流水线更新: 地址[96, 95], 有效[True, True]
-- 处理BRAM地址 95 的数据:
BRAM[95] -> 缓冲区[760:767]
-- 统计: 已发请求=97, 已处理=96
时钟周期 100:
【状态】: BRAM-缓冲区
-- 请求BRAM地址 97
-- 流水线更新: 地址[97, 96], 有效[True, True]
-- 处理BRAM地址 96 的数据:
BRAM[96] -> 缓冲区[768:775]
-- 统计: 已发请求=98, 已处理=97
时钟周期 101:
【状态】: BRAM-缓冲区
-- 所有BRAM请求已发出,等待流水线处理
-- 流水线更新: 地址[97, 97], 有效[False, True]
-- 处理BRAM地址 97 的数据:
BRAM[97] -> 缓冲区[776:783]
-- 统计: 已发请求=98, 已处理=98
时钟周期 102:
【状态】: 等待流水线处理
-- 所有数据已处理完成
-- 流水线更新: 地址[97, 97], 有效[False, False]
-- 统计: 已发请求=98, 已处理=98
时钟周期 103:
【状态】: over
-- 加载完成,返回空闲状态
-- 输出完整图像数据
-- 统计: 已发请求=98, 已处理=98
这份代码之所以比较繁琐,主要是因为延迟和解包。
如果没有延迟和解包,直接for循环98次,每次读取BRAM中8个字写入缓冲区,就完事了。
因为延迟,所以需要有两个地址A和B来记录当前请求的地址和上次请求的地址。还需要有两个状态A_valid和B_valid来记录当前请求地址是否有效和上次请求的地址是否有效。
因为解包,所以每次写数据,需要计算写到缓冲区的哪个地址范围,比如你得BRAM[97] -> 缓冲区[776:783],而这里的97就是上一句话说到的地址B。
(这里的A和B之类的是随便写的,方便理解,不是代码里面的变量名)。
output_logic
上一份文件是模型的输入,现在说一下模型的输出。
这份文件的输入主要有信号和LIF层的输出向量。
信号分为累加信号和决策信号,因为有100个时间步,所以在1-99个时间步每次有一个结果(这个结果就是LIF每次对这个时间步数据的预测结果,因为总共是10个标签,所以每次的结果是一组10个的向量)执行的是累加。
在最后一个时间步,累加完进行决策。也就是看这10个标签的100次结果加下来, 哪个标签的结果最高,最后的预测结果输出就是这个标签。
仿真流程
| 时间步 \ 类别 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| t = 0 | 1 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
| t = 1 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 1 | 0 | 1 |
| t = 2 | 1 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 1 | 0 |
| t = 3 | 0 | 1 | 1 | 1 | 0 | 0 | 1 | 0 | 0 | 1 |
| … | … | … | … | … | … | … | … | … | … | … |
| t = 98 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 |
| t = 99 | 0 | 1 | 1 | 0 | 1 | 1 | 1 | 0 | 1 | 1 |
| 累计脉冲数 | 42 | 38 | 45 | 33 | 40 | 39 | 37 | 41 | 44 | 36 |
| 预测结果 | ✅ |
每行是每个时间步LIF的输出向量,每次给本文件的是累加信号。
最后t=99的时候,给的是决策信号,所以就得到的了表格倒数第二行的累积脉冲数,可以看是标签2最高,所以最后决策结果是:标签2。
顺序
-
初始化:
- 在复位信号
rst_n为低电平时,所有内部寄存器被初始化为0。
- 在复位信号
-
累加信号处理:
- 当累加信号有效时,表明当前时间步的脉冲数据可以累加。
- 对于每一个神经元(共10个),如果其对应的输出结果向量中的值为1(神经元发放了脉冲),则相应神经元的脉冲计数器加1。
-
决策信号处理:
- 当决策信号有效时,表示所有时间步已经完成,开始进行最终的决策过程。
- 首先初始化最大计数值为第一个神经元的脉冲计数值,并将最大值索引设为0。
- 然后遍历所有神经元的脉冲计数器,寻找具有最大脉冲计数的神经元,并更新最大计数值和相应的索引。
- 最大值索引作为预测的(
类别标签)输出,而最大计数值作为(预测结果脉冲数值)输出。 - 设置(
类别标签 和 预测结果脉冲数值 是否有效)为高电平,指示输出结果有效。
-
重置:
- 在决策输出之后,立即重置所有神经元的脉冲计数器为零,以便为下一轮计算做准备。
poisson_encoder
参数
parameter P_NUM_INPUT_PIXELS = 784, // 输入像素的总数
parameter P_PIXEL_INTENSITY_BITS = 8, // 单个像素强度值与PRNG输出随机数的位宽 (0-255对应与归一化的像素值0-1)
parameter P_PRNG_LFSR_WIDTH = 32, // 传递给内部prng_lfsr实例的LFSR状态位宽
parameter P_T_MAX = 100 // SNN总时间步数, 用于计算 i_time_step_t 的位宽
首先是输入了像素数量28×28,像素位宽8。
这份文件的主要功能是根据输入图像的像素强度,在每个时间步生成随机脉冲输出。意思就是:
- 对这784个像素,每个像素生成一个随机数。随机数的位宽=像素位宽=8。
为什么强调位宽,是要让随机数的大小范围 = 像素的强度范围。像素是8个二进制位表示的,所以像素的范围就是0-255;所以随机数也是8位,范围也是0-255。 - 如果随机数 < 像素强度,则认为该像素发放脉冲;否则不发放。发放脉冲的概率正比于像素强度。
(这个文件调用了prng_lfsr文件来生成随机数,prng_lfsr是伪随机数生成器。)
仿真模拟
对每个像素:
| 像素强度 | 随机数 | 是否发放脉冲 |
|---|---|---|
| 255(最亮) | 123 | 是(123 < 255)→ 概率 ≈ 100% |
| 128(中等) | 150 | 否(150 > 128)→ 概率 ≈ 50% |
| 10(很暗) | 8 | 是(8 < 10)→ 概率 ≈ 4% |
- 对于一个强度为 128 的像素(8 位中的一半),它大约有 50% 的概率发放脉冲。
- 对于强度为 255 的像素,几乎总是发放脉冲。
- 对于强度为 0 的像素,从不发放脉冲。
这就体现了泊松编码的核心思想是:一个像素越亮(强度越高),它在每个时间步发放脉冲(spike)的概率就越高。
代码逻辑
-
定义模块参数:设置
像素总数28×28、像素位宽8、LFSR状态位宽32和总时间步数100等可配置参数。 -
使用generate循环:每个像素调用一次prng_lfsr模块,为每个像素创建一个独立的伪随机数生成器。
-
使用generate循环:对每个像素,将PRNG输出的随机数与对应像素强度进行比较判断是否发放脉冲,当PRNG有效且随机数小于像素强度时,对应位置输出高电平(表示发放脉冲)。
-
处理复位情况:当复位信号有效时,清空输出脉冲向量和有效信号。
-
正常工作时更新输出:当PRNG有效时,将当前比较结果锁存到输出寄存器中。
prng_lfsr
随机数生成器
linear_layer
参数
parameter P_NUM_INPUT_PIXELS = 784, // 像素数量28×28
parameter P_WEIGHT_BIT_WIDTH = 16, // 权重位宽
parameter P_NEURON_VALUE_TOTAL_BITS = 26, // 神经元值总位宽
parameter P_NEURON_VALUE_FRAC_BITS = 12, // 神经元值小数位宽
parameter P_BRAM_DATA_WIDTH = 64, // BRAM IP核的数据输出端口位宽
parameter P_BRAM_ADDR_WIDTH = 8, // BRAM IP核的地址端口位宽
parameter P_BRAM_EFFECTIVE_DEPTH = 196, // BRAM IP核的有效存储字数
parameter P_NUM_OUTPUT_NEURONS = 10 // 输出神经元数量
因为标签有10个,所以输出神经元数量=10
权重
权重是连接输入像素与输出神经元之间的突触强度,类似于传统神经网络中的权重参数。
权重有10个,分别是weights_0,weights_1,weights_2,…
数据存储结构:以 weights_0 为例,存储第0个输出神经元的784个权重值。如下:
Memory_Initialization_Radix=16;
Memory_Initialization_Vector=
FF8D00920032006A,FF7D0071FF79002B,0027FFD1FF710012,FFBE005A0020000D,
00770046FF5EFFA3,FFD9FF8DFF9A00E2,FF8C006EFFA2003A,004EFFF5FFE1FF93,
004C0042005AFFD2,FF70FFE3FC0F07D4,08CB07E6FC5A0369,045906220019FF5C,
读取权重
首先读取10个神经元的权重。
读取权重的操作(拿weights_0举例):
if (n_idx == 0) begin : gen_bram_0
weights_0 u_bram_neuron_0 (
.clka (clk),
.ena (bram_ena_for_npu),
.addra (bram_addr_for_npu),
.douta (bram_dout_to_npu)
);
这里总共有三个输入,一个输出。
| 端口 | 方向 | 来源/去向 | 含义 |
|---|---|---|---|
.clka |
输入 | clk |
时钟信号,BRAM 在时钟上升沿读数据 |
.ena |
输入 | bram_ena_for_npu |
使能信号,高电平时允许读操作 |
.addra |
输入 | bram_addr_for_npu |
要读取的地址(比如 0, 1, 2…) |
.douta |
输出 | bram_dout_to_npu |
从该地址读出的数据(64 位) |
addra(bram_addr_for_npu)地址的宽度是8,最多可以寻址 28=2562^8 = 25628=256 个位置,实际上储存了196个(刚刚的参数里面定义的)。douta(bram_dout_to_npu)数据的宽度是64,每个地址存一个 64 位的数据。
- 地址宽度 = 8 → 256 个地址
- 数据宽度 = 64 → 每个地址存 64 位数据
- 总容量 = 256 × 64 = 16,384 位(约 2KB)
所以,一个 BRAM 的总容量 = 2地址宽度×数据宽度2^{\text{地址宽度}} \times \text{数据宽度}2地址宽度×数据宽度
neuron_processing_unit
主要职责:
- 权重读取:从BRAM顺序读取权重数据
- 脉冲匹配:将权重与对应的输入脉冲对齐
- 乘积累加:计算加权和(脉冲×权重)
- 流水线优化:通过2级流水线提高计算吞吐量
参数
P_NUM_INPUT_PIXELS = 784 // 输入像素总数
P_WEIGHT_BIT_WIDTH = 16 // 权重位宽
P_BRAM_DATA_WIDTH = 64 // BRAM数据宽度(包含4个权重)
P_BRAM_EFFECTIVE_DEPTH = 196 // BRAM有效深度(784÷4=196)
BRAM_READ_LATENCY = 2 // BRAM读取延迟2个时钟周期
4个状态转换:
S_IDLE_VAL → S_PROCESSING_VAL → S_FLUSHING_VAL → S_DONE_VAL
开始处理 → 读取权重 → 等待最后两个延迟的数据 → 结束输出,产生`o_current_valid_out`脉冲
关键技术实现
1. 权重解包与对齐
每次读取64位,解包为4个权重,放到w_from_bram变量中
w_from_bram[3] = i_bram_dout_raw[63:48] // 权重0(左上角)
w_from_bram[2] = i_bram_dout_raw[47:32] // 权重1
w_from_bram[1] = i_bram_dout_raw[31:16] // 权重2
w_from_bram[0] = i_bram_dout_raw[15:0] // 权重3(右下角)
2. 脉冲匹配机制
// 关键公式:脉冲索引 = 总像素数 - 1 - (地址×4 + 组内偏移)
global_raster_idx = (current_addr × 4) + logical_offset
pulse_index = 783 - global_raster_idx // 实现权重与脉冲的正确对齐
3. 流水线时序控制
周期0: 发出地址0请求 → BRAM开始读取
周期1: 地址0进入流水线[0],发出地址1请求
周期2: 地址0进入流水线[1],地址1进入流水线[0],收到地址0的数据
周期3: 处理地址0数据,地址1进入流水线[1],收到地址1的数据
运算逻辑
输出电流 = Σ(脉冲[i] × 权重[i])
对于脉冲=1的情况:贡献权重值
对于脉冲=0的情况:贡献0
// 对于每个权重包(4个权重):
if (脉冲有效)
项 = 符号扩展后的权重值
else
项 = 0
// 计算4个项的和
sum_of_4_weights = 项0 + 项1 + 项2 + 项3
// 累加到总和中
accumulator <= accumulator + sum_of_4_weights
更多推荐
所有评论(0)