1. 项目概述与整体设计思路
1.1 这个系列要解决什么问题
写《OpenCL编程系列》走到第三篇,前两篇我们把平台模型、执行模型、内存模型这些地基夯了一遍。到了这一篇,我觉得最适合聊的,就是算子实现与优化——因为这是OpenCL从“纸面知识”走向“能干活”的关键一跃。
很多开发者看完前两篇,对上下文、命令队列、内核函数这些名词很耳熟,但真要写一个算子跑起来,马上会遇到一堆问题:为什么同样的算法,别人在GPU上能跑到几百GFLOPS,我的实现却连CPU都不如?为什么点击运行后显存占用高得离谱?为什么某个内核在A卡上跑得飞快,换到N卡上反而更慢?这一篇就是要把这些问题从原理到实操拆开揉碎。
虽然我一直强调OpenCL与API无关、只有一个标准,但实际编码中平台差异的坑一点都不少。本篇的主体思路,是用一个矩阵乘法算子贯穿全程:从最朴素的实现出发,逐步加入分块、向量化、访存优化、局部内存复用等手法,让读者亲眼看到每一步优化带来多少收益。矩阵乘法选它当样例有一个原因——它是访存密集与计算密集的混合体,几乎能覆盖OpenCL算子优化的所有核心关注点,而且可复现性强,有标杆结果能对照。
1.2 我为谁写这篇文章
这篇面向的读者,我觉得可以分两类。第一类是已经学完OpenCL基础语法、知道怎么把kernel跑起来,但对性能没有直感的人。第二类是做图像处理、物理仿真、科学计算等方向、正在评估OpenCL能否满足性能要求的工程师。
不论你属于哪类,读这篇文章前建议先准备好两样东西:一台支持OpenCL的设备(CPU核显独立显卡均可,2GB显存以上体验流畅),以及一个能编译运行OpenCL的环境。Windows下直接用厂商驱动和Visual Studio就很顺手,Linux下配好驱动、安装CL加载库即可,macOS的历史遗留问题会在第三节单独讲。
1.3 优化工作的完整决策链路
做算子优化切忌闭眼硬调。我推荐的决策顺序是先硬件后软件、先算法后实现:先搞清楚目标设备的hierarchy(有多少计算单元、每单元多少ALU、局部存储多大、全局带宽多少),再确定算子的访存模式与计算强度,然后选择分块大小、向量宽度、访存策略,最后才是调编译器选项与微调参数。
这个顺序对应着OpenCL实现流程中的三重映射:程序员写的NDRange怎么映射到设备上的工作组,工作组里的工作项怎么映射到计算单元上的处理元件,以及数据怎么在这条映射链路上流动。任何一个映射环节出现瓶颈,优化都难见成效。有些初学者一上来就调-cl-fast-relaxed-math,或者把local_size从16改成32觉得性能没变就懵了,核心原因就是没用这条决策链去定位木桶的短板。
2. 内存模型与调度机制:理解优化的硬边界
2.1 全局内存、局部内存与私有内存的读写成本差异
OpenCL的内存架构可以类比成不同层级的“储物柜”。全局内存在显存里,容量最大,所有工作项都能访问,但延迟通常在四五百个周期起步;局部内存在工作组共享的计算单元旁,容量通常只有几十KB,延迟降低一个数量级;私有内存则是寄存器堆旁边的专属空间,一个工作项独享。还有一个更难感知的层级——每个工作项特有的常量内存与图像内存路径,通常走专门的缓存通道。
实测下来,在同等数据规模下,一个工作项从全局内存取数比从局部内存取数慢大概10到30倍。这个数值受设备架构影响较大,但结论是通用的:想优化算子性能,第一个目标永远是减少慢速内存的访问次数,而不是盯着计算部分死磕。很多人一上来就改运算逻辑、加指令,结果收益微乎其微,因为瓶颈根本不在计算,而在取数。
所有工作项并非同时访问全局内存的任意位置。OpenCL执行模型里,同一时刻只有一个工作组内的全部工作项被打到计算单元上执行,不同工作组之间靠调度器协调。这意味着局部内存的复用边界就是工作组的边界,分块方案是否合理,直接取决于工作项在一个工作组内的组织方式。
2.2 NDRange、工作组大小与计算单元负载的关系
clEnqueueNDRangeKernel传入的global_work_size必须能被local_work_size整除,这是初学最容易踩的硬性约束。实际操作时,global_work_size会被内部折成group(工作组)数,每个工作组在设备上调度为一个计算单元的子任务。如果local_size设置得不合理,会出现两种情况:要么工作组数少于计算单元数造成空闲,要么工作组内工作项数过多导致局部内存溢出或寄存器压力过大。
我自己的经验公式是:工作组数最好是计算单元数的整数倍,比如4倍或8倍,这样能保证负载均衡同时留下调度余量;local_size优先选设备厂商推荐的倍数,A卡常见64或256,N卡常见128或512,但这只是经验起点,最终按实测调。
另外要特别提醒一个点:global_work_size不是总能被local_work_size整除的。一个稳妥的解法是把global_work_size上取整到local_work_size的倍数,然后在kernel入口写一个边界判断if (idx >= n) return;。性能损失几乎可以忽略,但彻底消除了越界访问的崩溃风险。这个习惯我建议从一开始就养成。
2.3 工作项同步与内存栅栏的正确用法
工作组内用barrier(CLK_LOCAL_MEM_FENCE)做同步是OpenCL开发者的日常,但必须明确:OpenCL没有工作组间的全局同步原语,世界范围内的“全局屏障”只能靠多次kernel启动或者细粒度原子操作模拟。这个约束带来一个常见设计模式——把算法整个流程拆成若干个kernel顺序执行,每个kernel负责数据处理的一个阶段。虽然增加了排队开销,但代码逻辑清晰、可调性强,实际性能往往不比在一个巨型kernel里硬憋全局同步差。
用barrier的时候还有两个隐蔽的坑。第一,如果工作组里有工作项提前返回退出,barrier会导致同组其他工作项死锁,必须保证所有工作项都执行到同一个barrier;第二,barrier不能写在条件分支体里,除非你能静态证明所有工作项都走同一条分支。局部内存读写的顺序一致性依赖mem_fence来保证,这属于细粒度控制,第一次做优化时可以先不管,等到处理并行规约这类复杂数据依赖再回来研究。
3. 算子实现核心细节:从一个简单的矩阵乘法说起
3.1 朴素矩阵乘法的kernel实现与缺陷分析
我们用一个经典的矩阵乘法算子C = A * B开场,矩阵尺寸取M=N=K=1024。最直观的写法是让每个工作项算一个输出元素:
__kernel void matmul_native( __global const float* A, __global const float* B, __global float* C, const int M, const int N, const int K) { int row = get_global_id(0); int col = get_global_id(1); if (row >= M || col >= N) return; float sum = 0.0f; for (int k = 0; k < K; +k) { sum += A[row * K + k] * B[k * N + col]; } C[row * N + col] = sum; }这里三次循环中的每次乘法都要访问一次A和一次B的全局内存。访存量是2×K次全局访问,计算量也是K次乘加,但两者一比,计算强度只有约0.5次浮点操作/字节。现代GPU做这种计算强度低于10的操作,基本都在等数据从显存送过来,ALU几乎在闲置。
实测一下,这个实现跑在常见的GPU上性能上限大约百G左右,但更麻烦的是它会“跑不满”:大量工作项各自独立取数,不同工作项访问的内存地址跳跃跨度大,缓存命中率低,显存带宽被浪费在无效的行切换上。这一步的目的不是追求性能,而是看清瓶颈,给后续优化立一个对照组。
3.2 分块策略的核心思路与block尺寸推导
业界对于矩阵乘法这类算子,通用答案是分块(tiling)。分块思想不是OpenCL特有,是经典计算机体系结构中的老朋友——把数据切成能塞进局部内存的块,使数据可以被复用多次再替换出去。
以大小为TILE=16的分块为例,每个工作组负责计算输出矩阵中16×16的小块。内核执行前,先把A矩阵中对应的16×K条带状数据和B矩阵中对应的K×16条带状数据拉进局部内存,这样后续计算中A的每一列与B的每一行都能在局部内存里直接被反复读取,不再频繁命中全局内存。
分块参数的选取有讲究。理论上TILE越大,数据复用率越高,但局部内存容量是硬约束。以16×16的float矩阵为例,两个局部缓冲区各占1KB,加起来2KB,对绝大多数设备都不算压力;换成32×32,单缓冲区就4KB,乘2后8KB,一些老设备可能紧张。因此推荐初次优化从16开始,逐步往上探,超过设备局部存储上限时clGetDeviceInfo(CL_DEVICE_LOCAL_MEM_SIZE)会给出明确数字,先查它再定尺寸。
3.3 如何用局部内存重写算子并处理边界
引入局部内存后的kernel多了几个步骤:声明局部缓冲区、加载全局数据、同步栅栏、从局部缓冲区取数计算、再同步栅栏、写回结果。以TILE=16为例,核心代码如下:
#define TILE 16 __kernel void matmul_tiled( __global const float* A, __global const float* B, __global float* C, const int M, const int N, const int K) { const int row = get_group_id(0) * TILE + get_local_id(0); const int col = get_group_id(1) * TILE + get_local_id(1); __local float Asub[TILE][TILE]; __local float Bsub[TILE][TILE]; float sum = 0.0f; for (int tile = 0; tile < K / TILE; +tile) { const int a_idx = row * K + tile * TILE + get_local_id(1); const int b_idx = (tile * TILE + get_local_id(0)) * N + col; Asub[get_local_id(0)][get_local_id(1)] = A[a_idx]; Bsub[get_local_id(0)][get_local_id(1)] = B[b_idx]; barrier(CLK_LOCAL_MEM_FENCE); for (int k = 0; k < TILE; +k) { sum += Asub[get_local_id(0)][k] * Bsub[k][get_local_id(1)]; } barrier(CLK_LOCAL_MEM_FENCE); } if (row < M && col < N) { C[row * N + col] = sum; } }注意加载数据时用get_local_id而不是get_global_id做索引,这样才能让同一个工作组里的工作项协作把整块数据搬进局部内存。barrier出现在每次tile迭代的首尾:开头保证所有数据加载完才能开始计算,结尾保证所有计算都读完当前局部缓冲区的数据后才能覆盖新的一轮内容。
边界处理上,如果M或N不是TILE的整数倍,加载局部内存时会读到越界地址。可以在加载时加判断,也可以把全局缓冲区多分配一些并清零,使边界块也能安全读取。实际项目中我倾向后者,因为统一了逻辑,kernel里不用额外分支,对性能更友好。
3.4 向量化:float4与内存合并访问的收益
GPU计算能力的释放离不开向量化。OpenCL开发者说的向量化有两种含义:一种是语言层面的float4类型,让编译器生成SIMD指令;另一种是访问模式上的内存合并,让同一half-warp内的线程访问连续地址。分块实现已经把模型梳理得足够整齐,这一步要做的是把局部内存的矩阵按向量宽度重排。
以float4为例,可以把Asub重新组织成向量数组,并让内层循环一次处理TILE=16中的4个连续元素。核心修改包括:局部缓冲区从二维float变成一维float4,索引同步乘以4,累加循环每次从Asub取一个float4类型的数据与Bsub中对应元素做乘加。
实测中,向量化通常能带来20%到50%的额外提升,前提是你的设备ALU宽度确实能消化向量指令。老一代GPU(比如某些移动SoC)对float4的特殊优化很少,收益没那么明显;新型独立显卡则几乎都是为向量指令定制的。这块优化到头之后,就应该把焦点从计算挪回到访存上,也就是下一节要展开的内容。
4. 优化实战:让算子贴合内存系统的真实访存模式
4.1 全局内存合并访问的规则与矩阵转置的教训
显存带宽优化的第一法则是“合并访问”——同一时刻一个内存事务应尽可能传输连续地址的数据。GPU的内存控制器喜欢把相邻工作项访问同一行的访问合并成一个宽事务。假设一个wavefront包含64个工作项,如果它们各自从不同行取数,那就需要64个独立事务;如果它们从同一行连续取数,只需要1个宽事务。这个数量级差异直接决定了访存效率。
矩阵转置算子几乎是合并访问的反面典型。朴素实现中,读入一行是合并的,但写入一列时就完全摊开——每个工作项写不同行的同一列,地址跳跃幅度极大。我写过一个转置算子的测试,朴素版本在带宽上只能达到理论峰值的30%左右,连续踩坑后改成“共享内存分块转置”——先把一块数据按合并方式读入局部内存,然后以合并方式写回全局内存,性能立刻回到80%以上。
这类问题在图像算子中体现为像素布局的选择:连续读取图像的行数据,避免按列扫描;在卷积算子中,则体现为输入特征图的布局是否需要通道分离。设计算子时如果能把全局数据访问路径统一成“按行连续读取、按行连续写入”,基本就解决了一大半访存优化。
4.2 局部内存的bank冲突与规避技巧
局部内存在硬件上分为多个bank,同一bank在同一时钟周期只能响应一次读访问。如果多个工作项同时访问同一bank的不同地址,就会产生冲突,导致访问串行化。这个坑极其隐蔽,不对照架构手册根本看不出来。
以常见的32 bank布局为例,局部内存地址按字长连续映射到bank。当我们声明一个__local float tile[TILE][TILE]并按tile[row][k]循环时,工作项内部对k的访问模式是:同一行工作项在同一个时钟周期访问不同k的值,可能命中的恰好是同一个bank。最典型的冲突是矩阵转置:源矩阵按行读是连续的,但按列写入局部缓冲时,同一周期写入的是一列的连续元素,bank号冲突。
解决办法是填充(padding)。把局部缓冲区声明成TILE行每行TILE+1列,让列方向多出一个字的偏移,bank映射自然错开。代价是浪费一点容量,换来的是访存冲突大幅下降。我先声明一个#define TILE_PAD (TILE + 1),然后所有引用都用它,实测冲突率基本清零。
4.3 内核参数调优:work-group size与向量宽度的组合试验
优化收尾阶段有一个必须做的步骤,就是参数网格搜索。OpenCL的每个算子都有最合适的local_size与向量宽度组合,而这个组合对设备架构高度敏感,纸上谈兵不如直接跑一轮梯度式实验。
我自己常用的方法是固定矩阵规模,遍历local_size取值(8/16/32/64/128/256)与向量宽度(1/2/4/8)的组合,记录耗时与带宽,生成一张表格。有一次我在调一个图像滤波算子时,local_size=128加float4的组合比local_size=64加float2快了整整3倍,这个幅度很难靠直觉猜中。
还有一点值得提:kernel里的寄存器占用会影响local_size的调度。local_size=256时寄存器分配过紧会导致编译器把多余数据溢出到局部内存,反而拖慢速度。调大local_size之前,先看一眼编译日志(clBuildProgram的CL_BUILD_PROGRAM_LOG)里有没有寄存器溢出警告,能省掉很多无用功。
4.4 图像内存与常量内存:适合特定算子的隐藏路径
OpenCL里常常被人忽略的image对象与__constant地址空间,其实是两类特殊算子的性能利器。图像内存专门优化了二维空间访问局部性——缓存命中率远高于普通全局内存指针,且硬件内置双线性插值。如果算子是图像滤波、缩放、特征提取这类强空间局部性的任务,用read_imagef这类内置函数揉合缓存与插值,比自研采样器性能高很多。
__constant内存则在所有工作项读取同一块数据时非常高效。比如卷积核的权重、矩阵乘法的B矩阵固定部分,一旦存入常量内存,硬件广播机制让同一地址的读取近乎零成本。前提是数据量要小,通常限制在64KB以内,超过这个容量就退化成普通全局读,没有任何优势。
5. 性能剖析:设备端计时与瓶颈定向定位
5.1 事件计时:精确测出每个执行阶段的开销
优化不能靠感觉,必须量化。OpenCL的事件对象天然支持GPU端计时。在clEnqueueNDRangeKernel时带上event,然后调用clWaitForEvents,再读事件的CL_PROFILING_COMMAND_START与CL_PROFILING_COMMAND_END时间戳,就能拿到内核在设备上的纯执行时间。这里需要先在命令队列创建时启用CL_QUEUE_PROFILING_ENABLE标志。
需要注意的是,这两个时间戳覆盖的范围是内核在设备上排队到完成的时间,不包含数据在主机和设备间的拷贝时间。数据拷贝的开销要单独用clEnqueueReadBuffer或clEnqueueWriteBuffer的事件来测。整个流程的优化对象不只是计算内核,显存传输往往占总耗时的40%以上,主机与设备间的拷贝策略经常是算子性能的隐形天花板。
5.2 用AMD与Intel提供的开源剖析工具定位关键热点
除了手写事件计时,各家厂商的开源工具也能帮上大忙。AMD提供的分析工具可以输出内核执行中各硬件单元的利用率,包括ALU占用率、内存事务数量、局部内存吞吐量等;Intel的同类工具也能给出 occupancy、bank conflict 等统计。这些数据能直接告诉你瓶颈在哪一个具体环节,而不必靠猜测去试。
我用这类工具调过一个物理模拟算子,最后发现ALU利用率只有11%,而内存写入事务数量是理论值的6倍。按图索骥找到问题出在写入模式不合并,改成按行分块后直接提升到70%。没有剖析工具,这种问题可能需要痛苦迭代数天。
5.3 实战中经常遇到的性能计数器坑与校准方式
性能计数器不是完美的。大多数GPU计数器的精度受时钟频率动态影响,开启动态频率调整时,计数器数值会漂移。跑性能分析前,最好在驱动层面锁定GPU频率,保证结果可复现。还有一个常见问题:调试模式下驱动会把内核编译成未优化版本,导致计数器和实际性能严重不符,务必用release模式跑测试。
校准方式是反复跑同一算子取中位数。单次运行结果受缓存预热、上下文切换影响很大,取3轮每轮20次的中位数比较靠谱。缓存预热可以在正式计时前先跑几次同尺寸的数据,让缓存与调度器进入稳定状态。这个习惯我每次写性能报告前都会执行,避免被极端值骗了。
6. 常见问题与避坑心得:实测汇总
6.1 六大高频问题的现象、原因与解法速查
开发OpenCL算子,有一批错误出现频率极高。我在下面列成速查表,按实际排障优先级排列:
| 问题现象 | 根本原因 | 推荐解法 |
|---|---|---|
| 程序崩溃或访问违规 | 全局内存越界 | 内核入口加边界判断,或主机端分配填充区 |
| 数据错乱但概率性出现 | 局部内存同步缺失 | 检查barrier(CLK_LOCAL_MEM_FENCE)位置与所有分支路径 |
| 性能远低于预期 | 工作组规模过小/寄存器溢出 | 调整local_size,检查编译日志 |
| 每次运行结果不一致 | 不同工作组竞争同一输出地址 | 重新设计输出映射,避免无原子保护的随机写 |
| 大尺寸数据处理变慢 | 全局内存分配未对齐 | 使用16字节对齐分配,配合向量类型访问 |
| 编译失败且日志为空 | 私有数组过大,栈溢出 | 降低local_size或把大数组改到局部内存 |
第二行提到的“数据错乱但概率性出现”特别容易迷惑人。它不一定是你逻辑错了,而是工作项之间在局部内存上有数据依赖,但同步没做彻底。这种问题的排查方式是用小尺寸数据反复运行,确认是否与分组大小相关,再把kernel拆成纯计算版本对比结果,基本能定位到同步失效的循环位置。
6.2 平台差异陷阱:Windows、Linux与macOS的OpenCL状态差异
OpenCL的“一次编写,处处运行”愿景在现实中打折,不同平台对标准的实现程度参差不齐。Windows上Intel与NVIDIA的驱动实现比较成熟,Linux下AMD的ROCm、Intel的NEO驱动各有各的坑。最头疼的是macOS——苹果已经停止更新OpenCL运行时,仅对自家GPU内核做适配,新硬件上的OpenCL性能参数不一定真实反映物理能力,部分特性在macOS下形同虚设。
遇到平台差异问题,第一时间去clGetDeviceInfo查一下CL_DEVICE_OPENCL_C_VERSION、CL_DEVICE_EXTENSIONS与CL_DEVICE_MAX_WORK_GROUP_SIZE这些硬指标。同一份代码在目标平台上能否激活cl_khr_fp16、cl_khr_global_int32_base_atomics这类扩展,也是由这些值决定的。我开发时习惯用宏把不同平台行为包住,拿到新设备先跑一个自检kernel打印全部参数,再决定往哪个优化方向走。
6.3 编译器选项与构建日志:被低估的两个调优工具
clBuildProgram第三个参数支持编译器选项,很多人直接传NULL,等于放弃了OpenCL的免费优化机会。-cl-fast-relaxed-math可以让编译器使用更激进的浮点指令重排,但会牺牲NaN与Inf的处理精度,适合数值稳定需求不高的算子;-cl-opt-disable只在debug时用,发布前记得去掉。另一个被忽视的是-cl-single-precision-constant,如果kernel里混用了双精度字面量和单精度变量,它能把常量统一转成单精度,减少不必要的double运算开销。
构建日志相当重要。如果clBuildProgram返回错误,调用clGetProgramBuildInfo(..., CL_PROGRAM_BUILD_LOG, ...)拿日志,里面会明确提示是哪一行什么错误。很多“程序没反应”的问题,看一眼log就能定位,不用盲目加printf调试。
6.4 我做算子优化常用的几条后验经验
最后分享几条我自己的后验经验,都是代码之外的功夫:
第一,内核函数不要写得太长。OpenCL编译器内部有指令超时和寄存器分配的压力,一个kernel超过百行,常常出现局部变量被降级到内存的情况。拆成多个小kernel分别调优,逻辑清晰且性能更可控。
第二,优先做数学变换,再做指令级优化。比如把除法转换成乘法倒数、把条件分支减到最少,在GPU上的收益往往比单纯调编译器选项高。一个浮点除法在多数GPU上要十几个周期,而乘加只需要一个周期,工作量差距不可同日而语。
第三,别迷信双精度。消费级GPU的双精度性能通常只有单精度的1/8到1/4,能用单精度解决的问题不要用double。如果精度确实撑不住,可以考虑Kahan求和补偿算法,开销远低于切成double。
7. 展望与后续路线:从GEMM到更多算子的方法迁移
7.1 分块优化思路如何迁移到卷积与归约算子
矩阵乘法上练明白的优化思想,可以平移到多种算子。卷积算子本质上是对输入特征图做滑动窗口的点乘累加,可以把每个输出通道当作一组局部数据的加权求和,分块策略换成特征图通道维上的分块,配合图像内存的局部性优化即可。归约算子(sum、max、min)则会遇到更强烈的工作组内数据依赖,采用树形规约加局部内存缓冲,每个阶段配合一次barrier,思路与矩阵乘法中分块迭代完全一致。
矩阵乘法另一个值得关注的方向是稀疏化。现实中很多业务的矩阵稀疏率超过90%,这类算子直接跳过零元素能省下大量无效乘法。OpenCL的原子操作与局部内存组合可以作为稀疏压缩的基础,但稀疏格式的选择(如CSR、CSC)会直接影响并行度,这块算是个独立的课题。
7.2 从OpenCL到SYCL与oneAPI的迁移成本评估
OpenCL的开发经验迁移到SYCL比想象中平滑。SYCL把OpenCL的底层概念封装成高级C++模板接口,不再通过字符串传递内核代码,而是用lambda表达式与现代编译工具链直接集成,类型检查可以提前到编译期。如果你已经熟练掌握了OpenCL的工作组、内存模型和屏障语义,那么SYCL的学习成本基本上只有语法层面的转换。
对于新团队评估技术路线,我的建议是先写OpenCL完成原型验证,毕竟它的兼容面最广、生态最成熟、错误信息也相对直白;如果项目后续有跨厂商架构(CPU、GPU、FPGA)一体化部署需求,再逐步迁移到SYCL,因为后者在表达异构调度与内存管理上更贴合新硬件的抽象层次。迁移时注意原有内核函数里大量依赖的工作项同步逻辑可以原样保留,改动集中在数据分配与提交环节,普通算子迁移成本大约在20%工作量以内。
7.3 后续系列预告与建议的练习路径
这个系列如果继续往下写,我会挑三个方向展开:一是OpenCL与机器学习推理框架结合,重点讲算子融合与数据布局转换;二是多设备并行,讨论如何用流水线方式让CPU与GPU同时开工;三是专用硬件的新特性,比如TensorCore一类可编程矩阵单元如何通过扩展语言接入OpenCL执行模型。
给刚入门的读者一个路径建议:先复现本文的矩阵乘法全流程优化,再自己动手写一个图像模糊算子、一个向量归约算子,各调一轮性能。这些基础算子练完,你对OpenCL的掌控感会完全不一样。遇到卡壳不要硬钻,OpenCL论坛与官方文档的示例代码很多,带着问题去翻资料比闭门造车效率高得多。
我在实际开发里反复确认过:OpenCL的瓶颈从来不在语言本身,而在你有没有建立起一套“先定量、再定位、后优化”的方法论。矩阵乘法只是载体,这套思路才是能泛化的资产。希望这篇能把你的OpenCL功底从一个点拉成一条线,后面碰到什么算子都能有条不紊地开干。