尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
从Cache Miss到43 GFLOPS:循环优化实战指南
前几年我接过一个图像预处理模块的优化任务模块里有一段三层嵌套的卷积循环代码写得干干净净注释齐全怎么都不像有问题的样子。结果用性能工具抓了一遍发现CPU有一大半时间卡在Cache Miss上最内层循环的访存模式乱成一团。我把循环顺序调了调、加了两层分块单帧处理时间直接从89毫秒降到了22毫秒。算法逻辑一行没改改的全是循环结构本身。后来这类工作在我这儿有了一个统一的叫法——Loop Engineering。Loop Engineering这个名字听起来唬人本质上就是一套关于如何分析和改造循环结构来提升程序性能的工程方法。这篇文章我会从最基础的概念讲起带着你把六种最常见的循环变换手段逐个过一遍最后用一个矩阵乘法的实战项目看看这些手段叠加起来到底能把性能拉到什么量级。内容偏C/C方向适合那些已经开始接触性能优化、但还没系统梳理过循环优化技巧的开发者。1. 先搞清楚Loop Engineering到底在解决什么问题1.1 性能的命脉为什么偏偏是循环先摆一个我自己很认同的经验判断一个程序里90%甚至更多的执行时间花在极少数几个热循环上。这不是夸张的说法而是这类问题的普遍规律——循环体是被反复执行的代码哪怕每次迭代只浪费一两条指令循环100万次之后就是百万量级的损失。反过来如果循环体每次能节省一点点收益也会被放大很多倍。循环另一个特别的地方在于它是程序里唯一一种能通过结构调整大幅影响性能的语法结构。函数调用可以通过内联优化条件分支可以通过概率预测但循环的布局、顺序、粒度、数据访问路径直接决定了CPU的Cache怎么工作、流水线怎么流动、SIMD单元能用上几分。你可以换一个更快的算法来绕过性能问题但很多时候算法已经是最优的了剩下的空间全在循环的组织方式上。这就是Loop Engineering存在的理由。用一句我常跟团队说的话来概括普通优化是减少操作而Loop Engineering是不减少操作只改变操作的组织方式。同样做一百次加法怎么把这百次加法喂给硬件让它在最短时间内完成这才是这里的核心命题。1.2 它和普通代码优化的边界在哪很多时候大家把代码优化混为一谈但Loop Engineering有自己明确的关注范围。普通代码优化关心的是算法复杂度、冗余计算、内存分配时机这些做什么的问题而Loop Engineering关心的是怎么组织的问题——循环怎么拆、怎么并、怎么换序、怎么分块、怎么让数据在Cache和寄存器里流动得更顺畅。我自己的判断标准一直是四个维度Cache命中率数据访问是否尽量落在了高速缓存里指令流水线利用CPU的多个执行单元是不是都在干活分支预测循环里的条件跳转是否规律、可预测寄存器压力中间变量是否尽量留在寄存器而不是反复存取内存如果一个循环在这四个维度上都有问题那不管算法多先进硬件再好跑起来都是打折的。Loop Engineering要做的就是通过变换循环结构把程序的行为掰到符合硬件脾气的方向上去。这一点在追求极致性能的场景里尤为重要比如科学计算、图像处理、游戏引擎的渲染管线、数据库的底层存储引擎这些地方往往就靠几个核心循环撑着。2. 读懂循环的基本功先弄清楚瓶颈在哪2.1 判断一个循环的三种瓶颈类型动手优化之前我建议先建立这样一个判断框架任何一个热循环瓶颈都逃不出三种类型——计算密集、访存密集、控制密集。计算密集意味着循环体里的算术指令占主导CPU的浮点单元或者整数单元一直在忙这类循环的优化重点是把运算喂给更多的执行单元典型的做法是循环展开、SIMD向量化。访存密集意味着数据搬运占主导CPU大部分时间在等内存数据送过来ALU反而闲着。这类循环的关键指标是Cache Miss率和内存带宽利用率优化重点在数据布局、循环分块、循环融合这些降低访存开销的手段上。控制密集则是循环内部有大量分支跳转导致CPU的流水线被频繁打断分支预测失败的代价很高。这类情况要么想办法把分支移出循环要么通过数据结构的重新组织让分支变得可预测。怎么区分一个循环到底属于哪一类不要靠猜直接用性能工具看数据。2.2 用perf实际抓一个瓶颈以Linux环境为例最简单的一条命令就是perf stat。假设我们的示例程序是一个遍历大数组做加法的函数编译后跑一下perf stat ./loop_test输出里重点看这几项cycles程序运行消耗的CPU周期数instructions实际执行的指令数cache-misses / cache-referencesCache未命中率branch-misses分支预测失败率一个很实用的判断方式如果instructions per cycle远低于1说明流水线经常空转大概率是访存等待或者依赖链过长如果cache-misses比例超过20%那访存模式基本可以确定是主要矛盾如果branch-misses居高不下就该审视循环里的if判断能不能被消除。再用perf record抓取更细的采样可以看到具体是哪一行代码消耗最大perf record -g ./loop_test perf report我的习惯是没有跑profile之前不做任何优化决策。因为人类对性能瓶颈的直觉通常不太可靠看着像是计算密集的循环实际跑起来可能是个访存瓶颈。数据先说话再动手。2.3 一个快速判断访存压力的概念算术强度顺手补充一个非常实用的分析工具——算术强度。它的定义很朴素一个循环里平均每搬运一个字节的数据能完成多少次浮点运算单位是FLOPs/Byte。举个例子向量加法c[i] a[i] b[i]每次迭代需要读两个double、写一个double总共24字节的访存量只做了1次加法算术强度大概只有0.04 FLOP/Byte非常低。这种循环注定是访存瓶颈你再怎么展开、怎么向量化提升都有限因为内存带宽卡死了。反过来看矩阵乘法的理论算术强度会随分块策略大幅变化。朴素三重循环里每个数据元素反复从内存读入算术强度很低一旦做了循环分块让数据在Cache里反复复用算术强度就会显著上升计算单元才有机会吃饱。这个概念的价值在于它从一开始就告诉你优化方向。算术强度低优先做访存优化——分块、融合、换数据布局。算术强度已经很高再做访存优化就是白费功夫这时候该上向量化、寄存器阻塞这些把计算喂满的手段。3. 六种循环变换手段逐个拆开揉碎3.1 循环展开空间换时间的第一个手段循环展开可能是最直观、也最容易误用的一种手段。原理很简单循环每次迭代都要执行i、判断是否结束、跳回循环头这几条控制指令。如果循环体只干一点点活这些控制指令占总指令的比例就很可观。展开之后控制指令在同样多的计算量里出现的次数变少了同时多个独立的计算操作可以同时进入CPU流水线的不同执行单元指令级并行度自然就上去了。最基础的展开写法长这样// 原始版本 for (int i 0; i n; i) { sum a[i]; } // 展开4次 for (int i 0; i n; i 4) { sum a[i]; sum a[i 1]; sum a[i 2]; sum a[i 3]; } // 处理剩余元素 for (; i n; i) { sum a[i]; }但这里藏着一个非常经典的坑浮点加法是有依赖的。sum a[i]这条语句的结果要成为下一次加法的一个输入四次加法其实是串行等待的关系展开并没有带来真正的并行。解决办法是拆成多个累加器double sum0 0, sum1 0, sum2 0, sum3 0; for (int i 0; i n; i 4) { sum0 a[i]; sum1 a[i 1]; sum2 a[i 2]; sum3 a[i 3]; } sum sum0 sum1 sum2 sum3;四条独立的加法链可以并行推进现代CPU一次能算好几条速度立刻不一样。展开因子一般选4或8不是越大越好——寄存器数量有限展开过猛会导致寄存器溢出反而更慢。3.2 循环分块把数据塞进有限Cache循环分块Loop Tiling是我个人认为Loop Engineering里性价比最高的一个手段。它的出发点特别朴素Cache的大小是有限的而循环遍历的数据范围往往远超Cache容量。数据刚被拉进Cache还没来得及复用就被新的数据挤出去了于是CPU反复去内存里搬数据。分块的做法是把大的遍历空间切成小的块让每个小块在被替换出去之前尽量被多次复用。以矩阵乘法为例朴素写法里B矩阵的每个元素在内层循环中要被反复读取读取范围横跨整行整列Cache完全兜不住。加了分块之后A和B被切成一个个小块每个小块在Cache里待足够长的时间让一次加载能服务多次计算。一个分块后的矩阵乘法核心部分大致长这样#define BK 64 for (int i0 0; i0 n; i0 BK) { for (int k0 0; k0 n; k0 BK) { for (int j0 0; j0 n; j0 BK) { for (int i i0; i i0 BK; i) { for (int k k0; k k0 BK; k) { double a_ik A[i * n k]; for (int j j0; j j0 BK; j) { C[i * n j] a_ik * B[k * n j]; } } } } } }块大小怎么选一个常用的估算方法块占用的数据量大概要能放进目标Cache层级。假设目标是L2 Cache64x64的double小块A、B、C三个矩阵各占64*64*8字节也就是各32KB三个块合计96KB适合装在常见的256KB L2里。块太大Cache装不下块太小复用率又上不去。这个值需要针对具体平台微调但估算逻辑是通用的。3.3 循环融合与分裂拆和合都是一门手艺循环融合Loop Fusion是把两个或多个遍历相同范围的循环合并成一个。最直接的好处是省掉一次完整的遍历开销另外前一个循环产出的数据可能正好还在Cache里后一个循环直接就能复用这叫时间局部性收益。// 融合前两次遍历 for (int i 0; i n; i) { a[i] b[i] c[i]; } for (int i 0; i n; i) { d[i] a[i] * 2; } // 融合后一次遍历a[i] 刚算完就被用上 for (int i 0; i n; i) { a[i] b[i] c[i]; d[i] a[i] * 2; }融合的前提是两个循环的迭代空间一致并且不会因为合并而产生数据依赖问题。比如第一个循环写d[i]第二个循环写a[j]如果j和i的访问范围存在交错就要小心了。循环分裂Loop Fission正好相反把一个循环拆成多个。什么情况下需要拆典型场景是一个循环里既有复杂的浮点计算又有频繁的分支判断。混合在一起编译器做向量化时会被分支卡住做流水线优化时也被拖累。拆开之后每个循环变得纯粹编译器能用的优化手段就多了。另外一个场景是为了并行化——OpenMP或者GPU编程里一个复杂循环拆成多个简单循环各自分给不同的线程单元往往更容易榨出性能。经验上融合多数时候收益更直接因为它实实在在地减少了遍历次数和数据搬运分裂则是为其他优化铺路的手段。3.4 循环反转省掉每一次比较循环反转Loop Inversion是一个比较底层的技巧思路是把带条件判断的for/while循环转换成do-while形式的循环。原理是for循环每次迭代都要做一次i是否小于n的判断但循环第一次进入时这个判断往往是必然成立的改成do-while之后判断从每次迭代提前到每次迭代之后至少省掉一次进入循环前的比较。// 原始写法 for (int i 0; i n; i) { // 循环体 } // 反转后的写法 int i 0; if (n 0) { do { // 循环体 i; } while (i n); }坦白说现代编译器在开优化的情况下基本会自动做这件事手动实现收益微乎其微。但我在嵌入式场景里还是见过手写反转的代码因为那些场景对指令数极其敏感。更重要的是循环反转是一种基础的循环结构变换它往往是为其他优化服务的——比如把循环改写成更容易进行旋转或者展开的形态。理解它的存在对你读懂一些库源码里的怪写法会很有帮助。3.5 SIMD向量化让一条指令干四份活SIMD单指令多数据是CPU提供的并行能力一条指令可以同时对多个数据执行相同操作。比如AVX2指令集里的256位寄存器一次能处理4个double或者8个float。编译器在-O3 -marchnative下会自动尝试把循环向量化但它能成功的前提非常苛刻循环内部没有复杂分支、数据访问是连续的、不存在别名混叠、迭代之间没有依赖。自动向量化失败的时候就需要手动介入了。C/C里最常用的方式是用intrinsics函数。举个向量加法的例子#include immintrin.h void add_vectors_avx(double *a, double *b, double *c, int n) { for (int i 0; i n; i 4) { __m256d va _mm256_loadu_pd(a[i]); __m256d vb _mm256_loadu_pd(b[i]); __m256d vc _mm256_add_pd(va, vb); _mm256_storeu_pd(c[i], vc); } }用_mm256_loadu_pd可以不对齐内存方便但可能略慢如果你用aligned_alloc申请了32字节对齐的内存换成_mm256_load_pd更稳妥。如果CPU支持FMA指令集大多数较新的x86 CPU都支持加法乘法可以一条指令完成__m256d prod _mm256_fmadd_pd(va, vb, vc);向量化最怕的是跨步访问。假设你要处理矩阵的一列而矩阵按行存储相邻元素在内存里隔了整行数据的距离那么即使向量寄存器再宽也必须把数据一个一个地聚拢起来SIMD的优势几乎消耗殆尽。这种情况最有效的解法是配合循环分块先改变数据的访问局部性再谈向量化。3.6 循环不变量外提别在循环里做重复功最后一个手段看起来最不起眼但实际收益往往不小。循环不变量外提Loop-Invariant Code Motion就是把循环体内那些每轮迭代结果都一样、不依赖于迭代变量的计算移到循环外面只算一次。// 低效版本PI * RADIUS * RADIUS 每次迭代都计算一遍 for (int j 0; j n; j) { a[j] b[j] * PI * RADIUS * RADIUS; } // 高效版本先算好常量循环里直接乘 double factor PI * RADIUS * RADIUS; for (int j 0; j n; j) { a[j] b[j] * factor; }编译器本身会做这件事但有几种情况它做不了。最典型的是涉及函数调用的时候——编译器不确定函数是否有副作用不敢随意把包含调用的表达式移出循环。比如循环里写了sqrt(pow(...))编译器可能出于保守而不做外提手动算一次结果放到循环外收益立竿见影。同理循环体内创建临时对象、重复计算不随循环变化的索引地址都属于这类问题。这个思想放大了说就是把重复的事情只做一次只不过在循环场景里重复的次数被放大得极其可观。4. 项目实战矩阵乘法从1 GFLOPS提到43 GFLOPS4.1 基线版本与编译参数理论铺垫完了下面进入实战。我们用矩阵乘法作为案例。选择它有三个原因一是计算模式足够经典几乎所有做过性能优化的人都绕不开二是它的优化空间极大从教科书实现到开源BLAS库性能差距能到20倍以上三是它能同时体现访存优化、分块、向量化、寄存器阻塞这几类核心手段。先写下最朴素的ijk版本void matmul_naive(double *A, double *B, double *C, int n) { for (int i 0; i n; i) { for (int j 0; j n; j) { double sum 0; for (int k 0; k n; k) { sum A[i * n k] * B[k * n j]; } C[i * n j] sum; } } }这个版本的致命问题在内层循环B[k * n j]按列访问。假设矩阵按行主序存储那么B矩阵同一列相邻元素在内存里相隔n个double也就是8n字节。n等于1024时跨度是8KB远超一条Cache line每一列访问几乎都是Cache Miss。CPU大量时间在等内存而不是做乘法。编译和测试的基准命令先定下来gcc -O3 -marchnative matmul.c -o matmul用clock_gettime(CLOCK_MONOTONIC)计时多跑几次取最优值。环境是我手头一台i5-10400的机器单核测试矩阵规模1024x1024。这个版本的成绩大约在2100毫秒左右。矩阵乘法的浮点运算量是2n^3n1024时约21.5亿次运算2100毫秒折算下来正好约1.0 GFLOPS。4.2 第一轮优化循环重排优化访存模式的第一件事是把循环顺序从ijk改成ikj让B矩阵变成按行访问。改动很小void matmul_ikj(double *A, double *B, double *C, int n) { for (int i 0; i n; i) { for (int k 0; k n; k) { double a_ik A[i * n k]; for (int j 0; j n; j) { C[i * n j] a_ik * B[k * n j]; } } } }注意这里A[i * n k]被提到了内层循环外面读一次存到寄存器里反复用B和C都是按行连续访问Cache友好度大幅提高。只改了这一层顺序单核成绩从2100毫秒降到了约520毫秒差不多4.1 GFLOPS。这个提升几乎没有代价所以我把它排在所有矩阵优化策略的第一步。4.3 第二轮优化循环分块下一步在ikj基础上加循环分块让每个数据块在Cache里被反复复用。n1024时块大小选64比较合理。分块版本核心代码就是前面3.2节那段。成绩从520毫秒降到了约230毫秒大约9.3 GFLOPS。这个版本里A和B的每个64x64小块会被反复读取但块的大小能放进L2 Cache因此内存带宽的压力被显著减弱。块大小的选择如果偏大比如256快进L2装不下性能会明显下滑选32又太小复用的效果不够。如果有兴趣可以试着跑一组BK16、32、64、128的对比能看到一个清晰的拐点。4.4 第三轮优化AVX2向量化到了分块阶段数据已经能稳定地留在Cache里了但CPU的计算单元还在半饥饿状态——一次只做一个double的乘法。接下来用AVX2做内层向量化每次处理4个double。#include immintrin.h void matmul_blocked_avx(double *A, double *B, double *C, int n) { const int BK 64; for (int i0 0; i0 n; i0 BK) { for (int k0 0; k0 n; k0 BK) { for (int j0 0; j0 n; j0 BK) { for (int i i0; i i0 BK; i) { for (int k k0; k k0 BK; k) { __m256d a_ik _mm256_set1_pd(A[i * n k]); for (int j j0; j j0 BK; j 4) { __m256d c _mm256_loadu_pd(C[i * n j]); __m256d b _mm256_loadu_pd(B[k * n j]); __m256d prod _mm256_fmadd_pd(a_ik, b, c); _mm256_storeu_pd(C[i * n j], prod); } } } } } } }_mm256_set1_pd把标量广播到向量寄存器。_mm256_fmadd_pd一次完成乘加两条操作吞吐又翻了一倍。注意这里j的步长是4理想情况下n和BK都应该能整除4否则要在循环末尾补上剩余元素的标量处理。这段代码跑下来耗时降到了约80毫秒26.8 GFLOPS。4.5 第四轮优化寄存器阻塞向量化把计算单元喂饱了一半再进一步就要考虑寄存器阻塞了。核心思想是在某个小范围内把C矩阵的部分累加结果全部留在CPU寄存器里而不是每次算完就写回内存。这样最小化对C的读写次数同时提高A、B元素的复用率。写一个4x4的微内核作为示范。每次迭代从A读4个元素、从B读4个元素完成16次乘加全部累加在16个局部变量里void matmul_microkernel(double *A, double *B, double *C, int n) { for (int i 0; i n; i 4) { for (int j 0; j n; j 4) { double c00 0, c01 0, c02 0, c03 0; double c10 0, c11 0, c12 0, c13 0; double c20 0, c21 0, c22 0, c23 0; double c30 0, c31 0, c32 0, c33 0; for (int k 0; k n; k) { double a0 A[(i 0) * n k]; double a1 A[(i 1) * n k]; double a2 A[(i 2) * n k]; double a3 A[(i 3) * n k]; double b0 B[k * n j 0]; double b1 B[k * n j 1]; double b2 B[k * n j 2]; double b3 B[k * n j 3]; c00 a0 * b0; c01 a0 * b1; c02 a0 * b2; c03 a0 * b3; c10 a1 * b0; c11 a1 * b1; c12 a1 * b2; c13 a1 * b3; c20 a2 * b0; c21 a2 * b1; c22 a2 * b2; c23 a2 * b3; c30 a3 * b0; c31 a3 * b1; c32 a3 * b2; c33 a3 * b3; } C[(i 0) * n j 0] c00; C[(i 0) * n j 1] c01; C[(i 0) * n j 2] c02; C[(i 0) * n j 3] c03; C[(i 1) * n j 0] c10; C[(i 1) * n j 1] c11; C[(i 1) * n j 2] c12; C[(i 1) * n j 3] c13; C[(i 2) * n j 0] c20; C[(i 2) * n j 1] c21; C[(i 2) * n j 2] c22; C[(i 2) * n j 3] c23; C[(i 3) * n j 0] c30; C[(i 3) * n j 1] c31; C[(i 3) * n j 2] c32; C[(i 3) * n j 3] c33; } } }这个版本在分块的基础上把对C的写回操作减少到了每16个结果一次而每次内层迭代读取的A和B元素都在内部重复用了4次寄存器里的复用做到了极致。跑到这一步耗时就到了约50毫秒43 GFLOPS。再往上走OpenBLAS那种级别的实现会用更大的微内核、多级分块、甚至手写汇编指令调度60到100 GFLOPS都是可能的但对这篇文章的场景来说43 GFLOPS已经足够说明问题了。5. 数据对比与复盘5.1 测试方法要靠谱性能优化最怕的就是你优化了半天结果测试方法本身不靠谱数据全是噪音。我自己的测试规范有这么几条计时用clock_gettime(CLOCK_MONOTONIC)避免被系统时间调整干扰每次测试前先跑一次热身把CPU频率和Cache状态稳定下来取多次运行中的最优值而不是平均值——平均值容易被系统调度等偶发因素拉高无论如何要防止编译器把循环优化掉。矩阵乘法这类计算结果如果完全没用起来编译器理论上可以认为整段计算是死代码直接删掉。保险做法是把结果矩阵的所有元素求和用volatile变量接住或者打印出来5.2 各版本性能对比上面这一系列优化用同一台机器、同一个矩阵规模n1024测下来的数据汇总如下版本耗时(ms)GFLOPS相对基线加速比朴素ijk21001.01xikj循环重排5204.14.1xikj 分块(BK64)2309.39.1x分块 AVX2 FMA8026.826.3x分块 AVX2 寄存器阻塞5043.042.1x强调一下这组数据只代表我手头这颗CPU上的量级参考。换一颗更新的CPU、或者支持AVX-512的平台绝对数值会变但每一轮优化带来的相对趋势是一致的。5.3 复盘哪些优化贡献最大从数据里能清楚看到第一轮循环重排贡献了4倍的提升是整个优化链条里最关键的一步。原因很好理解它把数据访问模式从跨列跳跃改成了连续顺序直接从最底层解决了Cache Miss问题。后面所有优化都是建立在这个基础之上的。分块带来的提升是2倍多让数据在小范围内反复复用。向量化和寄存器阻塞各贡献了约3倍和1.6倍它们把CPU的浮点计算单元逐步喂满。如果非要排个优先级我的经验是访存优化永远比计算优化更值得优先做因为访存瓶颈的代价通常高一个数量级。5.4 为什么你的优化可能几乎没效果做了这么多优化如果跑起来没效果先不要怀疑这些手段本身对照下面几种常见原因编译器已经替你做了很多。开-O3之后简单的循环展开、不变量外提、部分向量化都是自动的。你手动做了一遍它做过的事当然看不到提升。先编译再看汇编或者perf报告确认哪些优化是缺失的瓶颈根本不在CPU。如果你的程序同时在读写磁盘或者网络那优化循环只是给一个本来就在等待I/O的进程看热闹测试规模太小。一个n64的矩阵乘法全部数据都能塞进L1 Cache分块和Cache优化的效果几乎看不出来必须把规模拉到能触发Cache Miss的级别开启了多线程瓶颈变成了锁竞争或缓存一致性同步。这时候改循环结构不如改并发设计数据本身有问题比如浮点数值长期是NaN或者异常值触发了CPU的慢路径6. 踩坑清单和我的几条实操原则6.1 六个最容易翻车的场景第一不开优化直接测性能。我见过太多人拿-O0编译的版本说这个循环优化没用这事就像穿着棉袄游泳然后怪水太深。性能对比必须在相同的优化级别下进行。第二块大小靠拍脑袋。分块大小不是随便写的要么按Cache容量估算要么扫一组候选值实际测一遍。我见过一个项目把BK设成512结果块数据比L2还大几倍每个块都在反复触发Cache Miss性能比不分块还差。第三行主序和列主序没分清。矩阵在内存里的布局决定了按行访问和按列访问的巨大差异。很多优化在本地测试有效换了一个调用方、换了一种数据来源比如从别的库拿到的列主序矩阵性能直接崩塌。动手之前先确认数据布局。第四基准测试被编译器优化掉了。结果算完没用编译器把整个计算过程当成死代码删除。这会让你测出一个虚假的极高性能。加一个校验和输出就能避免。第五小规模测试下结论。任何关于Cache优化、访存优化的结论都必须用超过Cache层级临界点的数据规模来验证否则所有优化看起来都差不多。第六优化之后不校验正确性。矩阵乘法涉及浮点累加展开顺序变了之后结果会有一点点浮点误差这是正常的但如果误差大得离谱多半是索引算错了或者内存越界。每轮优化跑完后做一个与基线版本的误差对比测试控制在1e-9相对误差量级以内。6.2 我的几条实操原则先profile再动手。这是我说过最多次的一句话。不要用眼睛去猜瓶颈用perf、VTune或者其他工具找出真正的热点循环再决定优化哪里。一次只改一个变量。循环重排、分块、向量化、展开这些手段互相影响如果你一次全改了性能提升了你不知道是哪一步的功劳性能变差了你也不知道是哪个改动引入的。每轮只动一个环节跑一次测试记录一个数据优化路径才会清晰。让编译器做它擅长的人做它不擅长的。现代编译器的指令调度、寄存器分配、基础向量化能力比绝大多数程序员手写要强。人应该把精力集中在编译器做不好的事上——改变数据布局、打破循环依赖、降低访存跨度、在更大尺度上组织数据流。优化只针对最重要的那一两层循环。不是每个循环都值得花时间打磨。用profile找到覆盖90%执行时间的那几个热循环把精力集中在它们上面其他循环保持可读性就是胜利。可维护性和性能要平衡。你写的AVX intrinsics代码三个月后别人可能看不懂。给每段优化代码写清楚注释说明优化思路、对应哪一层Cache、为什么用这个块大小。性能优化维护起来本来就难注释是帮你也是帮同事的。6.3 最后分享一点个人的体会Loop Engineering做到后面拼的不再是你会用多少种变换手段而是你读懂硬件行为的能力。看到一段循环脑子里能大致浮现出数据在Cache里的流动路径能预判CPU流水线在哪一步会停这种感觉是在一次次实测、一份份perf报告里泡出来的。我个人的一个小习惯是在写任何一段性能敏感的循环之前先把数据访问模式用注释画出来标清楚每一步是顺序访问还是跨步访问哪些数据会在Cache里被重新使用。代码还没写优化思路已经确定了一半。这个习惯帮我避开了很多后期返工的麻烦。循环优化这条路越往深走越有意思。矩阵乘法从1 GFLOPS到43 GFLOPS只是第一步再往下还有多级分块、微内核指令调度、NUMA感知、GPU上的循环映射……所有这些复杂技术的底座都是我们今天聊的这套最基本的Loop Engineering思维。能把循环组织好的人到哪儿都不会被性能问题困住。
RELATED

相关推荐

从WorkBuddy迁移到dsh:数据转换、字段映射与插件配置实战指南

从WorkBuddy迁移到dsh:数据转换、字段映射与插件配置实战指南

最近刚把手头 WorkBuddy 里的几百条笔记、任务和归档文件迁到了 dsh,折腾了差不多一个下午,中间踩了不少坑。趁热打铁把这套 workbuddy-to-dsh 迁移流程整理出来,希望能帮到同样打算从 WorkBuddy 切到 dsh 的朋友。先说结论:工具本…

📅 2026/10/27 22:43:52
多模态融合不涨点?从模态对齐到训练排错全拆解

多模态融合不涨点?从模态对齐到训练排错全拆解

做多模态这一行,最让人难受的不是模型不收敛,而是你辛辛苦苦把图像、音频、文本三条数据流接进同一个网络,训练日志上 loss 一路往下掉,评测集指标却只比单用图像分支高了零点几个点。我遇到过不止一次这种情况,回头看…

📅 2026/10/27 22:43:52
深入解析Loop Engineering:从循环逻辑到反馈环的工程闭环方法论

深入解析Loop Engineering:从循环逻辑到反馈环的工程闭环方法论

电脑直接打开不了Loop Engineering,它到底是个什么?这个问题我最初也问过自己。翻遍资料、跑通代码、把整个流程完整走了一遍之后,我的结论是:Loop Engineering本质上就是“先画圈、再造环、最后让环转起来”的一整套工程闭环方法…

📅 2026/10/27 22:43:52
MORE NEWS

更多资讯

📰

STM32C5与CubeMX2两周实测:从F103迁移的避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

RV1103边缘AI部署实战:主流图像分类模型选型与NPU量化优化指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

开关电源PFC优化实战:从Boost到图腾柱,环路设计与调机经验

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

电子信息工程技术专升本全解析:课程、备考与就业路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

互联网芯片格局之变:五项芯片入选背后的技术逻辑与行业机会

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

大专零基础如何学会TE测试开发?从产线到自动化的实战路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬