本篇是整个系列技术密度最高的一篇,逐行剖析 OInfer 中卷积 kernel 的 OpenCL 实现。在上一篇理解了 Tensor 到 Image2D 映射规则的基础上,本篇将展示这些映射公式如何在实际的
.cl代码中落地------包括工作项与输出坐标的双向映射、三层卷积循环结构、输入与权重的 Image 索引计算、dot()向量化累加的原理,以及 bias 处理中容易踩坑的细节。
1. 卷积计算的数学本质
在展开 OpenCL 实现之前,先回顾标准二维卷积的计算公式:
ini
output[n][o][h_out][w_out] = bias[o] +
Σ_{c=0}^{C_in-1} Σ_{kh=0}^{KH-1} Σ_{kw=0}^{KW-1}
input[n][c][h_out*stride + kh][w_out*stride + kw] × weight[o][c][kh][kw]
对于每个输出位置 (n, o, h_out, w_out),需要对 输入通道 C_in × 卷积核高 KH × 卷积核宽 KW 三个维度进行乘加累加。这意味着一个输出元素的计算量为 O(C_in × KH × KW) 次乘加。对于典型的 3×3 卷积和 64 通道输入,每个输出元素需要 64 × 3 × 3 = 576 次乘加。
在 OInfer 的 OpenCL 实现中,利用 C4 打包将这个计算进行了 四路向量化 :每次 read_imagef() 一次性读取 4 个 Channel 的值(float4),然后用 OpenCL 内建的 dot() 函数在一条指令中完成 4 个乘加操作。理论上可以将上述 576 次标量乘加减少到 ⌈64/4⌉ × 3 × 3 = 144 次向量 dot 运算。
2. 全局工作项布局:输出驱动的并行策略
卷积 kernel 采用 输出驱动(output-centric) 的并行策略:每个工作项(work-item)负责计算输出 Image2D 中的一个像素,即输出张量中某个空间位置 (w_out, h_out) 上连续 4 个 Channel 的值。
Host 端设置二维全局工作空间,尺寸等于输出 Image2D 的宽高:
cpp
size_t global_size[2] = {out_img_w, out_img_h};
clEnqueueNDRangeKernel(queue, kernel, 2, NULL, global_size, NULL, ...);
其中:
ini
out_img_w = outW × out_c4 (out_c4 = ⌈out_channel/4⌉)
out_img_h = batch × outH
这意味着总共启动 outW × out_c4 × batch × outH 个工作项,每个工作项独立计算 4 个输出值(一个 float4),工作项之间无数据竞争,天然支持大规模并行。
scss
全局工作空间 (2D):
← out_img_w = outW × out_c4 →
┌──────────────────────────────────┐ ┬
│ 每个格子 = 1 个工作项 │ │
│ = 输出 Image 的 1 个像素 │ │
│ = 4 个连续输出 Channel 值 │ out_img_h
│ │ = batch × outH
│ get_global_id(0) → x (列索引) │ │
│ get_global_id(1) → y (行索引) │ │
└──────────────────────────────────┘ ┴
为什么不选择"输入驱动"的并行策略(每个工作项负责一个输入位置,向多个输出位置贡献结果)?因为输入驱动需要原子操作或 reduction 来汇总不同工作项对同一输出位置的贡献,而输出驱动中每个工作项独占一个输出位置,不存在写冲突,实现简单且高效。这是 GPU 卷积实现中最常见的并行策略。
3. 输出坐标解码
kernel 入口的第一步是将二维工作项坐标 (x, y) 解码为四维输出张量坐标 (n_out, h_out, w_out, c4_out)。这是上一篇 Image 坐标映射公式的 逆运算:
c
int x = get_global_id(0); // Image 列索引
int y = get_global_id(1); // Image 行索引
// 边界检查:全局工作空间可能被驱动向上取整
if (x >= out_img_width || y >= out_img_height) return;
// 从 image 坐标反推 tensor 坐标
int w_out = x / out_channel_4; // 输出空间宽度位置
int c4_out = x % out_channel_4; // 当前工作项处理的 C4 组索引
int h_out = y % outH; // 输出空间高度位置
int n_out = y / outH; // batch 索引
推导过程:根据正向映射公式 image_x = w × out_c4 + c4_idx,对 x 做整除和取余即可分离出 w 和 c4_idx。同理,image_y = n × outH + h,对 y 做整除和取余分离出 n 和 h。
图解(outW=3, out_c4=2, outH=4, batch=1):
ini
Image x 轴:
x: 0 1 2 3 4 5
w=0 w=0 w=1 w=1 w=2 w=2
c4=0 c4=1 c4=0 c4=1 c4=0 c4=1
Image y 轴:
y: 0 1 2 3
h=0 h=1 h=2 h=3
n=0 n=0 n=0 n=0
每个工作项通过 c4_out 知道自己负责计算哪 4 个输出通道:[c4_out*4, c4_out*4+1, c4_out*4+2, c4_out*4+3]。这 4 个通道的值将被打包到一个 float4 中,最终通过 write_imagef() 写入输出 Image。
解码完成后,还需计算输出位置在输入空间中的起始坐标(考虑 stride):
c
int h0 = h_out * stride; // 输入空间中的起始 h
int w0 = w_out * stride; // 输入空间中的起始 w
4. 核心三层循环结构
每个工作项执行如下三层循环来累加卷积结果。这三层循环直接对应卷积公式中 Σ_kh Σ_kw Σ_c 的三重求和:
c
float4 sum = (float4)(0.0f); // 累加器,存储 4 个输出通道的结果
int out_c_base = c4_out * 4; // 当前 C4 组的基础通道号
for (int kh = 0; kh < KH; ++kh) { // ← 卷积核高度方向
for (int kw = 0; kw < KW; ++kw) { // ← 卷积核宽度方向
for (int inslice = 0; inslice < intput_c4; inslice++) { // ← 输入 C4 组
为什么需要遍历 inslice(输入 C4 组)? 这是初学者常困惑的地方。一个工作项负责的是输出的某个 C4 组,但卷积公式要求对 所有输入通道 做加权求和。如果输入有 8 个 Channel(2 个 C4 slice),每个 kernel 空间位置 (kh, kw) 都需要读取并处理这 2 个 slice 的数据:
ini
输入通道 C=8 的情况:
某个空间位置 (h, w) 的数据分布在 2 个 C4 slice 中:
slice 0: [c0, c1, c2, c3] ← 第一次 read_imagef 读取
slice 1: [c4, c5, c6, c7] ← 第二次 read_imagef 读取
两个 slice 对每个输出通道的贡献需要分别计算并累加
sum[o] += dot(input_slice0, weight_slice0) + dot(input_slice1, weight_slice1)
整体循环次数 = KH × KW × input_c4。对于 3×3 卷积、8 通道输入,循环体执行 3 × 3 × 2 = 18 次,每次循环内进一步遍历 4 个输出通道。
5. 输入数据读取:Image 坐标计算
在三层循环的内部,首先需要从输入 Image 中读取当前滑窗位置的数据:
c
// 计算考虑 padding 后的实际输入坐标
int h = h0 + kh - h_pad; // 可能为负(padding 区域)
int w = w0 + kw - w_pad; // 可能超出宽度(padding 区域)
float4 inputv = (float4)(0.0f); // 默认为 0(padding 值)
if (h >= 0 && h < input_height && w >= 0 && w < input_width) {
int scrX = w * intput_c4 + inslice; // ← 输入 Image 的 x 坐标
int scry = n_out * input_height + h; // ← 输入 Image 的 y 坐标
inputv = read_imagef(input, sampler, (int2)(scrX, scry));
}
索引推导 :这里的 scrX 和 scry 正是上一篇推导的映射公式的直接应用:
ini
正向公式: image_x = w × slice + slice_index
image_y = n × H + h
代入: scrX = w * intput_c4 + inslice ✓
scry = n_out * input_height + h ✓
read_imagef() 返回的 float4 包含当前空间位置 (h, w) 上第 inslice 组的 4 个 Channel 值 [c_4s, c_4s+1, c_4s+2, c_4s+3]。
Padding 处理采用显式边界判断:如果 (h, w) 超出输入范围,inputv 保持初始值 (0, 0, 0, 0),即 zero-padding 语义。虽然 sampler 的 CLK_ADDRESS_CLAMP 也能实现自动返回零,但显式判断提供了更好的可移植性和可读性。
6. 权重读取与点积累加
输入数据读取后,需要与对应的权重做乘加运算。这是整个 kernel 中最精巧的部分------通过内层的 4 次循环,一个工作项同时计算 4 个输出通道的累加值:
c
for (int c = 0; c < 4; c++) {
int output_channel = out_c_base + c; // 当前处理的输出通道号
if (output_channel >= out_channel) continue; // 跳过 C4 对齐的 padding 通道
int kernelx = kw * intput_c4 + inslice; // 权重 Image 的 x 坐标
int kernely = output_channel * KH + kh; // 权重 Image 的 y 坐标
float4 inputk = read_imagef(kernel_img, sampler, (int2)(kernelx, kernely));
sum[c] += dot(inputv, inputk); // 向量化乘加累加
}
6.1 权重 Image 索引解析
ini
kernelx = kw × input_c4 + inslice
kernely = output_channel × KH + kh
注意这里使用的是 output_channel(单个通道索引,0, 1, 2, ...)而非 c4_out(C4 组索引)。这是上一篇中提到的关键设计------权重 Image 的 Y 轴按单个输出通道排列 。原因是:每个输出通道有自己独立的一组 KH × KW × C_in 个权重值,必须按通道分别读取才能做正确的点积。
如果 Y 轴按 C4 组排列(即 4 个输出通道的权重混合在同一行),一次 read_imagef 返回的 float4 将包含来自 4 个不同输出通道的权重,无法与输入的 float4(来自同一 C4 slice 的 4 个输入通道)做有意义的 dot() 运算。
图解:为什么权重 Image 和输入 Image 的 Y 轴排列方式不同
ini
输入 Image Y 轴: 按 (n, h) 排列 权重 Image Y 轴: 按 (o, kh) 排列
y=0: n=0, h=0 y=0: o=0, kh=0
y=1: n=0, h=1 y=1: o=0, kh=1
y=2: n=0, h=2 y=2: o=0, kh=2
y=3: n=1, h=0 y=3: o=1, kh=0 ← 单个通道,非 C4 组!
... y=4: o=1, kh=1
...
6.2 dot() 的向量化语义
c
sum[c] += dot(inputv, inputk);
dot() 是 OpenCL 内建函数,计算两个 float4 向量的点积:
css
dot(a, b) = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w
在这里它实现的物理意义是:将输入的 4 个 Channel 值分别与对应的 4 个权重值相乘后求和。这恰好是卷积公式中对输入通道维度求和的一个 子步骤------4 个通道的贡献在一次 dot 中完成。
对于有多个输入 C4 slice 的情况,外层 inslice 循环的多次 dot() 结果累加到 sum[c] 中,最终完成对所有输入通道的求和:
ini
C_in=8 时:
sum[c] = dot(input_slice0, weight_slice0_for_channel_c) ← inslice=0
+ dot(input_slice1, weight_slice1_for_channel_c) ← inslice=1
= Σ_{c_in=0}^{7} input[c_in] × weight[c_in] ← 完整的通道求和
C4 打包的计算效率优势可以量化:
markdown
不使用 C4: 每个 (kh, kw, c_in) 需要 1 次标量乘 + 1 次标量加
总计 KH × KW × C_in 次乘加
使用 C4: 每个 (kh, kw, slice) 需要 1 次 dot(≈1 条向量指令)
总计 KH × KW × ⌈C_in/4⌉ 次 dot
→ 指令数减少约 4 倍
7. Bias 处理:一个容易犯错的细节
Bias 使用 cl_mem buffer(而非 image)传入,在三层循环 外部 一次性加到 sum 上:
c
// ── 循环前:构造 bias4 向量 ──
float4 bias4 = (float4)(
(out_c_base + 0 < out_channel) ? bias[out_c_base + 0] : 0.0f,
(out_c_base + 1 < out_channel) ? bias[out_c_base + 1] : 0.0f,
(out_c_base + 2 < out_channel) ? bias[out_c_base + 2] : 0.0f,
(out_c_base + 3 < out_channel) ? bias[out_c_base + 3] : 0.0f
);
// ── 循环结束后 ──
sum += bias4;
write_imagef(output, (int2)(x, y), sum);
为什么 bias4 必须在循环外而非循环内累加? 代码中的中文注释已经明确指出了这个陷阱:
bias4 要写在外面,只能在最后和 sum 做一次自加,不能写在循环里面,因为循环里至少会计算 kernel 长乘宽次,导致会额外多加。
如果将 sum += bias4 放在三层循环内部,bias 会被累加 KH × KW × input_c4 次,远远超出正确值。例如 3×3 卷积、8 通道输入时,bias 会被多加 3 × 3 × 2 - 1 = 17 次,导致输出偏移严重。
Bias 向量的边界检查(out_c_base + c < out_channel ? bias[...] : 0.0f)确保了当输出 Channel 不是 4 的倍数时,C4 对齐产生的 padding 通道不会错误地加上 bias 值。
8. 输出写回与 Host 端结果提取
8.1 GPU 端写回
c
write_imagef(output, (int2)(x, y), sum);
将 float4 的 sum 向量写入输出 Image 的 (x, y) 位置。(x, y) 的值就是 get_global_id(0/1),因此每个工作项写入的位置互不重叠。
8.2 Host 端读取与裁剪
ConvKernel::Run() 在 GPU 计算完成后,通过 clEnqueueReadImage() 将输出 Image 的像素数据读回 Host 端:
cpp
// 从 GPU 读回
size_t region[3] = {out_img_w, out_img_h, 1};
clEnqueueReadImage(queue, output_image, CL_TRUE, origin, region, 0, 0,
output_data.data(), 0, NULL, NULL);
读取后得到的是 C4 对齐的 NHWC 数据(shape = [N, outH, outW, out_c4 × 4]),最后一步是通过 PackTensorForNHWC() 裁剪掉 C4 padding,恢复为原始输出 Channel 数:
ini
GPU 输出: shape = [1, 4, 4, 4] (C4 对齐,out_c4=1 → C_pad=4)
裁剪后: shape = [1, 4, 4, 1] (原始 out_channel=1)
PackTensorForNHWC: 每个 (n, h, w) 只保留前 1 个 float
9. Host 端完整流程
ConvKernel::Run() 是一个约 130 行的方法,其执行流程可以概括为 10 个步骤:
ini
┌──────────────────────────────────────────────────────┐
│ ConvKernel::Run() │
├──────────────────────────────────────────────────────┤
│ 1. PadTensorToC42(input) → C4 对齐输入 │
│ 2. PadTensorToC42(kernel) → C4 对齐权重 │
│ 3. 计算输出尺寸: │
│ outW = (W + 2*w_pad - kW) / stride + 1 │
│ outH = (H + 2*h_pad - kH) / stride + 1 │
│ out_c4 = ⌈out_channel / 4⌉ │
│ 4. 创建 bias buffer (clCreateBuffer + WriteBuffer) │
│ 5. clCreateImage2D × 3: │
│ input_image: W×slice × N×H │
│ kernel_image: kW×k_slice × O×kH │
│ output_image: outW×out_c4 × N×outH │
│ 6. clSetKernelArg × 18 个参数 │
│ 7. clEnqueueNDRangeKernel(2D: out_img_w × out_img_h)│
│ 8. clEnqueueReadImage → host buffer │
│ 9. PackTensorForNHWC → 输出到 outputs_[0] │
│ 10. clReleaseMemObject × 3 (释放 GPU 内存) │
└──────────────────────────────────────────────────────┘
值得注意的是,当前实现中每次 Run() 都会重新创建和释放 Image/Buffer 对象。在工业级实现中,这些对象通常会被缓存复用以避免频繁的 GPU 内存分配/释放开销。这是一个明确的优化方向。
10. 与 Relu Kernel 的对比:Buffer 模式的简洁实现
作为参照,Relu kernel 使用 Buffer 而非 Image2D,实现仅有 8 行代码:
c
__kernel void relu(__global const float* input,
__global float* output,
const int n) {
int i = get_global_id(0);
if (i >= n) return;
float val = input[i];
output[i] = val > 0.0f ? val : 0.0f;
}
两者的实现复杂度对比鲜明:
| 特性 | Conv Kernel | Relu Kernel |
|---|---|---|
| 工作空间维度 | 2D | 1D |
| 内存对象 | Image2D | Buffer |
| 需要 C4 打包 | 是 | 否 |
| 需要坐标解码 | 是 | 否(线性索引) |
| 循环嵌套深度 | 4 层 (kh, kw, inslice, c) | 0 层 |
| Kernel 代码行数 | ~80 行 | 8 行 |
| Host 端参数数 | 18 个 | 3 个 |
这种差异并非 OInfer 特有,而是卷积和逐元素操作在计算本质上的区别。卷积的每个输出元素依赖于一个感受野范围内的多个输入元素,需要复杂的索引计算来定位这些输入;而 Relu 的每个输出元素仅依赖对应位置的一个输入元素,索引关系是平凡的一对一映射。
11. 小结
本篇详尽剖析了 OInfer 卷积 kernel 中每一个索引计算的来龙去脉,这些公式不是凭空出现的"魔法数字",而是上一篇 Tensor-Image 映射规则在实际代码中的严格应用:
| 组件 | 索引公式 | 物理含义 |
|---|---|---|
| 输出坐标解码 | w_out = x / out_c4, c4_out = x % out_c4 |
从 Image 像素坐标反推输出空间位置和 C4 组 |
| 输入读取 | scrX = w * input_c4 + inslice |
定位输入 Image 中某位置某 C4 slice 的像素 |
| 权重读取 | kernely = output_channel * KH + kh |
定位权重 Image 中某输出通道某 kh 行的像素 |
| 向量化累加 | sum[c] += dot(inputv, inputk) |
4 路 Channel 并行乘加 |
| Bias | sum += bias4(仅在循环结束后) |
避免在 KH×KW×input_c4 次循环中重复累加 |
下一篇将展示 Pipeline 如何将 Conv + Relu 串联为一条完整的执行链路,以及 Interpreter 如何提供用户友好的端到端推理接口。