ARTICLE DETAIL

资讯详情

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

循环顺序没变为何快了三倍:分块矩阵乘法压测

循环顺序没变为何快了三倍:分块矩阵乘法压测 摘要矩阵乘法的算术复杂度同为立方级实际速度却可能因访问顺序和分块大小出现明显差异。本文从连续内存访问热点出发用 JavaScript 类型数组构造朴素版与缓存分块版压测解释数据复用、尾块处理、计时边界以及不能从单次小样本推出结论的原因。压测目标算法频道的内存对齐条目提醒了一个常被忽略的事实大 O 只统计操作数量不描述运行时为这些操作搬了多少数据。矩阵乘法CA×B是观察缓存局部性的理想样本。朴素三重循环和分块版本都执行约n³次乘加但后者让一小块数据在缓存中反复使用减少从更慢层级重新加载的次数。我们比较两个实现。朴素版循环顺序是i-k-j已经比最直观的i-j-k更照顾行优先布局分块版仍保持相同的内部顺序只在外层把i、k、j划成小区间。这样比较的变量主要是块复用而不是偷偷换了另一种算法。缓存账本怎么记JavaScript 的Float64Array连续存放数值。固定i、k后A[i][k]可被运行时保留B[k][j]与C[i][j]都沿连续索引向右访问。问题在于矩阵很大时等下一行再次使用同一片B它可能已被逐出缓存。分块把工作集限制为若干block × block小片。只要块大小与缓存容量大致匹配B的小片和C的小片会在处理多个i时复用。块越大并不必然越快工作集装不下会失去意义块太小又增加循环边界与分支开销。因此本文把块大小作为参数并明确禁止把某台机器的一次结果包装成通用结论。可运行的 JavaScript 程序程序使用固定公式生成矩阵先校验两个版本结果再分别计时。默认n240在普通开发机上运行时间适中。这里还留出命令行参数便于把规模改成 64、240、512观察“小数据计时噪声”和“大数据缓存压力”的差别。const{performance}require(node:perf_hooks);functionmultiplyPlain(a,b,n){constcnewFloat64Array(n*n);for(leti0;in;i){for(letk0;kn;k){constaika[i*nk];for(letj0;jn;j)c[i*nj]aik*b[k*nj];}}returnc;}functionmultiplyBlocked(a,b,n,block){constcnewFloat64Array(n*n);for(letii0;iin;iiblock){for(letkk0;kkn;kkblock){for(letjj0;jjn;jjblock){constiEndMath.min(iiblock,n);constkEndMath.min(kkblock,n);constjEndMath.min(jjblock,n);for(letiii;iiEnd;i){for(letkkk;kkEnd;k){constaika[i*nk];for(letjjj;jjEnd;j)c[i*nj]aik*b[k*nj];}}}}}returnc;}functiontimed(run){constbeginperformance.now();constresultrun();return{result,ms:performance.now()-begin};}constnprocess.argv[2]?Number(process.argv[2]):240;constblockprocess.argv[3]?Number(process.argv[3]):32;if(!Number.isInteger(n)||!Number.isInteger(block)||n0||block0)thrownewError(positive integer arguments required);constanewFloat64Array(n*n);constbnewFloat64Array(n*n);for(leti0;in;i){for(letj0;jn;j){a[i*nj]((i*17j*3)%23-11)/11;b[i*nj]((i*5j*13)%19-9)/9;}}constplaintimed(()multiplyPlain(a,b,n));constblockedtimed(()multiplyBlocked(a,b,n,block));letmaxError0;for(letp0;pn*n;p)maxErrorMath.max(maxError,Math.abs(plain.result[p]-blocked.result[p]));if(maxError1e-9)thrownewError(result mismatch:${maxError});console.log(plain_ms${plain.ms.toFixed(3)});console.log(blocked_ms${blocked.ms.toFixed(3)});console.log(max_error${maxError.toExponential(3)});服务化试验若要把不同块大小、矩阵规模和机器信息统一提交到外部分析接口可以把 https://haerapi.com 作为待评估的 API 接入选项之一基准原始数据仍应本地保留结论也必须以自己的硬件复测为准。怎么复制测试在 Node.js 环境中依次运行node program.js 64 16、node program.js 240 32、node program.js 240 64。每次都应看到max_error为零或极小误差时间值随机器、Node.js 版本、JIT 状态和后台负载变化不应复制本文标题中的倍率当作承诺。建议先预热再让每组参数重复五次并取中位数。为什么不用固定期望毫秒数因为时间不是算法的逻辑输出。正确测试分两层第一层比较完整矩阵保证优化没有改答案第二层记录多次耗时和环境判断差异是否稳定。若只看最快一次恰好发生的睿频、缓存预热或系统调度都可能误导结论。边界块是最容易漏的地方当n不能整除block时最后一块尺寸不足。代码用min截断三个方向的结束位置。很多只用 256 测试的实现会错误地假定整除到了 250 就越界。n1、block1、blockn都应正确零和负参数直接拒绝。数据存为一维数组可以避免vectorvectordouble每行独立分配带来的额外间接访问。浮点加法不满足严格结合律。若优化版改变累加顺序理论相同的结果也可能有末位差异因此应使用容差比较。本例内部累加次序与朴素版一致通常结果相同但测试仍保留1e-9容差避免把平台差异误判为错误。复杂度没有变化瓶颈变了两版时间复杂度都是O(n³)输出和矩阵存储空间都是O(n²)。分块额外空间为常数因为它只改变遍历边界没有复制子矩阵。变化发生在缓存缺失数量和内存带宽压力而这不会出现在大 O 里。常见错误包括计时中包含随机数生成两个版本使用不同初始化忘记清零C块循环覆盖重叠或遗漏尾部用调试构建比较先运行的版本总承担冷缓存成本只测一个很小规模看到一次加速就宣称普适倍率。一个可信压测至少应公开规模、块大小、编译选项、重复次数和正确性误差。压测结案分块矩阵乘法的价值不在于换掉立方复杂度而在于让已经加载的数据多做几次有用工作。先建立结果等价的基线再逐步试块大小才能把缓存优化与偶然计时波动分开。面对“算法相同为什么更快”这类问题内存访问路径往往比乘法次数更接近答案。压测矩阵还要覆盖哪些形状方阵只是最方便展示的情况。真实推理、图计算和推荐任务常见m×k乘k×n三个维度相差很大。块大小不应只沿用一个数字i块决定同时更新多少输出行j块影响输出连续写入k块决定输入复用。若k很小分块层级可能比计算本身更贵若n极大右矩阵连续访问与预取更重要。完善基准应至少加入瘦高、扁宽和非整除三组形状。初始化数据也会影响可诊断性。全零矩阵能迅速执行却可能让编译器或硬件走特殊路径全一矩阵便于手算但不容易暴露下标交换。本文的周期公式同时含正负值能降低错误抵消的机会。进一步测试可以用单位矩阵验证左右乘不改变原矩阵用只有一个非零元素的矩阵定位具体索引映射。从毫秒扩展到硬件计数器稳定耗时只能说明“有差异”不能单独证明差异来自缓存。具备分析工具时可观察末级缓存缺失、加载字节数、分支预测和向量指令比例。若分块版耗时下降而缓存缺失没有变化原因可能是编译器更好地展开了循环若缺失下降但耗时未变瓶颈可能转移到计算单元或线程调度。硬件计数器也需要归一化。不同规模的总缺失数不可直接比较应该同时看每次乘加、每个输出元素或每秒的指标。采样工具自身有开销短基准应增加重复次数。测量机器要记录处理器型号、缓存层级、内存频率和编译器版本避免几周后无法复现实验。并行化会改变块设计单线程最优块不一定适合多线程。多个线程若更新相邻输出块可能共享同一缓存行并造成伪共享若都读取同一片右矩阵则共享只读缓存可能是好事。合理切分通常让一个线程独占输出区域并把工作量分配到足够大的块避免调度成本超过计算。线程数也不能无限增加。矩阵规模较小时创建任务和同步的开销占主导内存带宽饱和后再加核心只会争夺数据。压测应分别报告单线程和固定线程数结果并确认两个版本使用相同并行策略。否则所谓算法加速可能只是一个版本偷偷用了更多核心。更快算法与更快实现不是同一层Strassen 一类算法减少乘法次数理论复杂度低于立方但会增加加法、临时空间和实现复杂度只在足够大规模上可能占优。分块没有改变算术复杂度却常能在中等规模直接收益。工程选择应先确定输入尺寸和精度需求再决定优化内存、调用成熟库还是更换算法。生产场景通常优先调用经过向量化和多线程调优的 BLAS。手写版本的价值是理解瓶颈、验证布局和建立回归基线而不是凭一个教学循环取代成熟内核。任何替换都要比较正确性、峰值内存、尾延迟和不同尺寸稳定性而不只看平均吞吐。
返回列表