资讯详情

资讯详情

C++ ResNet50推理代码生成器:从模型描述到可编译实现

C 里手搓一个 resnet50第一版跑通的人基本都会经历同一个心路历程前几天觉得“不就是卷积堆叠”后几天盯着 500 多行长得一模一样的conv1x1 → bn → relu → conv3x3 → bn → relu → conv1x1 → bn → add → relu发呆改一个 stride 位置要翻十几处权重加载的偏移量算错一位整个输出就变成噪声。我第三次重构这个项目的时候彻底不想忍了干脆把“写代码”这件事本身交给程序去做用代码生成的方式从模型描述自动产出 C 推理代码。这篇文章讲的就是这套生成器的完整做法怎么把 resnet50 抽象成中间表示怎么把权重和代码拆开生成的 C 长什么样内存池怎么排性能和数值对齐怎么做以及我在这个过程中踩过、并且现在还会踩的那几个坑。适合已经能在 C 里跑通一个简单 CNN、想把手写样板代码换成自动生成的同学也适合只是好奇“代码生成到底生成什么”的读者——里面的分析思路放到任何重复结构密集的模型上都通用。1. 为什么 resnet50 的 C 版本值得写个生成器而不是手敲五十层1.1 手写五十层的真实成本重复结构、参数一致性与改一次全崩先算一笔账。resnet50 的 backbone 是 1 个 7x7 卷积 1 个 maxpool 4 个 stagestage 里的 bottleneck 数量分别是 3、4、6、3加起来 16 个 bottleneck每个 bottleneck 里 3 个卷积再加上每个 stage 第一个 block 的 downsample 分支 1 个卷积一共 16×3 3 51 个卷积层加 conv1 是 52 个再加最后的全连接54 个可学习层。每个卷积后面跟着 BN几乎每个 BN 后面跟着 ReLU。如果你按最朴素的方式手写一个 bottleneck 展开大概是十几行 C 调用16 个就是两百多行加上每层要写输入输出张量的形状、临时 buffer 的分配、权重指针的偏移整个文件轻松破千行。这些代码 90% 是复制粘贴改数字剩下 10% 是你真正需要动脑的地方——而这个比例恰好是最糟糕的比例机械劳动占比极高出错成本又极高。更让人头疼的是“改一次全崩”。我印象最深的一次是为了对齐某个参考实现把 bottleneck 里 3x3 卷积的 stride 从第一个 1x1 挪到 3x3 上这就是原论文后续版本的结构早期版本把 stride 放在第一个 1x1 上。这一个改动牵动三件事3x3 卷积的输入通道数变了、downsample 分支的 1x1 卷积也得带 stride、后面所有层的输出尺寸全部要重新推导。我手工改完之后编译过了跑出来的 top-1 精度掉了 6 个百分点查了两天才发现是 stage2 第一个 block 的 downsample 忘了改 stride。这就是生成器要解决的第一个问题结构上的重复不应该由人来复制而应该由规则来推导。你把“bottleneck 的 stride 落在 3x3 上”这条规则写进生成器一次51 个卷积就全都对了。1.2 代码生成该覆盖什么、不该覆盖什么很多人对代码生成有个误解以为要“全自动把整个推理引擎都生成出来”。我的经验是恰恰相反生成器只负责那些有规律、会变、且容易抄错的部分算子本身的数学实现必须手写。具体划分是这样的层次是否生成原因网络拓扑谁接谁、stride 多少、通道数多少生成高度规律手写必错权重偏移表、层间张量形状生成纯计算人算不如机器算逐层的调用序列指令流生成50 多层纯样板算子实现conv2d、gemm、pooling手写需要性能调优生成器写不出好的 SIMD内存池的分配策略生成 手写结合生命周期分析可自动分配器接口手写数值验证脚手架生成每层都要 dump手写太累一句话总结生成器负责“调度”手写负责“计算”。这条边界如果划错你会陷入“为了让生成器能生成优化后的卷积而写一个编译器”的深渊。1.3 整条流水线从模型描述到可编译 C 的五个阶段我把整条链路拆成五段每一段都有明确的输入输出方便单独调试图解析从训练侧导出的开放图格式文件里读出节点序列、算子属性、权重张量或者干脆用一份手写的结构描述文件我后来更偏好后者因为 resnet50 结构固定手写一份 200 行的配置比接一套图解析器省事得多。构造 IR把图变成线性的指令表每个指令带输入张量 id、输出张量 id、算子类型、属性字典、权重 id。形状推导按拓扑序把每个张量的 N/C/H/W 全部算出来这一步做不完后面全白搭。代码渲染用模板把 IR 渲染成 C 源文件同时产出一份权重清单。校验把生成的代码编译、跑一遍随机输入和参考实现逐层比对。这五段里我认为最容易低估的是第 3 段形状推导。我第一版偷懒把形状直接写在模板里当常量传结果调试时经常遇到“形状对不上但错误信息指向一个完全不相关的地方”。后来老老实实写了个推导器报错精确到“第 27 个指令 conv2d 期望输入 256 通道实际拿到 128”调试效率提升了不止一个量级。2. 把网络结构抽象成中间表示IR 到底该定义成什么样2.1 节点、张量与权重三张表我不喜欢过度设计的 IR。对 resnet50 这种前馈网络三张表就够了张量表每个张量一个 id记录名字、形状NCHW 四元组、是否需要持久化只有输入、输出和跳连的源需要。指令表有序列表每条指令包含op算子类型、inputs张量 id 列表、outputs、attrs如 stride、padding、groups、weights权重 id 列表。权重表每个权重一个 id记录名字、形状、在二进制包里的字节偏移、字节长度。用 Python 的字典或 dataclass 描述就行别上什么自定义 DSL除非你打算支持几十个模型。下面是我实际用的结构from dataclasses import dataclass, field dataclass class Tensor: tid: int name: str shape: tuple # (N, C, H, W) persist: bool False dataclass class Instr: idx: int op: str inputs: list outputs: list attrs: dict field(default_factorydict) weights: list field(default_factorylist)这里有个设计决定值得说张量 id 用连续整数而不是字符串。一开始我用名字当 key写起来直观但生成 C 的时候要做“这个 buffer 能不能复用”的分析字符串比较慢不说还容易因为命名不规范导致两个不同的张量重名。换成整数 id 后生命周期分析就是一次简单的区间扫描。2.2 bottleneck 在 IR 里怎么表达才不丢信息resnet50 的 bottleneck 有一个特别容易表达错的地方它同时存在“主干分支”和“残差分支”而残差分支在第一个 block 里不是一个简单的恒等映射而是一个带 stride 的 1x1 卷积。更坑的是主干里的 stride 位置在不同版本里不一样。我在生成器里做了个明确的约定并且写进注释避免自己半年后回来看不懂def emit_bottleneck(ir, x, in_ch, mid_ch, out_ch, stride, tag): # 主干: 1x1 降维(stride1) - 3x3(stridestride) - 1x1 升维(stride1) h ir.conv(x, in_ch, mid_ch, k1, s1, p0, namef{tag}.c1) h ir.bn_relu(h, namef{tag}.bn1) h ir.conv(h, mid_ch, mid_ch, k3, sstride, p1, namef{tag}.c2) h ir.bn_relu(h, namef{tag}.bn2) h ir.conv(h, mid_ch, out_ch, k1, s1, p0, namef{tag}.c3) h ir.bn(h, namef{tag}.bn3) # 残差: 通道不匹配或 stride ! 1 时走 1x1 投影且这个 1x1 也要带 stride if stride ! 1 or in_ch ! out_ch: s ir.conv(x, in_ch, out_ch, k1, sstride, p0, namef{tag}.ds) s ir.bn(s, namef{tag}.ds_bn) else: s x y ir.add(h, s, namef{tag}.add) return ir.relu(y, namef{tag}.relu)这段代码里两个stride是重点主干 3x3 带 stride残差投影的 1x1 也带 stride两者必须一致否则 add 的两路形状对不上。我第一次写生成器的时候忘了给残差分支传 stride生成出来的 C 能编译、能跑但 stage2 的 add 算子直接越界读了半张错误尺寸的 buffer程序在第三个 block 崩了排查花了大半天。提示如果你想让生成器同时支持“stride 在第一个 1x1 上”的变体别写成两个 if 分支而是在 bottleneck 函数里加一个stride_pos参数默认c2。这样两种结构共用一套代码切换只改一个字符串。2.3 形状推导器把每个张量的 NCHW 提前算死形状推导的公式本身小学水平out_h floor((in_h 2*pad - kernel) / stride) 1但魔鬼在细节。resnet50 里有三种算子需要小心7x7 convstride 2pad 3输入 224输出 (224 6 - 7)/2 1 112正确。3x3 maxpoolstride 2pad 1输入 112输出 (112 2 - 3)/2 1 56正确。注意这里是floor而不是ceil不同框架对 maxpool 的默认行为有差异ceil_mode打开后 112 会变成 56 还是 57 需要实测确认。全局平均池化输出固定 1x1但输入尺寸必须是 7x7224 输入的 resnet50 走到最后一层刚好 7x7。我建议把形状推导做成一个“要么全对要么立刻抛错”的严格模式。任何一步出现负的或非整数结果直接报错并打印出当前指令的完整上下文。这一步严格了后面生成代码时基本不会再遇到形状相关的 bug。顺便说一个实测下来的经验把推导出来的形状表打印成一份文本文件跟生成的 C 一起提交。调试的时候两眼一抹就知道第 N 层应该是多大比在 GDB 里翻 buffer 强太多。3. 权重和代码分家二进制权重包怎么排、怎么对齐、怎么加载3.1 BN 折叠生成阶段就把它做掉别留到运行时BN 层在推理时是个纯线性变换y gamma * (x - mean) / sqrt(var eps) beta。既然它的参数是固定的就完全可以在生成阶段折叠进前面的卷积scale gamma / sqrt(var eps) w w * scale # 按输出通道广播 b beta - mean * scale折叠之后原来的conv → bn → relu变成conv带偏置→ relu少一次逐元素乘加和一次额外的内存往返。对于 resnet50 这种 52 个卷积全带 BN 的模型这个优化几乎是白送的。我踩过的坑在于广播方向。卷积权重形状是[out_ch, in_ch, kh, kw]scale是长度out_ch的向量要广播到第 0 维不是最后一维。第一次我按最后一维乘了编译能过、形状也对因为都是 256但精度直接从 76% 掉到 0.1%看起来像“权重没加载对”实际是广播轴写错了。这种错误用形状检查抓不到只能靠数值比对。还有eps。有些实现的默认值是1e-5有些是1e-3差两个数量级。折叠时用错输出会有肉眼可见的整体偏移。我的做法是把eps写进权重包的元数据加载时校验一遍版本号不匹配就拒绝加载。3.2 64 字节对齐与偏移表权重包我用的是最土也最稳的格式一个二进制文件所有张量按顺序紧凑排布外加一个 JSON 清单记录每个张量的名字、形状、偏移、长度、dtype。排布时有两个细节必须处理对齐。每个张量的起始偏移向上对齐到 64 字节一条 cache line。不对齐的话某些平台的向量化加载会因为跨 cache line 而性能下降甚至在某些指令集上直接触发对齐异常。填充用的字节我统一写 0方便排查。dtype 与端序。默认全 fp32小端序。如果后面要做 int8 量化可以在清单里加一个quant字段存 scale 和 zero_point代码生成时据此插入反量化指令。清单大概长这样{ version: 3, dtype: fp32, endian: little, bn_folded: true, tensors: [ {name: conv1.weight, shape: [64, 3, 7, 7], offset: 0, bytes: 37632}, {name: conv1.bias, shape: [64], offset: 37632, bytes: 256}, {name: layer1.0.c1.weight, shape: [64, 64, 1, 1], offset: 37888, bytes: 16384} ] }生成 C 的时候把这些偏移直接编成常量数组加载时一次mmap或一次fread进一块连续内存后面所有层都靠指针偏移访问运行时零解析开销。注意偏移一定要用生成器算不要手写。我见过有人手写偏移表改了一个层的通道数之后忘了同步后面所有偏移程序跑起来不崩不报错只是精度诡异地下滑——因为读到的权重整体错位了几百字节。这种 bug 的排查难度是顶级的。3.3 生成代码里的权重加载器生成的加载器我倾向于做成“一次读入 指针数组”struct WeightRef { const void* ptr; size_t bytes; }; static bool load_weights(const char* path, std::vectoruint8_t blob, const WeightDesc* descs, int n, WeightRef* out) { FILE* f fopen(path, rb); if (!f) return false; fseek(f, 0, SEEK_END); long sz ftell(f); fseek(f, 0, SEEK_SET); blob.resize(sz); size_t rd fread(blob.data(), 1, sz, f); fclose(f); if (rd ! (size_t)sz) return false; for (int i 0; i n; i) { if (descs[i].offset descs[i].bytes (size_t)sz) return false; out[i].ptr blob.data() descs[i].offset; out[i].bytes descs[i].bytes; } return true; }descs数组是生成器产出的WeightRef在后续指令流里按索引取用。这里刻意没做任何类型转换和拷贝权重 blob 一直是只读的多线程推理时不需要加锁。4. 生成的 C 到底长什么样算子层、指令流层、内存池4.1 算子层接口要窄属性要全算子层的接口设计原则只有一条所有会变的参数都从指令属性里来算子函数本身不带任何 resnet 相关的假设。struct ConvAttrs { int in_c, out_c, kh, kw; int stride_h, stride_w; int pad_h, pad_w; int groups; bool has_bias; bool relu_fused; }; void conv2d_nchw(const float* in, int in_h, int in_w, const float* w, const float* b, float* out, int out_h, int out_w, const ConvAttrs a);这样写的直接好处是生成器只要填ConvAttrs就行算子内部完全不需要知道“我是第几个 block”。我第一版把relu_fused做成了独立算子生成出来的指令流里每个卷积后面都紧跟一条 relu多一次完整的内存读写后来改成融合标志2048 通道的 stage4 上单层就省了大约 8% 的时间。算子层要手写的部分包括im2col或者直接写 naive 循环、gemm、maxpool、avgpool_global、add、relu、fc。这些都不难难在调优但那属于另一个话题。4.2 指令流层一张静态表驱动整个网络生成器最核心的产出就是这一张表。每条指令是一个聚合体包含算子类型、输入输出 buffer 索引、属性、权重索引enum class Op : uint16_t { Conv, MaxPool, AvgPoolGlobal, Add, Relu, Fc }; struct Instr { Op op; int in0, in1; // 输入 buffer 索引-1 表示不用 int out; // 输出 buffer 索引 int w_idx; // 权重索引-1 表示无 ConvAttrs attrs; }; static const Instr kProgram[] { {Op::Conv, 0, -1, 1, 0, {3,64,7,7, 2,2, 3,3, 1, true, true }}, {Op::MaxPool,1, -1, 2, -1, {64,64,3,3, 2,2, 1,1, 1, false, false}}, {Op::Conv, 2, -1, 3, 1, {64,64,1,1, 1,1, 0,0, 1, false, true }}, // ... 后面 50 多层全是生成器吐出来的 };驱动代码就是一个 for 循环加一个 switch。这么写看起来“不够面向对象”但实测有两个好处一是整个程序的数据都在静态区启动时没有构造开销二是调试时可以直接在第 N 条指令上打断点kProgram[N]一眼就能对上模型里的层名我会在注释里带上层的名字生成器顺手就写了。我做过对比手写的、每层一个函数调用的版本和这个表驱动版本在单线程下性能差异不到 2%但代码量从 1200 行降到 300 行加一张生成的表。4.3 内存池靠生命周期分析把峰值压下来如果老老实实给每个张量分配一块 bufferresnet50 在 224 输入下的峰值内存大概是所有中间张量之和粗算一下 stage4 每个 block 有 2048×7×7 的特征图16 个 block 全存下来就是 16×2048×49×4 字节 ≈ 6.4MB看着不多但前面 stage1、stage2 的张量尺寸更大56×56、28×28加起来能到几十 MB。实际推理时绝大多数中间张量用完就死。做法是做一次生命周期分析按指令顺序扫描记录每个 buffer 的“首次写入指令索引”和“最后一次被读指令索引”然后贪心地把不重叠的区间塞进同一块内存。def plan_arena(instrs, tensor_shapes, align64): intervals [] # (start, end, tid, nbytes) for i, ins in enumerate(instrs): for t in ins.inputs: for rec in intervals: if rec[2] t: rec[1] i for t in ins.outputs: n prod(tensor_shapes[t]) * 4 intervals.append([i, i, t, align_up(n, align)]) # 按起始排序后贪心复用 ...这个分析做对了resnet50 的峰值能压到单张最大特征图的几倍左右。做错的表现很难看某次我漏掉了 add 算子的两个输入把残差分支的源张量提前回收了结果 add 读到的是下一层已经覆盖的数据。程序不崩输出全错。提示arena 分析一定要加断言。在调试构建里每次向 buffer 写入前检查“这块内存的当前属主就是这条指令的 output”能提前抓出绝大多数生命周期 bug。5. 生成器顺手能做的几种融合与布局选择5.1 conv BN ReLU 的三合一已经在生成阶段完成第 3 章讲了 BN 折叠这里补一句 ReLU 融合。融合后卷积在写完每个输出元素后立刻做一次max(0, x)省掉一整趟读 写。对于 stage4 那种 2048 通道、7×7 的张量一趟读写是 2048×49×4×2 ≈ 800KB 的流量看起来不大但 16 个 block 累加就是 12MB 的内存流量在 L2 只有几 MB 的机器上这笔开销非常实在。我在生成器里加了一个开关fuse_reluTrue它会检查每个 conv 后面是否紧跟着 relu是就打上融合标志、并把 relu 指令从指令流里删掉。超过 90% 的 relu 都能被吃掉。5.2 im2col gemm 还是直接卷积用数据说话生成器不决定算子内部实现但可以决定“用哪个算子变体”。我实测过三种方案在同一台机器上的表现方案1x1 卷积3x3 卷积内存开销实现复杂度直接七层循环慢慢低极低im2col gemm不适用直接退化成 gemm中等高C×9×H×W中Winograd F(2x2,3x3)不适用快中高结论是按卷积核大小分流1x1 卷积直接走 gemm本质上就是矩阵乘3x3 卷积走 im2col gemm等有精力了再给 3x3 换成 Winograd。生成器在发射指令时根据kh kw 1自动选择算子变体这个判断写死在生成器里比运行时判断更干净。关于内存布局我试过把中间张量全部转成 NHWC理由是通道维连续对向量化友好。结果是卷积计算确实快了但 1x1 卷积和全连接层的权重都要重排而且 add 算子要做跨通道的连续访问改起来牵一发动全身。最终我放弃了全局 NHWC只在 conv 算子内部用分块tiling来改善局部性对外仍然保持 NCHW。5.3 多线程切分切输出通道别切 batch推理时 batch 通常是 1按 batch 切线程等于没切。按输出通道切是最自然的每个线程负责一部分输出通道各自写自己的那块输出完全没有写冲突也不需要同步。int nthreads std::min(8, a.out_c); #pragma omp parallel for num_threads(nthreads) for (int oc 0; oc a.out_c; oc) { // 每个线程独立算一个输出通道 }用 OpenMP 是最省事的生成器只要在循环前插一行 pragma。如果想要更细的控制可以自己写个线程池但对 resnet50 这种规模OpenMP 的动态调度已经够用。有一个细节要注意权重是按输出通道排布的多个线程读同一块权重时cache 争用会很凶。我的做法是把输出通道按线程数均分让每个线程读写的是物理上相邻的一块区域实测比“线程 i 处理 oc % nthreads i”这种交叉切分快 5% 左右。6. 数值对齐与排错那 0.3 的误差到底从哪来6.1 先搭验证脚手架再谈优化我的规矩是生成的代码第一次跑通第一件事不是测速度而是逐层比对。具体做法是在 C 侧加一个 hook每条指令执行完之后如果该指令的 output 在“关注列表”里就把它 dump 成一个二进制文件命名带上指令索引和层名。然后在 Python 侧加载同样的输入跑参考实现读这些 dump 文件逐个算最大绝对误差和余弦相似度。import numpy as np def compare(dump_path, ref_arr): got np.fromfile(dump_path, dtypenp.float32).reshape(ref_arr.shape) diff np.abs(got - ref_arr) print(fmax{diff.max():.3e} mean{diff.mean():.3e} fcos{np.dot(got.ravel(), ref_arr.ravel()) / (np.linalg.norm(got) * np.linalg.norm(ref_arr)):.6f})这个脚手架看起来笨但它是唯一能把“整个网络输出不对”定位到“第 27 层 add 的两个输入形状不一致”的手段。没有它你只能盯着最终的 top-1 精度猜。6.2 三个最常翻车的地方按我遇到的频率排序第一位padding 的边界处理。7x7 卷积 pad3输入 224 的情况下边界刚好对称。但如果你实现 im2col 的时候把 pad 区域的索引算错一位比如把-1映射成0而不是“补零”边界上会混进真实像素值。表现是整体输出看起来正常但精度掉 1 到 2 个点。这种错误在单层比对时误差不大非常难抓。我的做法是在 im2col 里加一个assert检查 pad 区域的索引确实是负的或者越界的。第二位maxpool 的 ceil_mode。112 输入、3x3 核、stride 2、pad 1按floor算输出 56按ceil算可能出 57。如果你的参考实现用的是ceil而你生成了 56后面的 add 会直接形状不匹配反过来如果两边都是 56 但窗口覆盖范围差一格误差会悄悄累积。我在生成器里把ceil_mode做成必须显式声明的属性不给默认值逼自己想清楚。第三位全连接层前的 flatten 顺序。全局平均池化之后是 2048 维向量这一步没什么歧义。但如果你的实现里 avgpool 输出的布局和 fc 权重的期望布局不一致比如一个是 NCHW 一个是 NHWC 拉平结果会全错。这个反而好查因为错得太离谱。6.3 误差预算fp32 下多少算正常很多人第一次做逐层比对看到max3e-6就慌了以为哪里写错了。实际上 fp32 累加本身就有误差卷积的累加长度等于in_c × kh × kw对 resnet50 的 stage4 来说3x3 卷积的累加长度是 512×9 4608 项每项都有舍入最终误差在1e-6到1e-5量级完全正常而且会逐层放大。我给自己定的红线是层类型可接受 max abs diff (fp32)单层 conv累加长度 1000 1e-5单层 conv累加长度 4000 5e-5整个 stage 输出 2e-4最终 logits 1e-3top-1 预测类别必须完全一致如果某一层的误差突然比前一层大一个数量级那就不是舍入问题了一定是实现 bug。这个“跳变检测”比绝对阈值更有用。注意对比时一定要用固定的随机种子生成输入并且把输入也 dump 下来。我曾经因为 Python 侧和 C 侧用了不同的随机数生成器输入本身就差了白查了一下午。7. 生成器本身的维护模板怎么组织、回归怎么跑、怎么扩展到别的模型7.1 模板别做太聪明我最初用模板引擎的时候恨不得把所有逻辑都塞进模板里结果模板变成了一堆嵌套的 if 和循环调试体验极差——报错行号指向模板文件你根本不知道是哪条指令触发的。后来的原则是模板只做“填空”所有判断都在 Python 里做完。比如“这条指令要不要带 bias”不是模板里写{% if has_bias %}而是 Python 侧直接算出bias_expr这个字符串模板里就是{{ bias_expr }}。模板文件因此变得非常扁平基本就是一个大括号加一堆占位符。改起来舒服出错也好定位。7.2 回归测试的三层结构生成器本身是代码代码就会改改了就得保证生成的 C 还是对的。我搭了三层回归第一层是IR 快照。每次改生成器把当前的 IR 序列化成 JSON 存一份和基准版本做 diff。如果 diff 只出现在预期的地方比如加了个新属性说明改动可控如果整片整片地变那就是不小心动了结构逻辑。第二层是编译 冒烟。自动跑一遍生成、编译、用固定输入推理检查退出码和输出文件是否存在。这一层能在 30 秒内抓出语法错误和链接错误。第三层是数值回归。用固定的输入和基准输出我存了一份参考实现的 logits比对最大误差。这一层跑得慢大概要几分钟但它是唯一能抓住“代码能编译、能跑、但算错了”这个类别的。三层都过了我才敢提交。这套流程听起来重但比起上线之后发现精度掉了再去回溯成本低太多了。7.3 扩展到别的模型时哪些能复用这套生成器的结构其实不绑定 resnet50。真正跟 resnet50 绑定的只有“bottleneck 的发射函数”这一小块其余部分IR 定义、形状推导、生命周期分析、权重打包、代码渲染、验证脚手架都是通用的。往别的模型上扩的时候我一般这么做先把新模型的重复单元抽出来写一个类似emit_bottleneck的函数然后把算子库里缺的算子补上比如 depthwise conv、group conv、attention 里的 matmul softmax最后跑一遍三层回归。真正耗时的是补算子尤其是那些不常见的激活函数和归一化结构部分反而很快。有个经验可以分享算子库的接口别改。我中途曾经想给conv2d加一个dilation参数结果所有已生成的模型都要重新生成、重新验证。后来我把这种“可能用到但当前不用”的参数一律塞进ConvAttrs的末尾默认值给 0 或 1这样加参数不破坏已有调用。这个习惯在后面加分组卷积的时候救了我一次。还有一点是我个人的偏好生成器产出的 C 代码我坚持让它可读。层名写进注释、指令表按 stage 分组、每 8 条指令空一行。有人觉得生成的代码不用给人看但我调试的时候 80% 的时间都在读这些生成的代码可读性直接决定排查效率。让机器写出人能读的代码这件事本身不难只是需要你在模板里多写几行空行和注释。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →