ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从汇编到性能:为什么换个遍历顺序,性能差 5 倍

从汇编到性能:为什么换个遍历顺序,性能差 5 倍 ① 钩子同样的指令不同的命运同一个 64MB 的数组同样的s v[...]累加行优先遍历 40ms列优先遍历 201ms——5 倍差距。两者的汇编几乎一样都是内存加载 加法差的不是指令是访问模式一个顺序读缓存行一个每次跳 16KB 让缓存行全部作废。这就是汇编以上的性能真相指令数不再是瓶颈数据在哪里才是。② 源码 vs 实测constexprintN4096;// N*N 个 int 64 MB超过缓存// 行优先v[i*Nj]内存连续for(inti0;iN;i)for(intj0;jN;j)sv[i*Nj];// 列优先v[i*Nj]步长 N*4 字节每个元素都换缓存行for(intj0;jN;j)for(inti0;iN;i)sv[i*Nj];本机实测g -O264MB 数组3 次取中row: 40410000 ns col: 201132000 ns ratio: 5.0x (sum16777216)为什么差 5 倍行优先CPU 一次拉一个 64 字节缓存行够读 16 个 int几乎每次都命中 → 有效带宽接近内存理论值。列优先每次访问v[i*Nj]都落在不同的缓存行步长 16KB每读一个 int 就换一行 → 缓存命中率趋近于零等价于每条指令都在等内存。自动向量化 ——-O2默认 SSE2vs-marchnativeAVX-512 本机; -O2128-bit 向量xmm一次处理 4 个 int movdqu .LC0(%rip), %xmm0 ... movups %xmm0, (%rax) ; 填充循环128-bit 写入 ; -marchnative256-bit 向量ymm一次处理 8 个 int vpcmpeqd %ymm0, %ymm0, %ymm0 vpsrld $31, %ymm0, %ymm0 ; 生成 8 个 1 vmovdqu %ymm0, (%rax) ; 填充循环256-bit 写入 vmovdqu %ymm0, -32(%rax)-marchnative让编译器看到本机完整的 SIMD 指令集把向量宽度从 128-bit 翻到 256-bit——同样的数据量指令数减半。一个诚实的反例add_arrays小循环; -O2a[i]b[i] 被向量化paddd xmm movdqu (%rcx), %xmm0 paddd %xmm1, %xmm0 ; -marchnativeg 反而选了标量循环 .L3: movl (%rdx,%rax), %r10d addl (%rcx,%rax), %r10d movl %r10d, (%r8,%rax) addq $4, %rax cmpq %rax, %r9 jne .L3③ 为什么这么设计性能瓶颈不在指令在数据在哪里现代 CPU 每周期能执行多条指令但一次缓存未命中要等 ~100 个周期的内存访问。指令再花哨喂不饱内存也没用。缓存层次决定访问代价L1 命中 ~4 周期L2 ~12L3 ~40内存 ~100。64MB 数组远超缓存遍历顺序直接决定你付哪种代价。向量化是编译器在并行加 4/8/16 个数据SSE2 一次 128-bit4 个 intAVX2 一次 256-bit8 个 intAVX-512 一次 512-bit16 个 int。-marchnative让编译器敢用本机最宽向量。为什么 add_arrays 反而标量向量化是 cost-model 决策——对长度未知、可能短、可能有别名的循环编译器要权衡展开边界处理别名检查的开销。-marchnative换了个更宽的 cost modelg 对这个小循环判定标量更划算。向量化不是单调的开了就一定更快这正是要用实测汇编交叉验证的原因。对齐与分配器new[]通常返回满足最宽对齐的地址__attribute__((aligned))/std::align能进一步控制但现代new一般已够用。④ 深入一缓存行、预取与步长的代价缓存行cache line是现代 CPU 内存交互的最小单位通常 64 字节顺序访问一次拉一行16 个 int 全用上 → 有效带宽高步长访问每读一个 int 就浪费其余 60 字节 → 有效带宽暴跌。硬件预取prefetchCPU 能识别顺序模式并提前拉取后续缓存行隐藏内存延迟步长模式预取器无法预测所以列优先连预取红利都没有。工程含义二维数组按行存本集v[i*Nj]就按行遍历结构体数组AoS按结构体访问把热字段连续放E06 布局重排的延续遍历方向、步长、对象大小直接决定内存带宽利用率——这是比指令数更重要的性能变量。⑤ 深入二为什么算法复杂度对了还不够O(n²) 但缓存友好 vs O(n) 但缓存灾难现实常反转直觉一个复杂度更高但顺序访问的算法可能比复杂度低但乱序访问更快——因为内存延迟100 周期远超指令成本1~几周期。本系列前 20 集的视角指令怎么生成本集的视角数据怎么流动必须合起来指令数/寄存器/调用开销 → 决定每周期干多少活缓存/带宽/预取 → 决定每周期等多久数据。性能工作 两者同时看。只看复杂度算法或只看指令微观都会误判。⑥ 常见误区误区 1“更少的指令 更快”本集列优先的汇编和行优先几乎一样但慢 5 倍——内存访问模式 指令数。误区 2“-marchnative一定更快”add_arrays反例——向量化是 cost-model 决策宽向量不一定划算。必须实测。误区 3“缓存优化是高级技巧不重要”5 倍差距在真实程序里常见。遍历顺序、布局、对齐是免费的几倍。误区 4“优化只跟编译器有关”编译器管指令生成数据布局/访问模式是你写的——这部分编译器改不了。误区 5“64MB 用std::vector就行”容器正确只是第一步遍历顺序和布局才决定能不能喂饱内存。误区 6“缓存优化只在大型服务器有用”手机、嵌入式、桌面同样受缓存支配。任何跑超过缓存大小的数据遍历顺序都是第一性能变量。误区 7“预取器能拯救乱序访问”硬件预取只擅长顺序/固定步长模式随机/大幅跳跃列优先预取器无能为力。别指望硬件替你兜底。⑦ 实战启示性能分析的正确流程先测后改用汇编交叉验证——perf / 火焰图Linux或简单 chrono 基准先定位热点再看对应函数的.s确认瓶颈是缓存、分支还是向量化。把遍历顺序当成一等公民能顺序访问就顺序访问按结构体的热字段重排布局cache-friendly struct能白赚几倍。-marchnative在发布/测试机一致时用向量化收益真实存在本集 128→256-bit但要保证部署 CPU 支持该指令集。别用直觉赌性能add_arrays的反例说明我以为常常错。改一行测一次看汇编再下结论。建立延迟预算直觉缓存命中的代价差一个数量级。设计数据结构时先问访问是顺序还是跳跃再谈算法。⑧ 扩展专题一向量化的边界处理——为什么不能只算主循环向量化大数组时编译器要处理长度不能被向量宽度整除的余数主循环按最宽向量4/8/16 个元素并行处理余数循环tail剩下的 0~宽-1 个元素用标量补齐若边界未知运行时长度还要运行时判断走哪条路。; 典型形态主向量循环 尾部标量循环 .Lmain: vpaddd ... ; 一次 8 个 .Ltail: addl ... ; 剩余用标量为什么长度已知更优常量长度可让编译器省略运行时边界判断与余数处理——所以sum4E19能直接展开成 1 趟向量化而运行时长度要多几行判断。⑨ 扩展专题二缓存友好的数据结构设计把访问模式当设计输入AoS vs SoA数组结构体AoS{x,y,z}[]适合每次访问整个对象结构体数组SoAx[], y[], z[]适合只处理某一字段的批量循环——后者向量化缓存命中都更好热字段聚拢把频繁访问的字段放结构体头部E06 布局重排减少缓存行污染分块blocking/tiling把大矩阵切块让每个块留在缓存里再复用矩阵乘法优化的核心预取提示__builtin_prefetch/_mm_prefetch在特定场景有用但通常先信硬件预取器。一句话数据结构的选择决定了缓存的命中率缓存命中率决定了真实性能上限——算法和指令只是在这个上限内填满。⑩ 扩展 FAQQ-marchnative的向量化为什么有的地方变慢A宽向量 边界处理 别名检查的 overhead 可能超过收益add_arrays反例。编译器按 cost-model 决策不是越宽越好。Q如何知道某循环有没有被向量化A反汇编找paddd/movdqu/vmovdqu等向量指令或用-fopt-info-vec让编译器打印向量化决策。Qrestrict对向量化有帮助吗A有——它告诉编译器指针不重叠去掉别名检查/运行时判断更容易向量化。Q缓存大小怎么查AlscpuLinux或 CPU-ZWindows看 L1/L2/L3 大小。数据规模 vs 缓存大小的关系决定优化策略。Qstd::vectorint是连续的吗A是E14 讲过所以顺序遍历天然缓存友好。容器对访问模式也要对。⑪ 扩展实验不同 N 实测把 N 从 512 调到 8192观察行/列优先比值如何随数据是否超过缓存变化小数组时比值接近 1超缓存后拉大。-fopt-info-vec对E21_perf.cpp打印向量化决策读为何 add_arrays 没向量化。-marchnative对比同一段数组累加-O2vs-O2 -marchnative计时 反汇编量化向量宽度收益。AoS vs SoA写两个版本结构体数组各跑一次对比缓存命中可用 perf stat 的 cache-misses。分块矩阵乘法朴素版 vs 分块版计时对比分块版缓存命中率显著更高。⑬ 扩展专题三内存带宽与有效带宽的数学内存访问的真实上限由有效带宽决定有效带宽 访问的数据量 / (访问时间) 顺序访问约等于理论带宽缓存行几乎全利用 步长访问有效带宽 ≈ 理论带宽 × (用到的字节/缓存行)64 字节缓存行、步长 4 字节int→ 每次只用到 4/64 6.25% 的带宽 → 慢 ~16 倍受预取/并行影响实测常是 3~10 倍本集实测 5 倍在理论上限内说明行优先已接近带宽上限。工程含义当你做大数据量遍历先算我的访问模式用到了多少带宽——如果步长大、缓存行利用低优化空间往往在改访问模式而不是改算法。⑭ 扩展专题四从本集回看 E19——指令优化与数据优化的分工把本系列两集连起来看层面谁负责工具指令生成内联/化简/向量化指令编译器-O2/-O3/-marchnative数据流动缓存/带宽/预取你写的代码遍历顺序、布局、分块分工的边界编译器在给定访问模式下把指令做到最好但访问模式本身是源码决定的编译器不能替你改v[i*Nj]的顺序——它不改变你的数据布局语义。所以性能优化分两层先问数据怎么流动你的责任再问指令怎么生成编译器责任。本系列的 E19 讲后者本集讲前者——两层都要会。⑮ 扩展 FAQ第二轮Qstd::vector之外还有哪些缓存友好容器Astd::array、std::deque分段连续、std::pmr池。关键是访问模式容器只是把布局摆好遍历顺序才是命门。Q多线程访问同一数组会怎样A伪共享E16 提过——不同线程改同一缓存行不同字段会互相拖慢。缓存行对齐padding能治。Q为什么有时增加复杂度反而更快A因为新算法缓存命中率高顺序访问/更小工作集抵消了指令数增加。复杂度不是性能唯一变量缓存是。Q__restrict在向量化里多重要A对可证明不重叠的指针去掉别名检查后向量化门槛降低add_arrays这类可能因此被向量化。Q生产环境用-marchnative安全吗A部署 CPU 与编译 CPU 一致才安全否则可能illegal instruction。内网/专属机可用通用发行用保守-march。⑯ 扩展实验第二轮步长扫描固定数组步长从 1 扫到 64int 数记录每个步长的耗时画出有效带宽 vs 步长曲线——直观看到缓存行效应。伪共享演示两个线程写同一缓存行的不同 int带/不带 padding计时对比。__restrict效果对add_arrays加__restrict看-marchnative下是否被向量化对比反例。perf statLinux行/列优先各跑一次对比cache-misses与cyclesWindows 可用 CPU 计数器工具近似。分块矩阵乘N1024 朴素 vs 分块block64计时 反汇编对比主循环形态。⑱ 扩展专题五从 5 倍差距到通用方法论——“内存延迟隐藏”本集的核心不只是缓存而是延迟隐藏latency hiding顺序访问时CPU 的乱序执行 硬件预取把内存延迟藏在并行指令后面 → 有效带宽接近理论值步长访问时每条加载都依赖前一条的地址乱序引擎无法并行化延迟完全暴露 → 每周期都在等内存。所以性能优化的通用心法让访问可预测顺序/固定步长→ 预取器和乱序引擎帮你隐藏延迟让数据小分块、压缩、位打包→ 更多数据留在缓存里让热数据聚拢SoA、热字段置顶→ 每个缓存行都被用满。这三条与编译器无关全在你的数据设计里——这是汇编以上的性能也是本系列从指令到系统的关键一跃。⑲ 扩展专题六实测方法论——如何做可信的基准本集数字40ms/201ms来自3 次取中。做性能基准的原则取中位数或多次平均单次测量受系统噪声影响控制变量同机、同编译器、同数据规模只改遍历顺序防优化累加结果要被使用否则-O2可能把整个循环删掉E19 的 DCE 陷阱本集用sum16777216打印验证先跑热身让缓存/TLB/频率爬升稳定后再计时。工程含义性能结论必须是可复现的实测不是我觉得。本系列的每个数字都遵循这个纪律——这也是为什么你能信这些数据。⑳ 扩展 FAQ第三轮Q为什么 64MB 的数组会超过 L3AL3 通常几 MB~几十 MB本机小于 64MB。4096*4096*4B64MB远超 L3所以大量访问落到内存——这是为什么选 64MB的设计意图。Qstd::deque缓存友好吗A分段连续——单段内顺序访问友好跨段跳转有开销。按访问模式选容器E14 决策树的延续。QGPU 上同样的问题还成立吗AGPU 有不同缓存/带宽模型但访问局部性原则更极端——SIMT 线程要连续访问才能合并访存。局部性原则跨平台成立。Qprefetch指令该不该手写A多数场景硬件预取够用手写_mm_prefetch只在已知未来访问模式且预取器预测不了时有效。先测别默认写。Q本集和 E14 的 vector 扩容有什么关系A扩容E14保证连续存储 顺序访问友好若用链表离散节点遍历每步都可能缓存未命中。容器连续性 缓存友好性。㉑ 扩展实验第三轮工作集实验固定 N4096只遍历一个 4KB 的小数组 vs 64MB 大数组对比耗时随工作集是否进缓存的变化。热身 vs 不热身同一循环第一次跑 vs 预热后跑观察差异TLB/缓存/频率爬升。防优化对照累加结果不使用编译后循环被删vs 使用保留对比汇编——亲眼看到 DCE 陷阱。SoA 向量化AoS vs SoA 的批量加法-O2反汇编对比是否向量化 实测对比。TLB 观察大数组如 256MB步长遍历 vs 顺序遍历看 TLB 未命中带来的额外惩罚perf stat dTLB-load-misses或 Windows 计数器。㉒ 扩展专题七内存层次全景——从寄存器到磁盘本集讲缓存把完整数据阶梯排出来差距一目了然层级容量延迟约谁在管寄存器~几百 B~01 周期编译器L1 缓存~32-64 KB~4 周期CPU 硬件L2 缓存~0.5-2 MB~12 周期CPU 硬件L3 缓存~几-几十 MB~40 周期CPU 硬件内存GB 级~100 周期OS/硬件磁盘/SSDTB 级微秒~毫秒OS量级感L1 到内存差 ~25 倍延迟内存到 SSD 差 ~万倍。快是相对的优化目标就是把热点数据放在尽可能靠近 CPU的层级。工程含义本集的行/列遍历L3→内存是5 倍级而热字段聚拢让数据留在 L1是几十倍级别在循环里随机跳磁盘是万倍级。性能优化的天花板由数据所处的层级决定。㉓ 扩展 FAQ第四轮Q怎么测自己的数据在哪个缓存层A用工作集扫描法——从小数组到大数组计时看耗时在哪个体积跳变跳变点≈缓存边界或用perf stat的 cache-misses 分档。Qstd::sort是缓存友好的吗A内省排序用分治但std::sort的底层插排快排混合对随机访问容器较友好链表排序则每步都跳节点缓存差。Q位压缩/打包值得吗A数据变小 → 更多进缓存 → 快。代价是解包指令。热点大数组常值得如索引用uint16_t而不是size_t。Q和 E16 的伪共享怎么区分A本集是同一线程访问模式差伪共享是多线程争同一缓存行。两者都是缓存行问题一个管顺序一个管隔离。Q本集对游戏/图形优化有什么启发A数据驱动设计SoA、热数据聚拢就是为缓存命中率服务的——游戏引擎把每帧遍历设计成顺序访问是本集方法论最激烈的应用场。㉔ 扩展实验第四轮工作集扫描曲线从 4KB 到 256MB 倍增扫描记录耗时标出 L1/L2/L3/内存边界。索引打包vectorsize_tvsvectoruint16_t存索引遍历计时对比数据量减半→缓存收益。链表 vs vector插入遍历同一数据量std::listvsstd::vector对比缓存相关耗时。排序缓存std::sort随机数组 vs 已排序数组看分支与缓存对耗时的贡献E08 分支预测延续。数据驱动重构把面向对象逐对象的循环改成SoA 顺序遍历实测加速倍数。㉕ 悬念同一份x 1的代码在 x86-64 上是leal (%rcx,%rdx),%eax在 ARM64 上却变成了add w0, w1, w0。寄存器、条件分支、甚至能不能对内存直接做运算都不同。
返回列表