拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PyTorch权值量化到FPGA定点补码的完整部署流程

把PyTorch里训练好的权值矩阵搬进FPGA第一道坎就是数据表示格式。训练时模型权重是FP32浮点数FPGA里做大规模乘加运算最顺手的却是定点补码两种格式之间需要一层“翻译”。我最近完整跑通了一遍从PyTorch导出权值、量化为定点数补码、生成ROM初始化文件、再导入FPGA做推理的流程里面坑不少尤其是负数补码的处理和端到端验证。这篇文章就把整个过程和可复现的脚本整理出来给同样做FPGA端模型部署的朋友一个参考。1. 项目全景从浮点权值到FPGA定点推理1.1 为什么非做量化不可先算一笔账。FP32是多数PyTorch模型的默认存储格式一个权重占32bit如果直接把FP32系数放到FPGA里参与乘加要么用FPGA上极少见的浮点DSP核要么用通用逻辑搭浮点运算单元资源占用和时序收敛都会非常吃力。相比之下16bit定点数能让RAM占用减半8bit定点则能把权值体积压到原来的四分之一同时定点乘加器在FPGA上有成熟的DSP原语支持一个时钟拍就能稳定完成。代价也很直白精度损失。但神经网络结构本身对噪声和量化误差有一定容忍度只要位宽选择合理、饱和处理得当把权值量化到16bit甚至8bit后推理精度下降通常可以控制在可接受范围内。所以“量化部署”成了边缘端模型上板的主流路线先用PyTorch训练好再离线把权值从浮点转为定点补码最后导入FPGA做定点推理。1.2 完整迁移链路与核心难点这次项目走的链路可以总结成一条流水线PyTorch训练好的模型 → 提取state_dict → 统计每层权值范围 → 选定定点Q格式 → 浮点权值转定点整数 → 转补码十六进制 → 导出.mem/.coe文件 → FPGA端ROM/BRAM初始化 → 定点乘加运算 → 结果反量化输出看起来步骤不多真正动手会碰到几个硬骨头位宽选择16bit还是8bit整数位占几位、小数位占几位都要根据权值实际分布来定拍脑袋选容易溢出或者精度崩掉。补码编码Pytorch和NumPy里没有直接的“补码”类型负数在内存里是二进制补码表示但要导出成FPGA认识的十六进制字符串需要自己做一次编码。Q格式对齐不同层权值范围可能差一个数量级统一用一套Q格式还是逐层选Q格式直接影响最终精度和FPGA端反量化逻辑的复杂度。端到端验证FPGA上算出来的中间结果和PyTorch浮点结果怎么对齐误差多大能接受没有一套校验机制很容易调一晚上也找不到问题。下面从原理到代码一个个讲清楚。2. 定点补码动手前必须搞清的数学细节2.1 补码为什么最适合硬件算术计算机里表示有符号整数主要有三种编码原码、反码、补码。为什么要用补码做定点数的基础核心原因是补码能把减法统一成加法。比如3减2在补码体系里直接就是3 (-2)符号位不特殊处理加法器电路一路算到底。对FPGA这种以加法器和乘法器为主体的硬件来说这种统一非常关键不需要为符号位单独做判断逻辑乘法器也不需要区分正负符号分支。补码的另一个特点是0只有一种表示。原码和反码里的0和-0在补码里合并成了唯一的一个0硬件比较是否等于0时少掉一类边界情况。N位补码能表示的范围是[-2^(N-1), 2^(N-1)-1]负半轴比正半轴多一个数。16bit补码范围是-32768到32767这个不对称在很多初版实现里会被忽略等到出现-32768时才发现设计上有漏洞。2.2 Qm.n定点格式与量化公式定点数本质上就是“把小数乘上一个缩放因子后取整”。常用的是Q格式表示法比如Q2.14表示总共16bit其中1bit符号位、2bit整数位、13bit小数位不过我习惯直接按小数位数量来表达比如“frac14”就表示小数位14位配合总位宽N16时整数位和符号位自动为1位。这里的比例尺是2^frac量化公式可以写成q round(w * 2^frac)然后做饱和处理把q限制在补码能表示的范围内q clamp(q, -2^(N-1), 2^(N-1)-1)一个具体的例子胜过一打公式。假设frac14权重w0.5那么q round(0.5 * 16384) 8192 0x2000权重w-0.75那么q round(-0.75 * 16384) -12288作为补码位模式就是0xD000。FPGA端把一个16位ROM里的0xD000按有符号数读出来得到的正好是-12288再乘以2^-14就还原成-0.75。量化公式里的round可以换成floor或者ceil精度特性略有差异。我习惯用四舍五入因为它是无偏的正负误差期望接近0对后续累加结果的系统性偏差最小。3. PyTorch端实现权值提取、量化和补码导出3.1 从模型里取出权值并统计范围PyTorch里拿权值非常直接model.state_dict()返回一个OrderedDict里面是所有参数张量。遍历时先做detach().cpu().numpy()把张量变成NumPy数组再处理。注意模型要先eval()避免BatchNorm等层还处于训练态影响统计结果。拿到权值后第一件事是统计范围这一步决定Q格式怎么定。操作的代码大概长这样import numpy as np import math def analyze_weight_range(w): w_min float(w.min()) w_max float(w.max()) rmax max(abs(w_min), abs(w_max)) return w_min, w_max, rmax然后根据rmax选择整数位宽。我的经验公式是def choose_frac_bits(w, n_bits16): _, _, rmax analyze_weight_range(w) if rmax 1.0: # 计算需要的整数位数再加1给符号位留空间 int_bits int(math.floor(math.log2(rmax))) 2 else: int_bits 1 # 只有符号位和0.几 frac n_bits - 1 - int_bits if frac 6: raise ValueError(f权值范围过大{n_bits}bit装不下考虑增大位宽或分通道处理rmax{rmax}) return frac这个公式的风险点是rmax刚好等于2的整数次幂比如rmax1.0时log2(1.0)0加上2后整数位为216bit里还剩13位小数足够精确。如果rmax2.0log2(2.0)1整数位为3只剩12位小数也算够用。实际测试下来ResNet这类模型的卷积层权值大多落在[-0.3, 0.3]范围frac14绰绰有余但全连接层有时会出现[-1.5, 1.5]的情况frac13更稳。第一版建议直接全局固定frac14简化FPGA端反量化逻辑。3.2 量化、饱和与补码编码的实现代码量化和补码编码可以拆成两个独立函数分开写方便调试def quantize(w, frac, n_bits16): q np.round(w * (1 frac)).astype(np.int64) q_min -(1 (n_bits - 1)) q_max (1 (n_bits - 1)) - 1 q np.clip(q, q_min, q_max) return q def twos_complement(q, n_bits16): mask (1 n_bits) - 1 # q为负时q mask 相当于 q 2^N得到正确的补码位模式 return (q mask).astype(np.uint64)这里有个很多人第一次写会卡住的点np.round返回浮点直接astype(np.int64)会有警告但能正常工作更稳的写法是np.floor(w * (1 frac) 0.5).astype(np.int64)。我代码里用np.round然后强制转整型简单直白。补码编码的原理再展开一下。一个负数如-12288用16bit补码表示等价于计算2^16 (-12288) 53248它的十六进制正是0xD000。用位运算写就是(-12288) 0xFFFF 53248。这就是twos_complement函数里q mask的由来。位运算比加一个偏移量更不容易出错因为它在二进制层面直接截断到N位有效位。3.3 导出hex/coe文件及实操示例导出文件是FPGA烧录前最后一步。我一般同时导出两种格式纯hex文件给$readmemh用coe文件给Vivado的Block Memory Generator用。纯hex格式最简单一行一个十六进制数coe格式需要带上Xilinx的头部声明。def export_hex(qbits, path): with open(path, w) as f: for value in qbits.flatten(): f.write(format(int(value), 04X) \n) def export_coe(qbits, path): size qbits.size with open(path, w) as f: f.write(memory_initialization_radix16;\n) f.write(memory_initialization_vector\n) for i, value in enumerate(qbits.flatten()): if i size - 1: f.write(format(int(value), 04X) ;\n) else: f.write(format(int(value), 04X) ,\n)04X表示输出4位十六进制对应16bit数据。如果位宽是8bit改成02X32bit改成08X注意别写错位数否则Vivado那边会报数据宽度不匹配。完整的主流程脚本长这样import torch def process_model_weights(model, output_dir./weights): model.eval() for name, param in model.named_parameters(): if param.dim() 2: continue # bias先跳过后面单独处理 w param.detach().cpu().numpy() frac choose_frac_bits(w, 16) q quantize(w, frac, 16) qbits twos_complement(q, 16) safe_name name.replace(., _) export_hex(qbits, f{output_dir}/{safe_name}.mem) export_coe(qbits, f{output_dir}/{safe_name}.coe) print(f{name}: shape{w.shape}, range[{w.min():.4f}, {w.max():.4f}], frac{frac})这份脚本跑完每个卷积核或全连接层的权值矩阵会变成两个文件.mem给仿真和$readmemh用.coe给IP核初始化用。别把bias落下。bias绝对值通常比权值大不少直接沿用权值的frac会放大误差我的习惯是bias单独用更高位宽比如32bit量化或者在FPGA端把bias放到定点累加之后再加避免一开始就污染累加器精度。导出的hex文件开头大概是这样2000 D000 1000 FC00对应前面算过的0.5、-0.75、0.25、-0.25。4. FPGA端导入ROM初始化与补码乘加逻辑4.1 两种初始化/加载权值的方式FPGA端加载权值最常见的方式有两种看你用仿真还是实际的IP核。第一种是直接用Verilog的$readmemh读hex文件适合做RTL仿真和简单的片上逻辑测试reg [15:0] weight_rom [0:4095]; initial begin $readmemh(weights.mem, weight_rom); end这种方式最灵活不需要生成IP核跑仿真时改文件路径就能换权重。但要注意文件路径在工作目录下Vivado仿真时默认是工程路径放错位置会报File not found。第二种是用Vivado的Block Memory Generator初始化时加载.coe文件。在IP配置界面里把“Memory Type”设为ROM把“Load Init File”勾选上选好.coe文件路径。这种方式适合最终上板BRAM被优化成专用的存储单元读端口可以配置成寄存输出时序更可控。要注意.coe文件里的数据个数必须和IP设置里的Memory Depth完全一致多一个逗号都会导致初始化失败。4.2 有符号乘法与位宽对齐ROM里存储的数据在Verilog里默认是无符号数。如果直接把weight_rom[addr]拿去和输入做乘法负数会变成一个很大的正数结果完全错误。必须显式声明成signed最干净的做法是用$signed做一次转换wire [15:0] w_raw; wire signed [15:0] weight $signed(w_raw);输入特征图如果也是定点补码同样转成signed类型wire signed [15:0] act; wire signed [31:0] product act * weight;两个16bit有符号数相乘结果需要32bit才能完整表示最大情况(-32768) * (-32768) 1073741824刚好落在32bit有符号范围内。如果接着做多路乘累加累加器还要再扩展比如8个32bit乘积相加最大误差范围可能是几千亿累加器至少要用到42bit保险起见我一般直接上48bit资源多花一点但省心。乘法结果怎么解释也有讲究。product是定点数小数位是两个操作数小数位之和。比如两个frac14的数相乘结果的小数位是28位想要还原成单权数量的尺度需要右移14位并截断。这个“尺度过量”是定点运算最容易乱的地方建议在FPGA逻辑里写注释标明每个节点的frac防止过几天自己都忘了中间值代表什么尺度。4.3 多Q格式管理、反量化与端到端验证如果所有层都统一frac14那FPGA端根本不用管每一层的尺度差异所有乘加结果的小数位都是28位最后统一右移14位并截断即可。这个简化非常值钱所以我推荐第一版全模型用同一个frac先把链路跑通再考虑分层优化。如果实在想逐层调frac那必须在PyTorch端导出一个格式表记录每一层的frac值。FPGA端就要按层切换反量化的移位位数或者把每层scale做成参数传入。流程复杂度会上升一个量级调试时也容易因为一层配置错而产生莫名其妙的输出误差。除非精度实测太差否则不建议第一版就做分层Q格式。端到端验证方面我实践下来最有效的是“三端对比法”同一个输入分别跑PyTorch浮点模型、PyTorch量化模拟模型、FPGA RTL仿真拿三份输出做误差统计。PyTorch量化模拟模型指的是把权值换成量化再反量化之后的数值然后前向传播q quantize(w, frac, n_bits) w_dequant q / (1 frac) # 还原成浮点近似把w_dequant替换进模型再跑一次推理就能在不上板之前先评估量化误差。误差用相对误差或绝对值差异来看通常顶到1e-2量级以下就算正常如果到了1e-1以上说明Q格式选小了得先调参再继续。5. 常见问题与调试经验实录5.1 如何快速评估量化前的精度损失很多人一上来就把整个模型量化了然后上板发现输出乱套却不知道是量化误差太大还是硬件逻辑有bug。我的习惯是先做“纯软件量化评估”只把权值和bias量化后反量化跑一遍完整前向对比原始模型的输出。这样排除掉FPGA逻辑问题只评估量化本身的损伤。具体操作用上一小节的w_dequant替换参数。如果这一步误差就很大说明位宽不够或frac定小了优先调参如果这一步误差在可接受范围那么问题大概率出在硬件侧接下来才去检查ROM初始化、补码解析和乘累加逻辑。这个排查顺序帮我省掉大量时间。5.2 常见问题速查表现象可能原因解决方案负数输出恒为0或正数Verilog里把补码当无符号数用用$signed显式声明乘法两个操作数都必须是signed量化后所有权值相同frac太小量化步长过大权值被压到相邻整点增大frac或者说缩小权值范围后再量化输出结果偏大或偏小乘累加后没有正确右移还原尺度确认每个中间节点的frac乘加结果小数位是两个操作数之和仿真结果和PyTorch不一致ROM初始化文件地址错位或位宽不匹配检查.mem文件行数是否等于ROM深度每行是否是4位hexbias导致动态范围溢出bias沿用权值Q格式数值过大溢出bias单独分配更高位宽或改用32bit定点结果完全乱码但软件量化正常乘法器位宽不够累加器溢出中间结果按最大可能范围扩位累加器上到48bit上板结果和仿真不一致ROM实际内容和仿真文件不一致检查Vivado工程里.coe路径和生成时间确认重新生成IP核5.3 进阶方向QAT和per-channel量化如果16bit定点精度不达标下一步可以考虑量化感知训练QAT。思路很简单在PyTorch训练时前向传播中插入伪量化节点让权值和激活在训练过程中就适应量化误差。实际效果是原来直接量化后精度掉到90%QAT训练几轮后能拉回到97%以上。这块可以在PyTorch里用torch.quantization来做参考官方文档能少走很多弯路。per-channel量化则是更进一步不再为整层设置同一个scale而是每个输出通道单独一个scale。对卷积来说每个卷积核分布差异很大per-channel量化能显著提升8bit量化精度。代价是FPGA端要为每个输出通道配置一个反量化scale参数乘累加完成后先做通道补偿再累加硬件逻辑会从一组统一的乘加变为带补偿的通道化结构。如果位宽在16bit以上per-channel收益不大建议先做好16bit再往8bit压。最后分享一个我在实际跑流程时的习惯先别急着优化把16bit定点、单层单通道的脚本完整跑通再逐步扩到整个模型。第一次上板时我会在PC端拿同一批输入分别跑浮点模型和量化定点模型输出做一次L1误差统计误差不超过整体张量量级的1%~2%再往下做硬件逻辑。轮到自己写转换脚本时多打印几轮hex的位模式跟手算核对一下基本就能避开绝大多数坑。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门