移动端AI推理对速度和功耗要求极高。传统的CPU卷积计算密集,难以满足实时性需求。将计算任务迁移到移动GPU上是主流的加速策略,而OpenGL ES (GLES) 和 Vulkan Shaders是实现这一目标的核心工具。
本文将聚焦于如何利用GLES的Fragment Shader(在移动端常用于GPGPU计算)结合高效的数据打包策略(Texture Packing),实现高性能的卷积运算。
1. 为什么选择Shader进行卷积?
GPU本质上是并行处理器,特别擅长处理图像和矩阵运算。Shader程序(如GLSL)允许我们将复杂的张量运算分解成数千个独立的线程(Fragment或Compute Thread),每个线程负责计算输出张量的一个或一组元素。这种大规模并行性是实现“极速”卷积的关键。
2. 核心加速策略:4通道数据打包(Texture Packing)
移动GPU对纹理(Texture)访问速度远高于通用内存缓冲区(Buffer)。我们利用纹理的RGBA四通道来存储张量数据,实现以下目标:
- 输入数据打包: 将输入特征图 $F_{in}$ 的通道数从 $C_{in}$ 降为 $C_{in}/4$,将原本的 $C_{in}$ 个通道数据存储到 $C_{in}/4$ 张纹理的RGBA分量中。
- 输出数据并行: 一个 Fragment Shader 线程不再只计算一个输出通道 $C_{out}$ 的一个像素点,而是同时计算 $C_{out}, C_{out+1}, C_{out+2}, C_{out+3}$ 这四个通道的同一像素点,并将结果写入一个 vec4 中。
3. GLSL Shader实现核心:4×4 MADD操作
由于我们同时处理4个输入通道(RGBA)和4个输出通道,一个典型的卷积操作在Shader内部就转化为了大量的 Fused Multiply-Add (MADD) 操作。
以下是一个简化的3×3卷积(假设步长为1,无填充)的GLSL Fragment Shader核心逻辑。我们假设权重和输入都已经打包成纹理,并且我们正在处理4个输出通道:
#version 300 es
precision highp float;
// 输入特征图 (已打包成 RGBA)
uniform sampler2D u_InputData;
// 权重纹理 (包含 (KxK) * C_in/4 * C_out/4 维度的权重)
uniform sampler2D u_WeightData;
// 纹理坐标,通常由 CPU 计算得出,指向当前要计算的输出位置
in vec2 v_TexCoord;
// 输出4个通道的结果
out vec4 FragColor;
// 辅助常量
uniform float u_TexelStep;
uniform float u_BiasOffset;
void main() {
// 初始化四个输出通道的累加器
vec4 out_result = vec4(0.0);
// 确定当前Fragment在输出张量中的位置 (用于查找偏置和权重)
// float out_C_group_idx = floor(gl_FragCoord.x);
// float out_W_H_idx = gl_FragCoord.y;
// -------------------------------------------------------------------
// 1. 迭代输入通道组 (Input Channel Group Iteration, C_in / 4)
// 在实际的移动端推理库中,这是一个循环,遍历所有的输入通道组。
// 假设我们当前只处理一个输入组(C_in=4)以简化示例:
// -------------------------------------------------------------------
// 假设当前权重组的纹理坐标 (需要根据当前计算的 C_out 组和 C_in 组计算)
vec2 current_weight_coord = vec2(0.1, 0.5); // 示例坐标
// 遍历 3x3 卷积核的空间维度
for (int k_y = -1; k_y <= 1; k_y++) {
for (int k_x = -1; k_x <= 1; k_x++) {
// 1.1 计算输入采样坐标
// u_TexelStep 是 1.0 / Width
vec2 sample_coord = v_TexCoord + vec2(float(k_x), float(k_y)) * u_TexelStep;
// 1.2 读取输入的 RGBA 数据 (4个输入通道)
vec4 input_data = texture(u_InputData, sample_coord);
// 1.3 读取权重数据 (权重纹理包含 KxKx(C_in/4)x(C_out/4) 的数据)
// 这里的权重查找是最复杂的部分,需要精确地找到对应 k_x, k_y, C_in_group, C_out_group 的4x4权重块
// 假设我们已将4个输出通道 (R0, G0, B0, A0) 对应的 4x4 权重块读取为 4 个 vec4:
// W_i,j,k: i=空间索引, j=输入通道, k=输出通道
// 读取 4 个输出通道的权重 (针对当前 k_x, k_y 和当前输入组)
vec4 w0 = texture(u_WeightData, current_weight_coord); // W_R
vec4 w1 = texture(u_WeightData, current_weight_coord + u_BiasOffset); // W_G
vec4 w2 = texture(u_WeightData, current_weight_coord + 2.0 * u_BiasOffset); // W_B
vec4 w3 = texture(u_WeightData, current_weight_coord + 3.0 * u_BiasOffset); // W_A
// 1.4 执行 4x4 MADD 操作 (使用 dot product 加速)
// 输出通道 R0 的计算: dot(Input R G B A, Weight R0 G0 B0 A0)
out_result.r += dot(input_data, w0);
// 输出通道 G0 的计算
out_result.g += dot(input_data, w1);
// 输出通道 B0 的计算
out_result.b += dot(input_data, w2);
// 输出通道 A0 的计算
out_result.a += dot(input_data, w3);
// 更新权重查找坐标...
}
}
// 2. 添加偏置和激活函数 (ReLU)
// 偏置也通常从另一个纹理读取
uniform vec4 u_Bias;
out_result += u_Bias;
// 应用激活函数
out_result = max(out_result, vec4(0.0)); // ReLU
FragColor = out_result;
}
4. 实践中的关键点
- GLES vs Vulkan: 虽然示例是基于 GLES 3.0+ 的 Fragment Shader,但 Vulkan Compute Shader 是现代移动设备上更推荐的 GPGPU 方式。Vulkan 提供了更细粒度的控制(如局部工作组大小),避免了 Fragment/Viewport 限制,能达到更高的性能,但其设置复杂度远高于 GLES。
- 数据精度: 移动端通常使用 mediump 或 highp float。为了加速和减少带宽,FP16(半精度浮点)是首选。确保您的 GLES 环境支持半精度纹理 (GL_HALF_FLOAT_OES)。
- 权重预处理: 权重必须在 CPU 端被重新排列和打包,以确保 Shader 在访问时具有内存局部性,避免随机访问导致性能下降。
通过这种纹理打包和并行计算的策略,移动端 GPU 可以高效地并行执行数千次点积操作,从而实现比纯 CPU 或非优化 GPU 代码快数倍甚至数十倍的卷积运算。
汤不热吧