多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

手撕Conv2D:从朴素循环到im2col与反向传播的底层实现

手撕Conv2D:从朴素循环到im2col与反向传播的底层实现 手撕代码系列我计划写成一个偏底层的专题第一篇选Conv2D是因为它太典型了代码量不大却能把索引计算、内存布局、参数联动、反向传播全部串起来。上周帮一个朋友模拟算法岗面试让他手撕Conv2D他第一反应是这不是PyTorch一行代码的事吗我说行那咱们不用PyTorch。结果他在白板上绕了十分钟最后输出尺寸公式都没写对。这个场景在面试里太常见了。会调API是一回事能从头实现是另一回事。这篇博文会从一个最简单的四重循环版本开始到im2col矩阵化再到反向传播最后附上我踩过的坑和面试追问清单。适合正在准备算法岗面试的人、刚入门想搞懂卷积到底在算什么的人以及想在工程里自己实现一个卷积内核的读者。1. 为什么面试官爱让你手撕Conv2D以及它到底在撕什么1.1 一个反直觉的面试现象我见过不少候选人简历上写着熟悉CNN、精通PyTorch但让他写一个最朴素的二维卷积第一版代码往往是这样的循环套循环但内层索引全是i和j卷积核的kh和kw不知道往哪放多输入通道的C_in直接消失。你看他用PyTorch时nn.Conv2d的in_channels和out_channels填得一清二楚但一旦落到裸循环脑子里对张量形状的建模就崩了。说实话手撕Conv2D并不是在考你背没背过API而是在考你有没有真正理解卷积到底在做什么。面试官要看到的不是一版看起来像卷积的代码而是你对输入输出形状的推导、对内存访问顺序的把握、对计算复杂度的判断。这些东西只在调API的时候是永远学不到的。1.2 手撕代码到底在考察什么我自己复盘了一下一次完整的手撕Conv2D至少覆盖这四个能力点张量形状建模能立刻写出输入(N, C_in, H, W)、权重(C_out, C_in, K_h, K_w)、输出(N, C_out, H_out, W_out)并且清楚每个轴的含义。输出尺寸计算在stride、padding、dilation同时存在时能写出H_out (H 2*padding - dilation*(K_h-1) - 1) // stride 1并且理解为什么是向下取整。索引映射输出位置(i, j)对应的输入窗口起点是i*stride窗口内偏移是kh*dilation。这一步只要错一个符号整个卷积结果就全错了。复杂度意识能说出朴素实现的计算量是O(N * C_out * H_out * W_out * C_in * K_h * K_w)并且理解为什么这个复杂度下还要想办法转成矩阵乘。这四条恰好就是面试官想快速验证的能力也是你日后做推理优化、写算子、排查性能瓶颈的基础。所以你会发现在很多大厂的面试题库里手撕Conv2D几乎成了保留节目。1.3 适合谁来看这篇这篇文章默认你有Python和NumPy基础会用PyTorch的conv2d但不清楚它底层怎么算。如果你是纯小白建议先把张量形状和基础线性代数过一遍再来看如果你已经能写出一版朴素实现可以直接跳到第4章的im2col和第6章的反向传播那两段更有含金量。2. 先把数学约定说清楚深度学习里的Conv2D其实是互相关2.1 卷积核、输入与输出张量的物理含义先说一个很多中文博客都没讲明白的点严格数学意义上的卷积在做滑窗之前要把卷积核翻转 180 度也就是沿两个空间轴各翻转一次。但深度学习框架里的conv2d实际上实现的是互相关cross-correlation它不翻转卷积核只是把核放在输入上做逐元素乘加。为什么框架不按严格卷积来因为从学习角度卷积核参数本来就是随机初始化再训练出来的旋不旋转对网络能力没有本质影响不旋转反而少一步操作实现也更简单。所以在手撕的时候你按不翻转核、直接点积来写就是对的。面试如果被追问能说出这个区别反而是加分项。具体到张量形状输入x(N, C_in, H, W)N是batch大小C_in是输入通道数H和W是空间尺寸。卷积核weight(C_out, C_in, K_h, K_w)C_out是输出通道数也就是说一组卷积核对应一个输出通道。偏置bias(C_out,)每个输出通道一个标量偏置。关键约束是weight的第1维必须等于x的第1维即卷积核的输入通道数必须和输入张量的通道数一致。如果输入是3通道的彩色图那一个输出通道对应的卷积核也是3通道的它会在3个通道上分别做滑窗点积再把结果加起来最后加上该通道的偏置。2.2 输出尺寸公式从padding到dilation输出形状不能拍脑袋公式是唯一的H_out (H 2 * padding - dilation * (K_h - 1) - 1) // stride 1 W_out (W 2 * padding - dilation * (K_w - 1) - 1) // stride 1这里dilation * (K_h - 1) 1叫有效核尺寸。空洞卷积dilated convolution虽然卷积核里的参数个数还是K_h * K_w但每个参数之间隔着dilation - 1个空位所以它实际覆盖的输入区域更大。我用一个具体例子帮你验算输入宽W5卷积核K_w3stride2padding1dilation1。有效核尺寸就是3代入公式W_out (5 2*1 - 3) // 2 1 4 // 2 1 3输出是3。如果把dilation改成2有效核尺寸变成2*(3-1)15那输出就是(52-5)//212可见空洞卷积会明显压缩输出尺寸。这个公式怎么记我的经验是别死记画图。你只需要知道滑窗从0开始能滑到的最远起点是H 2*padding - eff_kernel除以步长向下取整再加1就是窗口个数。起点必须保证窗口不越界所以是floor而不是ceil。2.3 步长、填充、空洞、分组四个参数的联动关系面试里经常把四个参数揉在一起问你要能说清楚它们的联动关系stride控制窗口移动步长直接影响输出尺寸stride越小特征图越大计算量越大。padding决定在输入四周补多少圈0它不增加参数但能保留边缘信息也是输出尺寸不变的常用手段。dilation控制窗口内采样点的间隔在不增加卷积核参数量的前提下扩大感受野。groups控制通道分组。当groups1时就是普通卷积每个输出通道和所有输入通道做计算当groupsC_in时就是深度可分离卷积里的Depthwise卷积每个输入通道只对应一个输出通道分组参数量大幅下降。手撕普通Conv2D可以不实现groups但你要知道如果groups1输入通道会被分成groups组每组分别做独立卷积最后再拼接输出通道。分组卷积本质上是分块后的普通卷积理解了普通卷积分组也就理解了。3. 最朴素的四重循环实现正确性优先3.1 接口设计我先定一个清晰的函数签名后面所有版本都复用同一套接口import numpy as np def conv2d_naive(x, weight, bias, stride1, padding0, dilation1): 手撕 Conv2D 最朴素版本 x: (N, C_in, H, W) weight: (C_out, C_in, K_h, K_w) bias: (C_out,) 或 None pass接口定好之后实现的顺序我建议固定为先算输出尺寸再处理padding然后才是循环。很多新手一上来就写循环写到一半发现输出尺寸不确定就乱了。3.2 核心实现完整实现如下import numpy as np def conv2d_naive(x, weight, bias, stride1, padding0, dilation1): N, C_in, H, W x.shape C_out, C_in_w, K_h, K_w weight.shape assert C_in_w C_in, weight和x的输入通道数必须一致 if bias is None: bias np.zeros(C_out, dtypex.dtype) # 有效核尺寸 eff_kh (K_h - 1) * dilation 1 eff_kw (K_w - 1) * dilation 1 # 输出尺寸 H_out (H 2 * padding - eff_kh) // stride 1 W_out (W 2 * padding - eff_kw) // stride 1 if H_out 0 or W_out 0: raise ValueError(输入尺寸过小输出尺寸非正) # 先padding再进入循环 if padding 0: x_pad np.pad( x, ((0, 0), (0, 0), (padding, padding), (padding, padding)), modeconstant, ) else: x_pad x out np.zeros((N, C_out, H_out, W_out), dtypex.dtype) for n in range(N): # batch for co in range(C_out): # 输出通道 for i in range(H_out): for j in range(W_out): acc bias[co] for ci in range(C_in): # 输入通道 for kh in range(K_h): # 卷积核高方向 for kw in range(K_w): # 卷积核宽方向 ih i * stride kh * dilation iw j * stride kw * dilation acc x_pad[n, ci, ih, iw] * weight[co, ci, kh, kw] out[n, co, i, j] acc return out我要提醒你几个细节。第一ih和iw的计算是唯一的重点i*stride是输出位置对应的窗口起点kh*dilation是核内偏移两者相加才是真正访问的输入坐标。第二先padding还是先循环决定了你要不要写边界判断先pad的话所有坐标都是合法坐标代码更简洁。第三循环顺序我建议把输出通道放在batch之后这样对每个输出位置一次累加完所有输入通道和核位置逻辑最清晰。3.3 复杂度分析与缓存友好性这个版本的计算复杂度是O(N * C_out * H_out * W_out * C_in * K_h * K_w)你拿一个中等规模例子算一下就知道它有多慢输入(8, 64, 32, 32)卷积核(128, 64, 3, 3)stride1padding1。输出尺寸也是32x32内层乘加次数是8 * 128 * 32 * 32 * 64 * 3 * 3约15亿次。纯Python嵌套循环跑这个量级是几十秒级别的噩梦。所以这个朴素版本只能作为正确性参考绝对不能用于实际训练。但就算在朴素版本里循环顺序也会影响性能。Python的NumPy底层是C实现的但你这层Python循环是解释执行的访问x_pad[n, ci, ih, iw]时每次都要做一次多维索引。理论上把kh、kw放到最内层可以让窗口内数据尽量相邻对CPU缓存更友好但在纯Python下的差异其实没有那么明显真正拉开差距的是第4章的im2col。3.4 用一个2x2小例子手动验算为了确认代码逻辑没错我建议你写代码之前先手动算一个例子。输入是一个3x3矩阵x [[1, 2, 3], [4, 5, 6], [7, 8, 9]]卷积核是2x2w [[1, 0], [0, -1]]stride1padding0bias0那么输出是2x2。手动算第一个位置(0,0)窗口覆盖[[1,2],[4,5]]乘上核就是1*1 2*0 4*0 5*(-1) -4。第二个位置(0,1)窗口[[2,3],[5,6]]结果是2*1 6*(-1) -4。楼梯式往下你能得到一个2x2的输出矩阵。写代码时用这个例子去验能很快抓到索引错误。4. im2col矩阵化让循环卷积的速度逼近BLAS4.1 卷积的两种视角滑窗与矩阵乘既然嵌套循环慢那工业实现里怎么提速最核心的思路是把卷积转换成矩阵乘法这就是著名的**im2colimage to column**技巧。你从另一个角度看卷积输出上的每个像素本质是卷积核展平后的向量和输入上对应窗口展平后的向量做点积。把所有窗口都抽出来排成矩阵卷积就变成了一个矩阵乘问题权重展平成矩阵(C_out, C_in*K_h*K_w)输入窗口展平成矩阵(C_in*K_h*K_w, N*H_out*W_out)两者相乘得到(C_out, N*H_out*W_out)为什么转成矩阵乘就快因为矩阵乘可以用BLAS库优化底层有SIMD指令、多线程、内存分块效率远高于手动循环。GPU上著名的cuDNN用的也是类似思路不过它更激进用隐式GEMM不真的把窗口数据复制出来而是让矩阵乘的kernel直接按卷积索引读取。4.2 用NumPy提取滑动窗口提取窗口最快的方式是用numpy.lib.stride_tricks.as_strided。它允许我们通过修改stride来构造一个跨步视图不复制数据。先看一个简单的例子。假设x_pad的形状是(N, C_in, H_pad, W_pad)它的四个轴的stride是s0, s1, s2, s3。我要构造一个窗口视图形状是(N, C_in, H_out, W_out, K_h, K_w)意思是第2和第3维是输出位置第4和第5维是窗口内偏移。对应的stride应该是batch和通道维保持不变s0, s1输出位置i移动1个位置在输入上移动stride个像素所以stride是s2 * stride输出位置j移动1个位置对应s3 * stride核内偏移kh移动1对应s2 * dilation核内偏移kw移动1对应s3 * dilation代码如下from numpy.lib.stride_tricks import as_strided def conv2d_im2col(x, weight, bias, stride1, padding0, dilation1): N, C_in, H, W x.shape C_out, C_in_w, K_h, K_w weight.shape assert C_in_w C_in if bias is None: bias np.zeros(C_out, dtypex.dtype) eff_kh (K_h - 1) * dilation 1 eff_kw (K_w - 1) * dilation 1 H_out (H 2 * padding - eff_kh) // stride 1 W_out (W 2 * padding - eff_kw) // stride 1 if H_out 0 or W_out 0: raise ValueError(输入尺寸过小输出尺寸非正) if padding 0: x_pad np.pad( x, ((0, 0), (0, 0), (padding, padding), (padding, padding)), modeconstant, ) else: x_pad x s0, s1, s2, s3 x_pad.strides # 窗口视图 patches as_strided( x_pad, shape(N, C_in, H_out, W_out, K_h, K_w), strides(s0, s1, s2 * stride, s3 * stride, s2 * dilation, s3 * dilation), writeableFalse, ) # 调整轴顺序把通道、核高、核宽合并成一维 patches patches.transpose(0, 1, 4, 5, 2, 3) # (N, C_in, K_h, K_w, H_out, W_out) cols patches.reshape(N, C_in * K_h * K_w, H_out * W_out) # (N, K, L) # 权重展平 w_mat weight.reshape(C_out, C_in * K_h * K_w) # 矩阵乘: (C_out, K) (N, K, L) - (C_out, N, L) out w_mat cols out out.transpose(1, 0, 2) # (N, C_out, L) out out.reshape(N, C_out, H_out, W_out) # 还原空间维度 out bias.reshape(1, C_out, 1, 1) return out这里有一个必须注意的点as_strided得到的patches内存布局是(N, C_in, H_out, W_out, K_h, K_w)但我想把C_in、K_h、K_w合并成一个通道维直接把patches.reshape(N, C_in*K_h*K_w, H_out*W_out)是错的因为在C连续内存中K_h和K_w在H_out和W_out之后不能直接跨维度合并。所以要先transpose把K_h, K_w挪到C_in旁边再reshape。这一步是我刚开始写时最容易翻车的地方。4.3 im2col的空间代价im2col的本质是空间换时间。原来输入是(N, C_in, H, W)现在窗口矩阵cols的形状是(N, C_in*K_h*K_w, H_out*W_out)。举个例子输入(1, 64, 32, 32)3x3卷积stride1padding1输出(1, 64, 32, 32)。窗口矩阵的大小是1 * (64*3*3) * (32*32)也就是576 * 1024个float32约2.25MB。而原始输入只有64*32*3265536个float32约256KB。也就是说im2col把数据放大了9倍因为3x3卷积核的每个位置都被复制了一遍这还只计算了单batch下的cols矩阵如果算上权重和中间结果内存开销更大。所以工程上做推理优化时往往不会真的把im2col的完整矩阵落内存而是采用分块im2col每次只对一部分输出位置做窗口变换把计算和内存占用折中。面试能说出这一点说明你不仅会写还懂工程权衡。4.4 为什么说逼近BLASim2col的瓶颈已经不在卷积逻辑而在矩阵乘本身。w_mat cols这一步只要矩阵规模够大NumPy会调用BLAS库在CPU上自动利用多核和SIMD指令。这也是为什么很多朴素的Python深度学习框架为了性能会把卷积拆成GEMM。我自己实测下来同一个任务用第3章的朴素循环跑可能要几十秒im2col版本通常在几百毫秒内完成差距是百倍级别。而PyTorch在CPU上可能只要几十毫秒它的优势在于算子融合、内存复用和更好的调度但底层计算逻辑跟我们写的基本一致。5. 与PyTorch逐一比对验形状、验数值、验边界5.1 搭一个可复用的测试骨架手撕完代码第一件事就是跟PyTorch对比。我习惯写一个轻量的测试函数随机初始化输入和权重比较输出是否一致import torch import torch.nn.functional as F import numpy as np def check_conv2d(x, weight, bias, stride, padding, dilation): # 自定义实现 out_mine conv2d_im2col(x, weight, bias, stridestride, paddingpadding, dilationdilation) # PyTorch实现 x_t torch.from_numpy(x) w_t torch.from_numpy(weight) b_t torch.from_numpy(bias) out_t F.conv2d(x_t, w_t, b_t, stridestride, paddingpadding, dilationdilation).numpy() np.testing.assert_allclose(out_mine, out_t, atol1e-5) print(shape:, out_mine.shape, | pass) # 测试1单batch单通道小矩阵 x np.random.randn(1, 1, 5, 5).astype(np.float32) w np.random.randn(1, 1, 3, 3).astype(np.float32) b np.random.randn(1).astype(np.float32) check_conv2d(x, w, b, stride1, padding0, dilation1)如果这一版能通过说明核心滑窗逻辑是对的。接下来就要测复杂参数。5.2 复杂参数组合验证我自己会一次性测好几组参数特别是stride和padding同时变化的情况x np.random.randn(2, 3, 8, 8).astype(np.float32) w np.random.randn(4, 3, 3, 3).astype(np.float32) b np.random.randn(4).astype(np.float32) for stride in [1, 2]: for padding in [0, 1]: for dilation in [1, 2]: check_conv2d(x, w, b, stride, padding, dilation)跑这个循环时最需要注意的是dilation2时会要求有效核尺寸变大如果padding太小输出尺寸会骤降甚至为负。比如8x8输入3x3核dilation2有效核尺寸是5如果stride2、padding0那么H_out(8-5)//212输出只有2x2这没问题但如果把输入改成5x5、stride2、padding0H_out(5-5)//211也还行不过再小就危险了所以我在实现里加了H_out0的报错。还有一个容易被忽略的地方NumPy的as_strided不允许数据越界。如果输出尺寸公式算出来是合法的那窗口最后一个位置不会越过padding后的边界因为(H_out - 1) * stride eff_kh - 1 H_pad - 1是输出尺寸公式保证的。所以只要尺寸公式写对as_strided就不会读越界。5.3 边界情况bias为空、多batch、多通道工程上bias有时候是None我的接口里做了处理if bias is None: bias np.zeros(C_out, dtypex.dtype)多batch和多通道的验证也不难只要保证weight.shape[1] x.shape[1]。我把batch从1改成8输入通道从1改成64输出通道从1改成128跑一轮stride2, padding1, dilation2结果和PyTorch依旧能对齐。这里要注意np.testing.assert_allclose的atol不要设得太严因为不同实现里的浮点累加顺序不同误差在1e-6量级是正常的我习惯放宽到1e-5。5.4 常见错误排错清单根据我自己的经验新手最容易犯的错就这几类padding的轴顺序写错np.pad的第二个参数必须和数组维度一一对应(N, C_in, H, W)对应((0,0),(0,0),(padding,padding),(padding,padding))少写一个维度就会报shape不匹配。输出尺寸公式漏了减1很多人会写成(H 2*padding - eff_kh) // stride 1忘了最外面还要-1。这个公式建议自己推到一遍。as_strided之后直接reshape第4章提过不先transpose就reshape矩阵布局会乱结果全是错。dtype不匹配输入是float32权重是float64PyTorch里一定会报错但NumPy里可能自动转换导致精度和对比结果莫名其妙。6. 反向传播手撕Conv2D真正的分水岭6.1 反向需要的三张梯度面试如果只让你写前向还只是热身如果让你写反向那才是真正的分水岭。反向传播要做的事情很明确给定上游梯度grad_output计算出dx输入x的梯度形状和x一致dw卷积核weight的梯度形状和weight一致db偏置bias的梯度形状和bias一致其中db最简单因为每个输出通道的偏置影响了该通道所有空间位置的输出所以db[co] sum_{n, i, j} grad_output[n, co, i, j]dw也比较好理解卷积核的每个参数在前向时和某个输入窗口相乘反向时就把上游梯度乘上那个窗口里的输入值再累加。所以dw[co, ci, kh, kw] sum_{n, i, j} grad_output[n, co, i, j] * x_pad[n, ci, i*stride kh*dilation, j*stride kw*dilation]最麻烦的是dx。因为前向时同一个输入像素会被多个输出位置的窗口用到反向时就要把多个上游梯度累加回这个像素dx_pad[n, ci, ih, iw] sum_{co, kh, kw, i, j满足 ih i*stride kh*dilation, iw j*stride kw*dilation} grad_output[n, co, i, j] * weight[co, ci, kh, kw]注意这里要用累加而不是赋值因为窗口重叠时同一个输入像素会收到多次梯度。6.2 朴素反向实现直接照着公式写循环版本是最容易保证正确的def conv2d_backward(x, weight, grad_output, stride1, padding0, dilation1): N, C_in, H, W x.shape C_out, C_in_w, K_h, K_w weight.shape assert C_in_w C_in eff_kh (K_h - 1) * dilation 1 eff_kw (K_w - 1) * dilation 1 H_out grad_output.shape[2] W_out grad_output.shape[3] if padding 0: x_pad np.pad( x, ((0, 0), (0, 0), (padding, padding), (padding, padding)), modeconstant, ) else: x_pad x dx_pad np.zeros_like(x_pad) dw np.zeros_like(weight) db np.zeros(C_out, dtypex.dtype) for n in range(N): for co in range(C_out): for i in range(H_out): for j in range(W_out): g grad_output[n, co, i, j] db[co] g for ci in range(C_in): for kh in range(K_h): for kw in range(K_w): ih i * stride kh * dilation iw j * stride kw * dilation dw[co, ci, kh, kw] x_pad[n, ci, ih, iw] * g dx_pad[n, ci, ih, iw] weight[co, ci, kh, kw] * g if padding 0: dx dx_pad[:, :, padding:-padding, padding:-padding] else: dx dx_pad return dx, dw, db这个版本和前向的朴素循环一样慢但不难写。关键就在dx_pad那一行它把上游梯度乘以核权重后散射回输入像素。因为同一个输入像素会被多个输出窗口踩到所以这里必须用这就是梯度累加。写完记得把padding裁掉dx_pad是padding后的梯度但x的尺寸是原始尺寸所以dx要截掉四周的padding。6.3 用autograd做梯度校验反向写没写对光靠肉眼很难看出来。我建议你用PyTorch的autograd做一次数值对比或者直接写数值梯度。数值梯度的思路是把输入x的某个元素扰动一下看损失函数怎么变化用中心差分近似梯度。def numerical_grad(f, x, eps1e-5): grad np.zeros_like(x) it np.nditer(x, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index old x[idx] x[idx] old eps fp f(x) x[idx] old - eps fm f(x) x[idx] old grad[idx] (fp - fm) / (2 * eps) it.iternext() return grad然后构造一个小的损失函数比如让前向结果乘一个随机向量再求和分别对输入和权重求梯度和conv2d_backward的结果做比较。误差在1e-4量级就说明反向逻辑是对的。这个验证方法以后写任何自定义算子都能复用。6.4 向量化反向的思路面试时如果能从朴素反向升级到向量化反向会很加分。向量化反向主要分三步db grad_output.sum(axis(0, 2, 3))。dw用im2col的cols矩阵来算dw_mat einsum(nol,nkl-ok, dy, cols)其中dy形状是(N, C_out, H_out*W_out)cols形状是(N, C_in*K_h*K_w, H_out*W_out)dw_mat就是展平后的dw。dx最麻烦需要先把dy和w_mat做矩阵乘得到d_colsd_cols einsum(nol,ok-nkl, dy, w_mat)然后把d_cols里的梯度通过散点累加scatter add放回dx_pad的对应窗口位置。难点在第3步的散点累加因为窗口重叠不能简单赋值。你能说出scatter add这个词面试官就知道你是真写过反向的。7. 面试与工程中的避坑清单7.1 面试高频追问清单手撕完Conv2D之后面试官大概率会顺着问下面几个问题追问点回答思路1x1卷积等价于什么逐位置上对通道维做线性变换相当于全连接作用是通道升降维、跨通道信息融合不提取空间特征空洞卷积为什么能扩大感受野dilation在不增加参数量的前提下让每个卷积核元素之间的间隔变大覆盖更多输入区域分组卷积是什么把输入和输出通道分成group组每组独立做普通卷积计算量降为1/group常用于MobileNet和ShuffleNet普通的池化为什么一般不放卷积里手撕池化没有可学习参数反向时只需要把梯度回传给窗口内被选中的那个像素NCHW和NHWC有什么区别数据内存布局不同。NCHW在GPU上更适合cudnnNHWC在某些CPU推理引擎上对卷积更友好因为空间维连续im2col的访存更收敛为什么卷积核尺寸通常是奇数奇数核有中心点padding能对称且便于保持输出尺寸与输入一致时做same卷积这些问题不一定每个都要答得特别深但至少你要能说出核心关键词不能卡住。7.2 工程实现中的几个真实教训第一别小看padding的影响。很多人在Python里用np.pad很顺手但如果你要在C或者CUDA里自己实现padding不是真去补一圈内存而是在索引计算里判断ih - padding 0就返回0。这样省内存但会让循环里多一个分支影响性能。工业实现通常会把边界检查和主体循环分开中间区域用无分支代码边缘区域单独处理。第二内存连续性和性能强相关。第4章的im2col虽然快但as_strided返回的视图如果后续做transpose会生成不连续的数组reshape时会触发复制。这个复制既是开销也是必要的因为矩阵乘需要连续内存。如果你在写推理引擎尽量把im2col和GEMM融合在一起减少中间拷贝。第三面试写代码时先把公式写出来。我见过太多人上来就写循环写到一半开始怀疑索引白板擦来擦去。我自己面试时都会先在角落写上输出尺寸公式和索引映射公式再照着公式写代码。这是最稳的打法也是我想重点推荐给你的习惯。第四反向比前向更容易错一定要验证。别觉得写出反向就结束了花两分钟跑一个数值梯度对比比自己盯代码找bug高效得多。你手撕Conv2D时能主动做验证这本身就是加分项。7.3 我的实操建议说实话手撕Conv2D这道题我在面试别人时其实不指望候选人写出完美无缺的代码更看重他能不能把思路讲清楚为什么输出尺寸是这个公式为什么窗口要从i*stride开始为什么反向要累加。如果你能一边写一边把我在算什么、为什么这么算讲明白哪怕代码有点小瑕疵我也会给过。我自己准备这类题目的方法是画图。在草稿纸上画一个5x5输入一个3x3卷积核亲自用手指在上面滑动几次把每个输出位置的窗口起点、采样点标出来。这个过程重复几次之后你会发现在哪里写stride、哪里写dilation、哪里写eff_kh就再也不会混淆了。Conv2D只是第一道开胃菜下一篇如果继续写ReLU、BatchNorm、MaxPool或者更复杂的Depthwise卷积思路也是一样的先把数学公式列清楚再把索引关系画清楚最后才是写代码。
返回列表