OpenGL / OpenCL 编程指南 / 第 12 章:内核编程
第 12 章:内核编程
内核(Kernel)是 OpenCL 中运行在设备端的计算函数。理解 OpenCL 的内存层次是写出高性能内核的关键。
12.1 内核函数基础
12.1.1 内核声明
// OpenCL C 内核函数
__kernel void my_kernel(
__global float *input, // 全局内存
__global float *output, // 全局内存
__constant float *weights, // 常量内存
__local float *scratch, // 局部内存
const int count // 标量参数(通过值传递)
) {
// ...
}
12.1.2 地址空间限定符
| 限定符 | 缩写 | 作用域 | 存储位置 | 典型用途 |
|---|---|---|---|---|
__global | global | 全局 | 显存(VRAM) | 输入/输出数据 |
__local | local | 工作组 | 片上 SRAM | 工作组内共享 |
__constant | constant | 全局(只读) | 常量缓存 | 查找表、参数 |
__private | private | 线程 | 寄存器 | 局部变量 |
__generic | generic | 自动推断 | 取决于赋值 | OpenCL 2.0+ |
12.1.3 内核参数传递
// C++ 主机端设置参数
cl_kernel kernel = clCreateKernel(program, "my_kernel", &err);
// 内存对象参数:传递 cl_mem 的地址
cl_mem buffer = clCreateBuffer(...);
clSetKernelArg(kernel, 0, sizeof(cl_mem), &buffer);
// 标量参数:传递值的地址
int count = 1024;
clSetKernelArg(kernel, 4, sizeof(int), &count);
// 局部内存:只分配大小,不传数据
clSetKernelArg(kernel, 3, 256 * sizeof(float), NULL);
// 设置完参数后,内核可以多次执行(参数不变的情况下)
12.2 内存层次结构
12.2.1 内存层次图
┌─────────────────────────────────────────────┐
│ 主机内存 (Host Memory) │
│ DDR4/DDR5, ~50 GB/s │
├────────────┬────────────┬───────────────────┤
│ 数据传输 │ 数据传输 │ 映射 (Map) │
├────────────┴────────────┴───────────────────┤
│ 全局内存 (Global Memory) │
│ VRAM GDDR6X, ~500 GB/s │
├───────────────────┬─────────────────────────┤
│ 常量缓存 │ │
│ (Constant Cache) │ │
│ ~数 TB/s, 只读 │ │
├───────────────────┴─────────────────────────┤
│ 局部内存 (Local Memory) │
│ 片上 SRAM, ~数 TB/s │
│ 工作组内共享,~32-64 KB │
├─────────────────────────────────────────────┤
│ 私有内存 (Private Memory) │
│ 寄存器, ~数十 TB/s │
│ 每线程独占 │
└─────────────────────────────────────────────┘
12.2.2 各层内存规格
| 内存类型 | 典型大小 | 带宽 | 延迟 | 可见范围 |
|---|---|---|---|---|
| 私有(寄存器) | 数 KB/线程 | 最高 | ~1 周期 | 单线程 |
| 局部 | 32-64 KB/组 | ~数 TB/s | ~20 周期 | 工作组 |
| 常量 | 64 KB | ~数 TB/s | ~20 周期 | 全局只读 |
| 全局 | 数 GB | ~500 GB/s | ~200 周期 | 全局可读写 |
12.3 全局内存优化
12.3.1 合并访问(Coalesced Access)
全局内存的性能关键:相邻线程应访问相邻地址。
// ✅ 合并访问:线程 i 访问元素 i
__kernel void good_access(__global float *data) {
int gid = get_global_id(0);
float val = data[gid]; // 连续访问
}
// ❌ 步长访问:线程 i 访问元素 i*stride
__kernel void bad_access(__global float *data, int stride) {
int gid = get_global_id(0);
float val = data[gid * stride]; // 非连续,缓存不友好
}
合并访问(好): 步长访问(差):
线程 0 → 地址 0 线程 0 → 地址 0
线程 1 → 地址 1 线程 1 → 地址 4
线程 2 → 地址 2 线程 2 → 地址 8
线程 3 → 地址 3 线程 3 → 地址 12
↓ ↓
1 次内存事务 4 次内存事务
12.3.2 避免全局内存银行冲突
// ❌ 冲突:所有线程访问同一银行
float val = data[0];
// ✅ 无冲突:不同线程访问不同银行
float val = data[get_local_id(0)];
12.3.3 向量化加载
// 使用向量类型提高内存吞吐
__kernel void vectorized(__global float4 *input, __global float4 *output) {
int gid = get_global_id(0);
float4 val = input[gid]; // 一次加载 16 字节
val = val * 2.0f;
output[gid] = val;
}
12.4 局部内存
12.4.1 使用模式
局部内存用于工作组内线程间的数据共享和复用:
__kernel void local_example(
__global float *input,
__global float *output,
__local float *scratch // 局部内存(主机端传 NULL)
) {
int lid = get_local_id(0);
int gid = get_global_id(0);
// 1. 从全局内存加载到局部内存
scratch[lid] = input[gid];
barrier(CLK_LOCAL_MEM_FENCE); // 同步工作组
// 2. 在局部内存上操作(快速)
float sum = 0.0f;
for (int i = 0; i < get_local_size(0); i++) {
sum += scratch[i];
}
// 3. 写回全局内存
if (lid == 0) {
output[get_group_id(0)] = sum;
}
}
12.4.2 并行规约优化
// 工作组内并行求和
__kernel void parallel_sum(
__global float *input,
__global float *output,
__local float *scratch
) {
int lid = get_local_id(0);
int size = get_local_size(0);
scratch[lid] = input[get_global_id(0)];
barrier(CLK_LOCAL_MEM_FENCE);
for (int stride = size / 2; stride > 0; stride >>= 1) {
if (lid < stride) {
scratch[lid] += scratch[lid + stride];
}
barrier(CLK_LOCAL_MEM_FENCE);
}
if (lid == 0) {
output[get_group_id(0)] = scratch[0];
}
}
12.4.3 局部内存大小查询
cl_ulong local_mem_size;
clGetDeviceInfo(device, CL_DEVICE_LOCAL_MEM_SIZE, sizeof(local_mem_size), &local_mem_size, NULL);
printf("Local memory: %lu KB\n", local_mem_size / 1024); // 通常 32-64 KB
12.5 常量内存
12.5.1 使用方式
// OpenCL C 中声明
__constant float weights[256]; // 只读,64 KB 限制
__kernel void use_weights(__global float *data, __global float *out) {
int gid = get_global_id(0);
float sum = 0.0f;
for (int i = 0; i < 256; i++) {
sum += data[gid * 256 + i] * weights[i];
}
out[gid] = sum;
}
// 主机端:创建常量缓冲区
cl_mem weights_buf = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
sizeof(weights), weights_data, &err);
// 绑定到内核的 __constant 参数
clSetKernelArg(kernel, 1, sizeof(cl_mem), &weights_buf);
12.5.2 何时使用常量内存
| 场景 | 使用常量内存 |
|---|---|
| 所有线程读取相同的少量数据 | ✅ 高效(广播模式) |
| 查找表(LUT) | ✅ 适合 |
| 不同线程读取不同地址 | ❌ 串行化,性能差 |
| 数据量 > 64 KB | ❌ 超出限制,使用全局内存 |
12.6 私有内存
12.6.1 默认行为
__kernel void private_example(__global float *data) {
int gid = get_global_id(0);
// 以下变量默认在私有内存(寄存器)
float temp = data[gid];
int count = 100;
float results[4]; // 小数组可能在寄存器中
// 如果寄存器溢出,编译器会自动溢出到"私有内存"(实际上是局部/全局内存)
float large_array[1024]; // ⚠️ 可能导致寄存器压力,影响占用率
}
⚠️ 寄存器溢出(Register Spill):当每个线程使用的寄存器过多时,编译器会将变量溢出到慢速内存。使用
__attribute__((register_num(N)))可以提示编译器限制寄存器使用,但最终由编译器决定。
12.7 内存模型对照
| 操作 | 延迟 | 吞吐 | 建议 |
|---|---|---|---|
| 寄存器访问 | ~1 周期 | 最高 | 优先使用 |
| 局部内存读写 | ~20 周期 | 高 | 工作组内共享数据 |
| 全局内存合并读 | ~200 周期 | 中 | 尽量合并访问 |
| 全局内存随机读 | ~400+ 周期 | 低 | 避免 |
| 常量内存广播 | ~20 周期 | 高(同地址) | 所有线程读同一地址 |
12.8 内核编程最佳实践
12.8.1 工作组大小选择
// 通常选择 64、128、256、512、1024
size_t local_size = 256;
// 必须是 2 的幂(某些设备要求)
// 必须 ≤ CL_DEVICE_MAX_WORK_GROUP_SIZE
// 必须能整除 global_size
12.8.2 条件分支优化
// ❌ 差:工作项发散
if (gid % 2 == 0) {
// 路径 A
} else {
// 路径 B → GPU 执行两路
}
// ✅ 好:让工作项走同一路径
// 数据预处理,将偶数/奇数分到不同工作组
12.8.3 除法与取模优化
// ❌ 差:运行时除法
int row = gid / width;
// ✅ 好:如果 width 是 2 的幂,使用位运算
int row = gid >> log2_width;
int col = gid & (width - 1);
12.9 完整示例:矩阵乘法
// matrix_mul.cl - 利用局部内存的矩阵乘法
#define TILE_SIZE 16
__kernel void matrix_mul(
__global const float *A,
__global const float *B,
__global float *C,
const int M, // A 的行数
const int N, // B 的列数
const int K // A 的列数 = B 的行数
) {
int row = get_global_id(0);
int col = get_global_id(1);
int lid_row = get_local_id(0);
int lid_col = get_local_id(1);
__local float tileA[TILE_SIZE][TILE_SIZE];
__local float tileB[TILE_SIZE][TILE_SIZE];
float sum = 0.0f;
// 分块计算
for (int t = 0; t < K; t += TILE_SIZE) {
// 协作加载到局部内存
tileA[lid_row][lid_col] = A[row * K + t + lid_col];
tileB[lid_row][lid_col] = B[(t + lid_row) * N + col];
barrier(CLK_LOCAL_MEM_FENCE);
// 计算当前块的部分积
for (int k = 0; k < TILE_SIZE; k++) {
sum += tileA[lid_row][k] * tileB[k][lid_col];
}
barrier(CLK_LOCAL_MEM_FENCE);
}
if (row < M && col < N) {
C[row * N + col] = sum;
}
}
矩阵乘法分块:
A (M×K) B (K×N) C (M×N)
┌─────┬─────┐ ┌─────┬─────┐ ┌─────┬─────┐
│ tile│ tile│ │ tile│ tile│ │ │ │
│ A │ A │ × │ B │ B │ = │ C │ C │
├─────┼─────┤ ├─────┼─────┤ ├─────┼─────┤
│ tile│ tile│ │ tile│ tile│ │ │ │
│ A │ A │ │ B │ B │ │ C │ C │
└─────┴─────┘ └─────┴─────┘ └─────┴─────┘
12.10 注意事项
⚠️ barrier 的正确使用:
barrier(CLK_LOCAL_MEM_FENCE)必须在所有工作项中统一执行。不能放在条件分支中(除非所有分支都到达 barrier)。
⚠️ 局部内存大小限制:通常 32-48 KB。超出会导致内核启动失败。通过
clGetDeviceInfo查询CL_DEVICE_LOCAL_MEM_SIZE。
⚠️ bank conflict:当多个线程访问同一内存 bank 的不同地址时,会发生 bank conflict。可以通过填充(padding)避免。
⚠️ 内核参数大小限制:单个内核参数的大小有限制(通常几 KB)。大量数据必须通过缓冲区传递。
12.11 业务场景
场景 1:图像卷积
将图像分块加载到局部内存,每个工作组处理一个图块,利用局部内存的高带宽加速卷积计算。
场景 2:直方图计算
使用局部内存累积工作组内的直方图,最后原子操作合并到全局直方图。
场景 3:稀疏矩阵求解器
SpMV(稀疏矩阵向量乘法)中,利用局部内存缓存共享的向量片段。
12.12 扩展阅读
| 资源 | 说明 |
|---|---|
| OpenCL Best Practices Guide | OpenCL C 规范 |
| AMD OpenCL Optimization | AMD GPU 优化指南 |
| NVIDIA OpenCL Best Practices | NVIDIA 优化建议 |
本章小结
- 内核参数通过
clSetKernelArg设置,内存对象传递地址,标量传递值 - 内存层次:寄存器 → 局部 → 常量 → 全局,速度递减
- 全局内存优化关键:合并访问、避免 bank conflict、使用向量类型
- 局部内存用于工作组内数据共享和复用,带宽远高于全局内存
- 常量内存适合所有线程读取相同数据的场景
- 并行规约是利用局部内存的经典模式
上一章:第 11 章:OpenCL 基础 下一章:第 13 章:OpenCL 进阶