1. 项目概述当“分析建模”遇上高性能计算最近在翻看高性能计算领域的论文时被一篇标题相当“嚣张”的文章吸引了——《Analytical Modeling Is Enough for High-Performance BLIS》。这个标题直接挑战了一个常见的认知要榨干硬件的最后一点性能是不是非得靠那些复杂的、基于硬件探测的自动调优Auto-tuning或者机器学习模型论文的核心观点很明确对于像BLISBLAS-like Library Instantiation Software这样的基础线性代数子程序库一个精心构建的分析模型Analytical Modeling就足以指导我们找到接近最优的性能参数。这听起来有点反直觉毕竟现在AI调参这么火。但仔细想想GEMM通用矩阵乘法这种计算模式规整、访存模式相对固定的核心其性能瓶颈往往可以归结为几个关键硬件参数的函数比如各级缓存的容量和带宽、浮点运算单元的吞吐量。理论模型如果能精准刻画这些约束确实有可能绕过大量耗时的试错。这篇论文探讨的正是这个方向。它试图证明通过建立严谨的数学模型来分析不同分块Blocking策略、循环次序Loop Ordering下的计算强度Operational Intensity和缓存行为我们可以直接推导出性能最优的算法参数。这对于我们这些在一线折腾性能优化的工程师来说无疑是个福音。它意味着在面对一款新的CPU架构时我们可能不需要跑遍一个庞大的参数空间来搜索最优配置而是通过分析其微架构手册比如缓存层次、端口、吞吐量就能快速定位一个高性能的配置区间。这不仅能节省大量的调优时间更能让我们对性能瓶颈的理解从“黑盒实验”提升到“白盒分析”的层面。接下来我就结合自己的理解拆解一下这篇论文的核心思路并聊聊在实际项目中我们如何借鉴这种“分析建模”的思想来搞定高性能Kernel的开发与调优。2. 核心思路拆解为什么分析建模能行得通要理解为什么分析建模在BLIS这类库的优化中能扮演关键角色我们得先回到高性能计算最根本的挑战上平衡计算与访存。现代CPU的浮点计算能力远远超过其从内存中读取数据的能力这个差距就是所谓的“内存墙”。因此几乎所有高性能Kernel优化的核心思想都是数据复用Data Reuse尽可能让从内存辛苦搬来的数据在高速的缓存中被多次使用。2.1 GEMM计算与分层分块策略经典的GEMM计算是C αA * B βC。一个朴素的三层循环实现其数据复用率极低性能会卡在内存带宽上。BLIS以及更早的GotoBLAS采用的核心优化技术是分层分块Layered Blocking。这通常对应硬件上的缓存层次最外层分块对应L3缓存/内存将巨大的矩阵A、B、C切分成适合放入最后一级缓存LLC的大块。目的是确保在计算一个“大块C”时对应的“大块A”和“大块B”能尽量驻留在LLC中减少与主内存的通信。中间层分块对应L2/L1缓存在“大块”内部进一步分块使得子块能放入容量更小的L2或L1缓存。这一层关注的是寄存器与L1缓存之间的数据流动。最内层内核对应寄存器与流水线这是性能的最终决战地。通过精心设计的手写汇编或内联汇编微内核Micro-kernel将一小块数据例如一个mr x nr的小面板加载到寄存器中并展开循环利用CPU的SIMD指令和指令级并行ILP实现极高的计算吞吐。这里的mr和nr是关键参数分别代表微内核一次计算中在寄存器中保留的C矩阵的行块和列块大小。2.2 分析建模的目标与输入论文中的分析建模目标就是为上述每一层的分块大小MC,NC,KC,mr,nr等找到最优值。它的输入不是海量的性能测试数据而是硬件的静态微架构参数缓存容量CapacityL1D、L2、L3缓存的大小。缓存行大小Cache Line Size通常是64字节决定了内存访问的最小单位。缓存关联度Associativity影响缓存冲突的可能性。TLB条目数影响虚拟地址到物理地址翻译的效率。浮点运算峰值FLOPSCPU每个周期能执行的浮点操作数。内存带宽Bandwidth理论上的最大内存读写速度。这些参数都可以从CPU的技术手册如Intel的Intrinsics Guide, Software Optimization Manual或通过lscpu,cpuid等工具可靠地获取。模型的任务就是建立这些硬件参数与算法参数之间的数学关系。2.3 模型如何工作一个简化的例子假设我们正在优化中间层分块目标是确定KC在K维度上的分块大小对应A矩阵的宽度和B矩阵的高度。为什么是KC因为它决定了在计算一个MC x NC的C块时需要从A和B中加载多少数据。计算数据量每次计算需要加载一个MC x KC的A面板和一个KC x NC的B面板。总数据量约为MC*KC KC*NC假设双精度乘以8字节。缓存容量约束这些数据加上正在计算的MC x NC的C块也可能需要读写必须能放入目标缓存比如L2缓存。因此我们有(MC*KC KC*NC MC*NC) * sizeof(double) Size_L2 * Safety_Factor这里的Safety_Factor是一个安全系数比如0.8用于避免缓存颠簸因为缓存并非独占还会被其他数据如指令、栈占用。最大化计算强度计算强度 浮点操作数 / 字节读写数。对于这个分块浮点操作数约为2 * MC * NC * KC。从L2缓存的角度看假设A、B面板来自L3C在寄存器/L1数据读写量就是上面计算的数据量。模型会尝试在缓存容量约束下选择KC来最大化这个计算强度。带宽与峰值性能平衡最终性能由“屋顶线模型Roofline Model”决定。性能 min(峰值浮点性能, 内存带宽 * 计算强度)。分析模型的目标就是选择参数使得计算强度尽可能接近“屋顶线”的拐点即让性能受限于计算峰值而非内存带宽。通过这样层层递进的约束分析和目标函数最大化计算强度或最小化数据移动优化模型可以推导出一组理论上最优的分块参数。论文的贡献在于证明了对于GEMM这种规整问题这种基于静态参数的分析其效果可以与动态调优相媲美。注意模型的有效性高度依赖于对硬件行为假设的准确性。例如它假设缓存是完美LRU最近最少使用策略且没有冲突失效。实际中冲突失效Cache Conflict Miss会导致性能下降因此模型中引入安全系数和经验性的微调是必要的。3. 关键参数解析与建模实践理解了核心思路我们来看看在BLIS的语境下具体有哪些关键参数以及如何为它们建立分析模型。这就像为你的算法引擎绘制一张精准的“性能地图”。3.1 分层参数详解BLIS的GEMM实现通常涉及以下几组核心参数它们与硬件的缓存层次紧密绑定参数符号通常对应层次描述主要影响因素MCL3缓存C矩阵在M维度行上的分块大小。L3缓存容量 矩阵形状 避免TLB miss。NCL3缓存C矩阵在N维度列上的分块大小。L3缓存容量 与MC共同决定C块大小 影响B矩阵的数据复用。KCL2缓存在K维度上的分块大小 决定一次计算中A、B面板的宽度/高度。L2缓存容量 是平衡A、B数据加载的关键 直接影响L1/L2的数据复用率。mr寄存器/ L1缓存微内核一次计算中 在寄存器中保留的C矩阵行数。寄存器数量 SIMD向量宽度如AVX-512可处理8个双精度数 指令级并行需求。nr寄存器/ L1缓存微内核一次计算中 在寄存器中保留的C矩阵列数。寄存器数量 通常与mr配对选择 以最大化寄存器利用率和指令吞吐。PC打包缓冲区对A矩阵面板进行连续内存重排打包时的缓冲区大小。L1缓存容量 用于保证打包后的数据对齐且连续 提升L1缓存命中率。3.2 为mr和nr建立微内核模型微内核参数mr和nr的优化目标是最大化寄存器利用和指令吞吐。这是一个相对底层的模型。寄存器压力分析假设我们使用AVX-512每个ZMM寄存器512位可存8个双精度数。微内核需要为mr x nr的C块、mr x 1的A列片段和1 x nr的B行片段在寄存器中分配空间。C块需要mr * nr / 8个寄存器假设每个寄存器存8个元素。加载A的一列需要mr / 8个寄存器。加载B的一行需要nr / 8个寄存器。此外还需要一些寄存器用于存放循环索引、广播的标量等。 总寄存器需求必须小于物理寄存器数量例如Intel Skylake架构有32个ZMM寄存器。模型需要找到一个(mr, nr)组合在满足寄存器约束的前提下最大化mr * nr即每次微内核调用计算更多的元素。流水线瓶颈分析现代CPU有多个浮点运算端口如Port 0和Port 1可以执行FMA乘加运算。一个设计良好的微内核应该能同时向这两个端口发射指令。模型需要考虑加载端口加载A和B的数据需要占用加载端口Ports 2, 3。mr和nr的选择会影响加载指令的密度。FMA端口计算本身占用FMA端口。理想情况下微内核的循环体应该被FMA指令占满形成稠密的计算流。依赖链FMA指令通常有4-6个周期的延迟。模型需要通过循环展开由mr和nr隐式决定来暴露足够的指令级并行ILP以掩盖这个延迟让CPU的乱序执行引擎能持续工作。在实践中对于AVX-512常见的(mr, nr)组合有(6, 8)、(8, 6)、(12, 2)等。选择(6, 8)意味着一次计算一个6x8的C小块需要6*8/86个寄存器存C6/8≈1个寄存器存A列实际上需要向上取整并考虑广播8/81个寄存器存B行总计约8-10个寄存器用于数据留有充足空间给其他变量。同时6和8的乘积48是一个不错的数能较好地平衡寄存器使用和循环展开的粒度。3.3 为MC,NC,KC建立缓存层模型这一层模型是分析建模的核心直接决定了数据复用效率。L2缓存约束下的KC优化目标在L2缓存容量的限制下选择最大的KC以最大化每次从L3加载A、B面板后的计算量即计算强度。约束方程计算过程中L2缓存需要同时容纳从L3加载的A面板大小MC * KC从L3加载的B面板大小KC * NC正在由微内核计算的C块片段可能已部分写回L1/L2这部分通常较小可近似为mr * nr的量级有时可忽略或作为安全余量。因此粗略的约束是(MC * KC KC * NC) * sizeof(elem) Size_L2 * Safety_Factor。由于MC和NC通常由L3缓存约束预先大致确定这个不等式可以直接解出KC的最大理论值。论文中的模型会更精细考虑矩阵的打包布局、缓存关联度等因素可能采用更保守的估计。L3缓存约束下的MC和NC优化目标在L3缓存容量的限制下选择MC和NC以最大化C块的大小从而减少外层循环的次数并提升B矩阵在L3中的复用率。约束方程在计算一个MC x NC的C块时L3缓存需要容纳这个C块本身可能来自内存并写回内存MC * NC当前迭代需要的A面板MC * KC整个B矩阵的对应列块这是关键KC * N其中N是B的全局列数。因为对于固定的KC在计算这个C块的过程中我们需要遍历B矩阵的整个宽度N列。约束为(MC*NC MC*KC KC*N) * sizeof(elem) Size_L3 * Safety_Factor。这里出现了全局维度N说明最优的MC和NC并不是固定的而是与问题规模相关的。模型需要针对不同的N或M来动态调整。当N很大时KC * N这一项会主导迫使MC或KC减小。这就是为什么高性能库的配置有时是问题规模相关的。TLB页表缓存的考量非常大的MC或NC可能导致C矩阵的访存跨越多页如果TLB条目不足会引起TLB miss触发昂贵的页表遍历。模型需要加入TLB约束MC * sizeof(elem) / Page_Size和NC * sizeof(elem) / Page_Size不应显著超过L1 DTLB的条目数。这通常会导致对MC和NC设置一个上限。通过联立上述缓存容量约束、TLB约束并以最大化计算强度或最小化数据移动量为目标进行求解可能是解析解也可能是一个简单的搜索分析模型就能输出一组合适的MC,NC,KC参数。实操心得在实际编码中我们很少在线求解这些不等式。通常的做法是根据目标硬件参数离线计算出一组“推荐值”并将其硬编码在库的配置头文件里例如blis_kernel.h。对于不同的CPU架构如Intel Ice Lake vs. AMD Zen3我们会提供不同的配置。论文的价值在于提供了计算这些推荐值的可靠方法论而不是盲目试错。4. 从模型到实践性能对比与调优迭代理论模型再漂亮最终也要用性能数据说话。论文的核心主张是基于分析模型选择的参数其性能能够媲美甚至超越传统的自动调优方法。4.1 与传统自动调优的对比自动调优Auto-tuning是另一种主流方法代表工具有ATLAS。它的流程通常是定义一个庞大的参数空间MC,NC,KC,mr,nr, 循环次序等所有可能组合。在目标机器上编译并运行所有参数组合的微基准测试。选择耗时最短的那组参数。这种方法简单粗暴理论上能找到特定机器上的绝对最优解。但它有几个显著缺点耗时极长参数空间可能是指数级的。一次完整的调优可能需要数小时甚至数天。可移植性差为机器A调优的参数在机器B即使是同系列不同频率上可能不是最优的。缺乏解释性你只知道“这组参数快”但不知道“为什么快”。当硬件或问题规模变化时无法进行推理和调整。相比之下分析建模方法的优势在于速度快一旦模型建立对于新硬件只需要输入其微架构参数几分钟就能计算出推荐参数。可解释性强参数选择直接与缓存容量、带宽等硬件特性挂钩性能瓶颈清晰可见。可移植性较好对于微架构相似的CPU如同一代的Intel Core系列模型推导的参数通常表现一致且良好。4.2 模型校准与“足够好”的性能论文标题中的“Enough”是关键。它不声称分析模型总能找到“全局最优”而是说它能找到一组“足够高性能”的参数。如何让模型更准引入安全系数Safety Factor这是模型贴近现实的关键技巧。理论缓存容量是XMB但我们不能全部用完。因为操作系统和其他进程会占用部分缓存。缓存是组相联的存在冲突失效的风险。我们的计算模型可能忽略了指令缓存、栈、全局变量等其他数据。 因此在约束方程中我们会使用Effective_Cache_Size Cache_Size * SF其中SF是一个经验值通常在0.5到0.8之间。这个系数可以通过少量实验来校准选择一个参数逐渐增大它直到性能因缓存颠簸而骤降反推出实际可用的缓存比例。考虑矩阵打包Packing开销BLIS在计算前会将非连续的矩阵数据如行主序的A矩阵的列打包成连续的内存块。这个打包缓冲区本身也占用缓存通常是L1。在建模KC对L1的影响时需要加上打包缓冲区的大小PC。验证与微调用模型生成初始参数后必须运行标准基准测试如在多种矩阵规模上运行GEMM进行验证。观察性能屋顶线如果发现与理论峰值差距较大需要回溯是哪个环节的假设出了问题。常见的微调包括微调mr,nr尝试相邻的数值组合测试其对微内核吞吐量的影响。微调KC在模型计算值附近进行小范围扫描因为模型可能高估了缓存的有效性。循环次序Loop Ordering虽然论文主要讨论分块大小但循环次序同样重要。例如在计算C_block A_panel * B_panel时是优先循环nc在N上切分还是优先循环mc在M上切分这会影响B面板或A面板在L2/L1中的复用。模型可以结合访存分析推荐一种次序但最终需要通过实验确认。在我的经验中一个经过校准的分析模型其推荐的参数在90%以上的常见问题规模上能达到自动调优方法95%-99%的性能。而获取这组参数的成本几分钟的分析 vs. 数小时的搜索和其可解释性带来的长期收益使得分析建模方法在实际工程中极具吸引力。5. 实战指南为新硬件定制BLIS风格的高性能GEMM假设我们现在要为一块新的ARM Neoverse-N2服务器CPU开发一个高性能的FP32单精度GEMM内核。我们可以遵循分析建模的流程来操作。5.1 第一步硬件参数探测与建模准备首先收集关键硬件参数。通过查阅ARM技术文档或运行微基准测试我们得到核心架构ARM Neoverse-N2 支持SVE2指令集假设矢量长度VL 256位可处理8个FP32。缓存L1D 64KB 8路组相联 L2 1MB 私有 L3 32MB 共享。缓存行64字节。寄存器32个VL长度的SVE矢量寄存器Z0-Z31。峰值性能假设每周期每核心可执行2个FMA操作 主频2.8GHz 则单核峰值性能为2 ops/FMA * 2 FMA/cycle * 2.8G Hz 11.2 GFLOPs。内存带宽实测或理论值 例如200 GB/s。5.2 第二步微内核参数mr和nr的选择对于SVEVL2568个FP32。寄存器预算我们有32个Z寄存器。需要为C块、A列、B行分配。尝试mr6,nr8。C块需要6*8/86个寄存器。A列需要6/81个寄存器实际上加载一个矢量后需要广播到其他列可能占用更多但可通过指令操作减少实际占用。B行需要8/81个寄存器。总计约8-10个寄存器 非常宽松。尝试mr8,nr12。C块需要8*12/812个寄存器。A列需要1个 B行需要12/82个。总计约15个。仍有富余。考虑到SVE的指令吞吐和延迟以及为了更好利用指令级并行我们可能选择mr8,nr12 因为它每次计算更多的元素96 vs 48 能更好地掩盖指令延迟。但必须通过实际编写微内核来测试。编写与测试微内核用ARM汇编或C语言 intrinsics 编写两个微内核版本(6,8)和(8,12)。在一个很小的循环中反复调用它们计算其稳态的CPI每指令周期数或GFLOPS。选择性能更高的那个。假设测试结果是(8,12)更优。5.3 第三步缓存层参数MC,NC,KC的推导我们采用自上而下的方式。L3缓存约束确定MC和NC范围目标在计算一个C块时让B的列块 (KC * N) 能尽量留在L3中。约束(MC*NC MC*KC KC*N) * 4 bytes 32MB * 0.7安全系数取0.7。这里N是变量。我们需要一个启发式策略。常见的做法是设定一个N的阈值。当N较小时可以分配较大的MC和NC当N很大时必须减小MC或KC来容纳KC*N。简化策略先忽略KC*N项假设问题规模中等专注于分配MC*NC。L3的70%约为22.4MB。如果我们希望C块占用约一半11MB则MC*NC ≈ 11MB / 4 bytes ≈ 2.75M个元素。取MC和NC为2的幂次方附近例如MC2048,NC1344 乘积约为2.75M。L2缓存约束确定KC约束(MC*KC KC*NC) * 4 bytes 1MB * 0.8L2更私有安全系数可取高一些如0.8。代入MC2048,NC1344 L2有效容量为0.8MB819KB。不等式(2048*KC KC*1344)*4 819*1024。计算(3392*KC)*4 838,656-13568*KC 838,656-KC 61.8。取一个接近的、利于向量化和内存对齐的数例如KC 64。TLB检查假设页大小4KB L1 DTLB有64个条目。MC的访存跨度2048 * 4 bytes 8KB 跨2页。安全。NC的访存跨度1344 * 4 bytes 5.25KB 跨2页。安全。KC影响的是A和B面板的宽度/高度其访存是连续的TLB压力小。L1缓存与打包缓冲区PCPC通常等于KC用于打包A的面板。KC64 那么打包缓冲区大小为MC*KC2048*64*4512KB。这已经远远超过L1D缓存的64KB。这里就发现了问题我们的MC对于L1来说太大了。实际上MC和KC的乘积决定了打包后A面板的大小这个面板需要被重复访问应该尽量放入L1。因此MC和KC的乘积需要受L1约束。修正我们需要联合优化。首先确保MC * KC * sizeof(float) Size_L1 * SF1。取SF10.8, 则MC*KC (64KB*0.8)/4 ≈ 13107。之前我们有一个KC62的约束。如果我们取KC64 那么MC 13107/64 ≈ 204。这和我们最初设想的MC2048相差甚远。这说明我们的分层需要调整。实际上在BLIS的分层中MC是L3级的分块但MC与KC的乘积A面板需要放入L2。而KC与mr的乘积A的微面板需要放入L1。我们混淆了层次。让我们重新梳理一个更典型的三层分块策略L3级将矩阵分块为MC x KC的A块和KC x NC的B块。MC和NC由L3容量和TLB决定。L2级将MC x KC的A块进一步在M维度切分为mc x KC的面板使其能放入L2。mc由L2容量决定。L1/寄存器级微内核操作mr x nr的C微块并重复使用mr x 1的A列片段和1 x nr的B行片段。因此我们需要定义的参数是MC,NC,KC,mc,mr,nr。其中mc是MC的约数。重新建模L1约束(mr*KC KC*nr mr*nr) * 4 64KB * 0.8。这主要约束KC不能太大。已知mr8,nr12 解得KC大约在200-300量级 很宽松。L2约束(mc*KC KC*NR mc*NR) * 4 1MB * 0.8。这里NR是L2级B的分块通常等于nr的倍数简单起见先设NR nr * 某个因子。这个约束会共同决定mc和KC。L3约束(MC*NC MC*KC KC*N) * 4 32MB * 0.7。决定MC和NC。这是一个更复杂的多变量优化问题。在实际的BLIS配置中通常采用一组经验值并通过脚本基于硬件参数进行估算和微调。例如对于ARM Neoverse-N2 经过简化和实验一组可能有效的参数是MC 2048,NC 4096受L3和TLB限制NC可以较大以利用B的复用KC 256平衡L1/L2约束和计算强度mc 256使得mc*KC256*256*4256KB 能放入L2mr 8,nr 12由微内核测试决定5.4 第四步实现、测试与迭代实现分层循环按照上述参数用C语言实现多层循环的GEMM框架。最外层循环遍历MC和NC分块中间层循环遍历mc分块内层调用微内核。性能测试使用不同规模的方阵和瘦长矩阵进行测试绘制性能曲线。分析与微调如果小矩阵性能不佳可能是微内核mr,nr或KC不够优化。如果大矩阵性能达不到峰值检查是否是L3缓存约束被违反KC*N太大尝试减小KC或MC。使用性能剖析工具如perf查看缓存命中率L1-dcache-load-misses,LLC-load-misses和指令效率。校准安全系数根据性能瓶颈反向调整模型中的安全系数SF使理论模型更贴合实际观测到的缓存行为。这个过程不是一蹴而就的。分析模型给出了一个强有力的起点和清晰的设计方向避免了在巨大的参数空间中盲目搜索。最终的参数可能需要几轮“建模-实现-测试-校准”的迭代才能达到最佳。但正如论文所言这个基于分析的方法是通向高性能的一条高效且可靠的路径。它让你始终知道自己在优化什么以及为什么这样优化是有效的。