1. 项目概述:从直觉到实现,为什么用矩阵乘法做卷积?
刚接触图像处理或者深度学习的朋友,第一次看到卷积神经网络(CNN)里的卷积层时,心里多半会犯嘀咕:这玩意儿不就是拿个小窗口(卷积核)在图像上滑来滑去,做乘积累加吗?听起来挺简单的,为什么框架(比如PyTorch、TensorFlow)的实现那么复杂,还总听说底层用了什么“im2col+GEMM”的优化?今天,我就从一个一线工程师的角度,掰开揉碎了讲讲,如何用最基础的矩阵乘法(GEMM)来实现高效的卷积运算,以及这背后一整套的工程权衡和性能哲学。
简单来说,用矩阵乘法实现卷积,核心目的是为了“借力”。现代CPU和GPU的硬件设计,尤其是它们的计算核心和内存带宽,对于大规模的、规整的矩阵乘法运算有着极高的优化。像英伟达的Tensor Core、Intel的AMX指令集,都是为矩阵乘加运算量身定制的加速器。而原始的卷积操作,虽然概念直观,但数据访问模式不规则(滑动窗口),不利于发挥硬件的并行计算威力。于是,聪明的工程师们想出了一个办法:把卷积这个“不规整”的问题,转换成矩阵乘法这个“规整”的问题。这个转换过程,就是我们常说的im2col(Image to Column)。
你可以把它想象成整理乐高积木。原始的图像是一整块板子,卷积核是一个小模板。你想用小模板去匹配大板子上的每一个位置,最笨的方法就是拿着模板到处比划。而im2col的做法是,把大板子上每一个可能匹配的位置所需要的“积木块”,提前全部拆出来,整齐地排成一列一列的,放在一个大桌子上。这样,卷积核这个“小模板”也被拉平成一行向量。接下来,大桌子上的所有列(积木块)和模板行向量之间的乘法,就变成了一个非常标准的、可以高度并行的矩阵乘法问题。计算完成后,再把结果重新拼回(col2im)输出特征图的样子。
这个方法解决了通用性和性能的核心矛盾。无论你的卷积核是3x3还是7x7,步长是1还是2,通过im2col都能转换成固定模式的矩阵乘。底层只需要一个高度优化的矩阵乘法库(如OpenBLAS, cuBLAS, MKL),就能通吃各种卷积配置,极大简化了计算库的开发和维护,同时又能榨干硬件的性能。当然,天下没有免费的午餐,im2col带来的巨大内存开销(通常会产生数倍甚至数十倍于原图的数据)是其最显著的代价,这也催生了后续如Winograd、FFT卷积以及直接卷积优化等多种技术路线。但对于绝大多数通用场景和教学理解而言,im2col+GEMM是最经典、最直观的基石。
所以,这篇文章适合谁?如果你是深度学习框架的初学者,想弄明白nn.Conv2d背后到底在干什么;如果你是做模型部署或优化的工程师,需要理解算子层面的计算逻辑;或者你就是一个对算法如何映射到硬件感兴趣的技术爱好者,那么这次从原理到实现的深度拆解,应该能给你带来不少干货。我们会从最基础的原理推导开始,一步步用代码实现,并深入分析其优劣和现代框架中的实际考量。
2. 核心原理拆解:im2col如何把卷积“拍扁”
要理解矩阵乘法实现卷积,关键在于掌握im2col这个数据变换操作。我们暂时抛开硬件和性能,先从纯数学和几何视角,看看这个变换是如何完成的。
2.1 卷积的原始定义与计算瓶颈
假设我们有一个输入特征图(Input Feature Map),其尺寸为[C_in, H_in, W_in],其中C_in是输入通道数,H_in是高度,W_in是宽度。一个卷积层包含C_out个卷积核(Kernel或Filter),每个卷积核的尺寸是[C_in, K, K](这里假设为方形核,K是核尺寸,如3)。步长(stride)为S,填充(padding)为P。
对于输出特征图上的一个空间位置(i, j),其值是通过以下计算得到的:
- 从输入特征图上,根据步长和填充,定位一个与卷积核同样大小的局部区域块(patch),尺寸为
[C_in, K, K]。 - 将这个局部块和卷积核逐元素相乘,并对所有元素(跨越所有输入通道和空间位置)求和,得到一个标量值。
- 这个标量值就是输出特征图在
(i, j)位置,对应某个输出通道的值。
计算的总复杂度很高。输出特征图有C_out * H_out * W_out个值,每个值的计算需要C_in * K * K次乘法和加法。所以浮点运算次数(FLOPs)是C_out * H_out * W_out * C_in * K * K。这本身计算量就大,但更麻烦的是数据复用性差。为了计算下一个输出点,滑动窗口只移动了一小步,但需要从内存中重新加载大量与之前重叠的数据。这种“滑窗”模式导致内存访问不规则,缓存(Cache)的命中率很低,大量时间浪费在等待数据从慢速内存加载到快速缓存上,而不是用于计算。这就是原始卷积直接实现的主要性能瓶颈。
2.2 im2col变换:数据重排的艺术
im2col就是为了解决上述数据访问问题而生的。它的核心思想是:将每次卷积操作所需的所有输入数据,一次性整理好,摆成一个规整的二维矩阵。
具体过程如下:
- 确定输出尺寸和所需的输入块:首先计算输出特征图的尺寸
H_out和W_out。对于输出图的每一个位置(i, j),它对应输入图上的一个三维块(C_in个通道,每个通道上一个K x K的区域)。这个三维块包含了计算该输出点所需的全部输入数据。 - 展开(Flatten)每个输入块:将这个
[C_in, K, K]的三维块,沿着通道维度展开,拉平成一个一维列向量。这个向量的长度是C_in * K * K。你可以理解为,把每个通道的KxK小方块,依次首尾相接,拼成一根长条。 - 组装成矩阵:我们有
H_out * W_out个输出位置,也就有H_out * W_out个这样的输入块。把每个块拉平后的列向量,作为一列,按顺序(通常是先遍历宽度,再遍历高度)排列起来,就得到了一个巨大的二维矩阵X_col。这个矩阵的维度是(C_in * K * K, H_out * W_out)。它的每一列,对应一个输出位置所需的全部输入数据。
与此同时,我们对卷积核也做类似变换。将C_out个卷积核,每个尺寸为[C_in, K, K],全部拉平成行向量。每个行向量的长度也是C_in * K * K。然后将这C_out个行向量堆叠起来,形成一个权重矩阵W_row,其维度为(C_out, C_in * K * K)。它的每一行,对应一个输出通道的卷积核参数。
注意:这里有一个关键点,im2col变换会显著增加内存占用。
X_col矩阵的大小是原始输入特征图大小的K*K倍(在不考虑通道的情况下)。例如,对于一个224x224的输入,3x3卷积,X_col的列数约为224*224=50176,行数为3*3=9(单通道),所以元素总数是原始输入(50176)的9倍。这是im2col方法最主要的代价,被称为“内存换速度”的权衡。
2.3 矩阵乘法与结果重塑
经过im2col变换后,奇迹发生了。原本复杂的卷积操作,被等价地转换成了两个矩阵的乘法:
Y = W_row @ X_col
这里:
W_row的形状是(C_out, C_in * K * K)X_col的形状是(C_in * K * K, H_out * W_out)- 矩阵乘法
Y的形状自然是(C_out, H_out * W_out)
你看,这个矩阵乘法Y的每一个元素Y[c_out, n],其计算过程正是:第c_out个卷积核的行向量(W_row[c_out, :]),与第n个输入位置的列向量(X_col[:, n])做点积。而这,恰恰就是原始卷积定义中,对一个输出位置和一个输出通道的计算!
得到矩阵Y后,我们只需要将其重塑(reshape)成[C_out, H_out, W_out]的三维张量,就得到了最终的输出特征图。如果需要,还可以加上偏置(bias),这通常是在矩阵乘之后,给Y的每一行(对应一个输出通道)加上一个标量偏置值。
至此,我们完成了从卷积到矩阵乘法的理论转换。整个过程可以概括为:im2col变换将输入数据重组,使得卷积核与每个局部区域的交互,变成了矩阵的行与列之间的规整乘加运算。这为后续调用极度优化的通用矩阵乘法(GEMM)库铺平了道路。
3. 从零实现:手写一个im2col卷积层
理解了原理,最好的巩固方式就是动手实现。我们将使用Python和NumPy,从最底层开始,实现一个包含im2col、GEMM和col2im的完整卷积层。这会让你对每个细节有切身的体会。
3.1 基础工具函数:im2col的实现
首先,我们实现最核心的im2col函数。这里我们实现一个简化版,假设输入是四维张量[N, C, H, W](批大小,通道,高,宽),并且只处理方形卷积核和相同的宽高步长、填充。
import numpy as np def im2col(input_data, kernel_size, stride=1, pad=0): """ 将四维输入张量转换为二维矩阵(列展开格式)。 参数: input_data : 四维numpy数组,形状 (N, C, H, W) kernel_size: 整数或元组,卷积核尺寸 (kH, kW) stride : 整数或元组,步长 pad : 整数或元组,填充 返回: col : 二维numpy数组,形状 (C*kH*kW, N*H_out*W_out) """ N, C, H, W = input_data.shape # 参数标准化 if isinstance(kernel_size, int): kH = kW = kernel_size else: kH, kW = kernel_size if isinstance(stride, int): sH = sW = stride else: sH, sW = stride if isinstance(pad, int): pH = pW = pad else: pH, pW = pad # 计算输出尺寸 H_out = (H + 2 * pH - kH) // sH + 1 W_out = (W + 2 * pW - kW) // sW + 1 # 对输入进行填充(pad) img = np.pad(input_data, [(0,0), (0,0), (pH, pH), (pW, pW)], mode='constant') # 预分配输出矩阵 col = np.zeros((C * kH * kW, N * H_out * W_out)) # 核心循环:为每个输出位置,提取对应的输入块并拉平 for y in range(H_out): y_start = y * sH y_end = y_start + kH for x in range(W_out): x_start = x * sW x_end = x_start + kW # 提取当前窗口的所有通道数据,形状 (N, C, kH, kW) window = img[:, :, y_start:y_end, x_start:x_end] # 拉平成 (N, C*kH*kW),然后转置为 (C*kH*kW, N),再放入col的对应列 # 列索引的计算:n * (H_out*W_out) + y * W_out + x for n in range(N): col_idx = n * (H_out * W_out) + y * W_out + x col[:, col_idx] = window[n].flatten() return col, (H_out, W_out)这个实现使用了显式循环,便于理解,但效率不高。在实际的深度学习框架中,会使用高度优化的C++或CUDA代码,通过索引计算来避免大量的小数据拷贝。
3.2 卷积层的前向传播实现
有了im2col,实现卷积层前向传播就非常直观了。
class Conv2D: def __init__(self, in_channels, out_channels, kernel_size, stride=1, pad=0): self.in_channels = in_channels self.out_channels = out_channels self.kernel_size = kernel_size if isinstance(kernel_size, tuple) else (kernel_size, kernel_size) self.stride = stride if isinstance(stride, tuple) else (stride, stride) self.pad = pad if isinstance(pad, tuple) else (pad, pad) # 初始化权重和偏置 kH, kW = self.kernel_size # 使用He初始化,适合ReLU激活函数 scale = np.sqrt(2. / (in_channels * kH * kW)) self.W = np.random.randn(out_channels, in_channels, kH, kW) * scale self.b = np.zeros(out_channels) # 缓存,用于反向传播 self.x = None self.col = None self.output_shape = None def forward(self, x): """ 前向传播 参数 x: 输入,形状 (N, C_in, H, W) 返回: 输出,形状 (N, C_out, H_out, W_out) """ self.x = x # 缓存输入 N, C, H, W = x.shape # 1. 对输入进行im2col变换 self.col, (H_out, W_out) = im2col(x, self.kernel_size, self.stride, self.pad) self.output_shape = (H_out, W_out) # 2. 将权重矩阵重塑为二维 (C_out, C_in*kH*kW) kH, kW = self.kernel_size W_row = self.W.reshape(self.out_channels, -1) # -1 表示自动计算 # 3. 执行矩阵乘法 # col 形状: (C_in*kH*kW, N*H_out*W_out) # W_row 形状: (C_out, C_in*kH*kW) # out 形状: (C_out, N*H_out*W_out) out = np.dot(W_row, self.col) # 4. 加上偏置 # 将偏置从 (C_out,) 扩展为 (C_out, N*H_out*W_out) 以便广播相加 out += self.b.reshape(-1, 1) # 5. 将结果重塑为四维输出张量 out = out.reshape(self.out_channels, N, H_out, W_out).transpose(1, 0, 2, 3) # 转置后形状: (N, C_out, H_out, W_out) return out实操心得:在步骤5的重塑和转置中,顺序非常重要。因为我们的
col矩阵是按列组织的,每一列对应一个样本的一个空间位置。矩阵乘的结果out的行对应输出通道,列对应(样本, 空间位置)。因此,需要先按(C_out, N, H_out, W_out)重塑,再交换第0和第1轴(transpose(1,0,2,3)),才能得到标准的(N, C_out, H_out, W_out)格式。这个细节在调试时很容易出错。
3.3 反向传播的思考与col2im
为了训练神经网络,我们还需要实现反向传播。反向传播的核心是计算权重W、偏置b和输入x的梯度。在im2col框架下,这变得异常优雅。
假设我们从上一层传回的梯度是dout,形状为(N, C_out, H_out, W_out)。
- 权重的梯度
dW: 根据矩阵乘法的求导规则,dW_row = dout_col @ X_col.T。这里需要将dout也变换成列格式dout_col,形状为(C_out, N*H_out*W_out)。计算得到的dW_row形状是(C_out, C_in*kH*kW),再将其重塑回(C_out, C_in, kH, kW)即可。 - 偏置的梯度
db: 偏置的梯度是dout在批次和空间维度上的和。db = np.sum(dout, axis=(0,2,3))。 - 输入的梯度
dx: 这是最有趣的部分。根据链式法则,传递到输入的梯度是W_row.T @ dout_col。这个结果是一个形状为(C_in*kH*kW, N*H_out*W_out)的矩阵。然而,我们需要的是原始输入x的梯度,形状为(N, C_in, H, W)。这就需要col2im操作,它是im2col的逆过程,负责将梯度矩阵中分散的、重叠的梯度值累加回输入图像的对应位置。
col2im的实现比im2col更复杂一些,因为它涉及重叠区域的累加。这里给出一个概念性的简化实现思路:
def col2im(col, input_shape, kernel_size, stride=1, pad=0): """ 将二维列矩阵转换回四维输入张量格式(im2col的逆操作)。 注意:此函数用于梯度反向传播,需要处理重叠累加。 """ N, C, H, W = input_shape kH, kW = (kernel_size, kernel_size) if isinstance(kernel_size, int) else kernel_size sH, sW = (stride, stride) if isinstance(stride, int) else stride pH, pW = (pad, pad) if isinstance(pad, int) else pad H_out = (H + 2*pH - kH) // sH + 1 W_out = (W + 2*pW - kW) // sW + 1 # 创建一个填充后的零张量,用于累加梯度 dx_padded = np.zeros((N, C, H + 2*pH, W + 2*pW)) # 遍历每个输出位置,将col中的列数据加回到dx_padded的对应窗口 for n in range(N): for y in range(H_out): for x in range(W_out): col_idx = n * (H_out * W_out) + y * W_out + x col_slice = col[:, col_idx] # 形状 (C*kH*kW,) # 将这一列数据重塑回 (C, kH, kW) patch = col_slice.reshape(C, kH, kW) # 累加到填充后图像的对应位置 y_start, x_start = y*sH, x*sW dx_padded[n, :, y_start:y_start+kH, x_start:x_start+kW] += patch # 去掉填充部分,得到原始输入形状的梯度 if pH == 0 and pW == 0: dx = dx_padded else: dx = dx_padded[:, :, pH:-pH, pW:-pW] if pH > 0 and pW > 0 else dx_padded return dx有了col2im,卷积层的反向传播就可以完整实现了。通过手写这些代码,你会深刻体会到,im2col如何将卷积的正向和反向传播,都统一到了矩阵乘法及其求导这个简洁的框架下,极大简化了推导和实现。
4. 性能权衡与工程实践:im2col的得与失
我们已经从原理和实现上理解了im2col+GEMM。但在实际工程中,尤其是在深度学习框架和高性能计算库中,事情远不止这么简单。我们需要深入权衡其优缺点,并了解框架是如何做出选择的。
4.1 优势:为何它能成为主流?
- 极致利用硬件性能:这是最根本的优势。矩阵乘法(GEMM)是计算密集型操作的典型代表,其规整的数据访问模式(连续内存访问)和极高的计算密度(乘加比),完美匹配现代CPU的SIMD指令集(如AVX-512)和GPU的大规模并行架构。像OpenBLAS、Intel MKL、NVIDIA cuBLAS这样的库,经过了数十年的优化,能够将特定硬件上的GEMM性能榨干到极致。将卷积转换为GEMM,就等于直接享用了这些顶级的优化成果。
- 实现通用且统一:无论卷积的参数如何变化(核尺寸、步长、膨胀等),只要经过im2col预处理,核心计算都是一个GEMM调用。这极大地简化了卷积算子的实现和维护。框架开发者只需要维护一个高效的GEMM后端,以及对应的im2col/col2im函数,就能支持几乎所有类型的卷积。
- 降低开发复杂度:相比于为每种卷积参数组合手写一个高度优化的内核(kernel),基于GEMM的实现策略在开发和调试上要简单得多。性能优化的工作可以集中在GEMM库和im2col数据布局上。
4.2 劣势:无法回避的内存墙
- 巨大的内存开销:如前所述,im2col会产生巨大的中间矩阵
X_col。其内存占用是输入数据的K*K倍。对于大尺寸输入、大卷积核或大批次训练,这个开销是惊人的,可能导致缓存溢出,甚至耗尽GPU显存,反而使得性能下降。这是im2col方法最致命的弱点。 - 额外的数据变换开销:
im2col和col2im操作本身需要时间。它们涉及不规则的内存访问和大量的数据拷贝。虽然这部分开销通常比GEMM的计算开销小,但在某些场景下(如小尺寸卷积、网络较浅时),它可能成为不可忽视的瓶颈。 - 并非所有场景都最优:对于某些特殊的卷积参数,存在更优的直接算法。例如:
- 1x1卷积:这本质上就是标准的矩阵乘法,完全不需要im2col。
- 深度可分离卷积(Depthwise Convolution):每个通道独立卷积,计算量小,用im2col反而会引入不必要的内存开销和变换损耗,直接实现或专用内核效率更高。
- 小尺寸特征图上的卷积:当
H_out * W_out很小时,GEMM的规模太小,无法充分发挥硬件优势,而im2col的开销占比会变大。
4.3 现代框架的优化策略
为了克服im2col的缺点,工业级的深度学习框架和推理引擎采用了一系列组合策略:
- 惰性im2col与融合内核:不会在内存中物理地创建完整的
X_col矩阵。而是通过计算索引,在GEMM内核内部“按需”访问输入数据。这被称为“隐式GEMM”或“融合im2col-GEMM内核”。CUDA上的cuDNN库就大量使用了这种技术,避免了显式的数据拷贝和内存占用。 - Winograd卷积算法:对于小的、固定的卷积核(如3x3),Winograd算法可以用更少的乘法次数来完成卷积。它通过巧妙的线性变换,将卷积计算转换为更小规模的元素级乘法和另一个变换。在满足其条件时(小核、单位步长),Winograd比im2col+GEMM更快,且内存开销更小。cuDNN和TensorRT等库都集成了Winograd。
- FFT卷积:利用快速傅里叶变换(FFT)在频域进行卷积。当卷积核很大时(例如超过7x7),FFT卷积在理论上有优势。但由于其变换开销和精度问题,在实际的CNN中应用不如前两者广泛。
- 直接卷积优化:针对特定场景(如深度可分离卷积、小批量、特定硬件),手写高度优化的直接卷积内核。这些内核直接实现滑窗逻辑,但通过循环展开、向量化、共享内存等技术进行极致优化,避免了im2col的内存开销。
- 算子选择与自动调优:像TVM、TensorRT这样的编译器/推理引擎,会针对目标硬件和具体的卷积参数(输入尺寸、核尺寸、步长等),自动生成或从预定义的内核库中选择最优的实现(可能是im2col-GEMM、Winograd、Direct等)。这是一个动态的、基于性能模型或实际 profiling 的选择过程。
注意事项:在实际项目中选择卷积实现时,不要盲目认为im2col就是最快的。对于模型部署,尤其是在资源受限的边缘设备上,需要 profiling 不同实现(如果框架提供选择)在目标硬件上的实际性能。有时,一个内存效率更高的直接卷积实现,可能比一个导致频繁缓存失效的im2col实现更快。
5. 扩展与对比:im2col与其他卷积实现算法
为了更全面地理解矩阵乘法实现卷积的定位,我们将其与另外两种主流的优化算法进行简要对比,这有助于你在不同场景下做出更明智的选择。
5.1 与Winograd卷积的对比
Winograd算法是一种基于最小滤波算法的快速卷积方法。其核心思想是利用卷积的线性特性,通过增加加法操作来减少乘法操作的数量。
- 计算复杂度:对于
r x r的卷积核输出m x m的块,经典算法需要m^2 * r^2次乘法,而Winograd可以将其降至(m+r-1)^2次乘法。例如,对于F(2x2, 3x3)(即用3x3核计算2x2的输出块),乘法次数从36次降至16次,理论加速比为2.25倍。 - 适用场景:Winograd对参数非常敏感。它通常要求:
- 卷积核尺寸固定且较小(最常见的是3x3)。
- 步长(stride)为1。
- 膨胀(dilation)为1。
- 优势:在满足条件时,计算量显著低于im2col+GEMM,因此可以获得更高的理论性能。
- 劣势:
- 数值稳定性:变换过程中涉及到的矩阵可能包含大数值,在低精度(如FP16)计算下,可能会引入比im2col更大的数值误差,影响模型精度。
- 灵活性差:只能用于特定的核尺寸和输出块大小组合。对于不支持的配置(如5x5卷积、步长为2),无法使用。
- 变换开销:需要额外的输入变换和输出变换步骤,虽然这些变换主要是加法,但当问题规模较小时,变换开销可能抵消计算节省。
如何选择:在移动端或嵌入式设备上,推理引擎(如NCNN、TFLite)在处理3x3卷积时,往往会优先尝试Winograd算法,以追求极致的速度。但在训练阶段,或者对数值精度要求极高的场景,im2col+GEMM因其通用性和稳定性,仍然是默认的可靠选择。
5.2 与直接卷积(Direct Convolution)的对比
直接卷积就是最直观的“滑窗”实现,使用多层循环嵌套。
- 实现方式:直接遍历输出空间的每个位置,对于每个位置,循环遍历卷积核的每个元素和每个输入通道,进行乘累加。
- 优势:
- 内存效率极高:除了输入、输出和权重,几乎不需要额外的内存,没有im2col的巨大中间矩阵。
- 数据复用性好:在循环设计合理的情况下,可以很好地利用CPU缓存,对于小规模卷积或特定硬件,可能表现出色。
- 劣势:
- 计算效率低:循环嵌套多,分支判断多,不利于编译器的向量化优化,也难以充分利用GPU的大规模并行性。
- 实现复杂:为了获得高性能,需要针对不同的参数(核大小、步长)进行手写高度优化的汇编或CUDA内核,开发和维护成本巨大。
如何选择:直接卷积通常用于以下情况:
- 在极度内存受限的微控制器(MCU)上部署超轻量模型。
- 作为某些特定优化库的补充,用于处理im2col和Winograd不擅长的小众参数(如非常大的步长、非标准核尺寸)。
- 在学术研究或教学代码中,作为最清晰易懂的参考实现。
5.3 实践中的混合策略
现代高性能卷积库(如cuDNN、OneDNN)通常采用一个分层的策略:
- 启发式筛选:根据卷积层的参数(输入尺寸、批大小、核尺寸、步长、通道数等),通过一套启发式规则或一个轻量级的成本模型,快速排除明显不合适的算法。
- 自动调优:对于多个候选算法(如im2col-GEMM的不同数据布局、Winograd、直接卷积的某个优化版本),在目标硬件上实际运行一个微基准测试(micro-benchmark),选择其中耗时最短的那个。这个调优过程可能在库安装时进行(生成一个查找表),也可能在运行时首次执行该层时进行(并缓存结果)。
- 内核融合:将im2col(或Winograd变换)与后续的GEMM计算融合在一个内核中执行,避免中间结果写回全局内存,这是GPU上提升性能的关键技术。
因此,作为使用者,我们通常不需要手动选择算法。但理解这些底层原理,能帮助我们在模型设计时(比如避免使用对Winograd不友好的超大卷积核),以及在性能调优时(比如当框架自动选择不佳时,尝试手动指定算法),做出更有效的决策。例如,在PyTorch中,你可以通过torch.backends.cudnn.benchmark = True开启运行时自动调优,让cuDNN为你的网络结构和输入尺寸选择最快的卷积算法。