ARTICLE DETAIL

资讯详情

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

缓存无关数据结构:递归分块如何让二分搜索缓存最优

缓存无关数据结构:递归分块如何让二分搜索缓存最优 最近在啃《Handbook of Data Structures and Applications》翻到Cache-Oblivious Data Structures这一章的时候我是真有点相见恨晚的感觉。这个词我第一次看到时很困惑缓存无关难道写代码不需要关心缓存了那怎么可能高性能。等我把第四章啃完才明白这恰恰是另一种“极致关心”——你不告诉算法缓存长什么样算法却能在所有缓存层级都拿到接近最优的访问效果。这篇文章就是一份学习笔记加实操总结适合已经熟悉基本数据结构、想搞懂Cache-Oblivious到底在讲什么的人也适合那种“普通二分搜索已经很好了还想再压榨一点性能”的朋友。这一章的理论门槛不算低但它解答了我特别多的疑问为什么递归分块能同时适应L1、L2、L3为什么有人说二分搜索其实缓存很不友好为什么有些排序算法在数据量大了之后反而更依赖内存带宽而不是比较次数搞清楚这些问题比背下来几个数据结构本身有用得多。1. 缓存无关到底在解决什么问题1.1 算法复杂度算不到的那部分开销我们平时分析复杂度默认所有内存访问代价一样。数组取第i个元素是O(1)链表访问第i个节点是O(n)对吧。但真实机器上根本不是这样CPU从寄存器拿数据是几个周期从L1 Cache拿是几个周期从L2拿是十几个周期从L3拿可能是几十个周期真到主存就是几百个周期了。差距可以到两个数量级以上。我最早意识到这个问题是有一年调一个哈希表的实现。明明理论复杂度没变就是把节点从散落的内存改成连续数组存储查询速度快了将近一倍。当时我还以为是玄学后来才知道这就是cache locality。每次CPU访问内存不是只取一个字节而是把一整块数据通常64字节搬到缓存里这叫cache line。如果你访问的数据在内存里挨得近第一个字节命中之后后面好几个数据其实已经白送了。《Handbook》第四章第一部分就在强调这件事传统的随机访问模型RAM模型在分析大规模数据时严重失真。所以研究人员引入了外部存储器模型也叫I/O模型。这个模型假设有两层存储内层容量M外层容量无限内外层之间按块传输块大小B。算法消耗的时间主要看“块传输次数”也就是I/O次数。Cache-Oblivious数据结构的所有结论基本都是在这样一个模型里证明的。1.2 Cache-Aware和Cache-Oblivious的路线分歧如果你想优化缓存最直接的想法是了解一下我这台机器L1有多大、cache line多长然后把数据分块成适配这个尺寸的chunk。这个思路叫Cache-Aware。矩阵乘法里的分块tiling就是典型实测确实快块大小选对了能翻倍。但问题很明显你针对这台机器调的参数换到另一台CPU上就不一定最优了。云服务器、手机、嵌入式设备缓存参数全不一样每换一个环境都要重新调。Cache-Oblivious走的是另一条路不知道M也不知道B但是用递归分块的方式去组织数据让数据访问在任意缓存层级上都保持局部性。听起来像魔法核心其实就一条反复把问题对半或者按平方根切分切到某个子问题刚好能塞进当前层级的cache为止。由于递归分块是自相似的无论缓存有多大总有一层递归能把块大小匹配到缓存和cache line的尺寸附近。这里有个前提条件叫tall cache假设也就是缓存大小M至少是块大小B的平方。现实中这个条件基本都满足L1的64KB对64字节的cache line远远大于B²。这个假设是很多复杂度分析成立的关键如果M只比B大一点点缓存根本装不了几个块局部性优势也就无从谈起。2. Handbook第四章核心内容拆解2.1 静态搜索结构vEB布局到底在干什么普通的有序数组二分搜索复杂度是O(log N)次比较每次比较访问数组中间位置。听起来很好但问题在于这些中间位置在内存里是均匀分布的每个位置所在的cache line都可能不同。也就是说一次二分搜索要做log N次内存访问几乎每次都可能miss。数据量一旦超过缓存容量二分搜索的性能会急剧下降。Cache-Oblivious的解法是把搜索树按一种叫van Emde Boas布局的方式重新排列。做法很优雅把一棵高度为h的完全搜索树从中间一分为二顶层是一个高度大约h/2的子树块底下一堆高度h/2的子树块。存储的时候先把顶层子树完整连续存放再依次存放每一个底层子树块。每个子树块内部再递归按相同方式分块。这样搜索的时候顶层子树块很小可以放进cache line甚至放进L1读完顶层块找到要去的方向下一个访问的子块又恰好是上一次访问块的附近区域。分析下来搜索的cache miss次数能降到O(log_B N)也就是以cache line能容纳的节点数为底。这个提升是本质性的。Handbook第四章把静态搜索结构当作第一个重点就是因为它最好地展示了“递归分块换局部性”的通用套路。2.2 动态结构缓存无关B-tree的实现思路静态搜索树只管查询不解决插入删除。数据结构要动态起来难度直接上一个台阶。Cache-Oblivious的B-tree是这一章真正的硬核内容。教科书上缓存的B-tree大概是这样不严格保证每个节点像普通B-tree那样恰好半满以上而是允许节点在一定区间内“松散”。插入的时候先沿着树往下找位置如果叶子节点还能塞就塞进去塞满了就往父节点合并/分裂。关键技巧是定期重构子树把不够紧凑的节点重新组织成连续块。这样每次插入可能只影响局部一小块数据不需要全树重建。《Handbook》里引用的方案核心是把“更新”和“布局”解耦。更新通过缓冲区批量处理缓冲区满了一次性合并进下层结构。这样摊还下来每个插入/删除的I/O复杂度依然是O(log_B N)量级。但说实话完整的缓存无关B-tree实现代码量非常大Chapter里的描述也更偏理论设计。我自己看完之后的感觉是理解它的设计哲学比死磕每一行伪代码更重要。2.3 遍历、排序与优先队列还有一个很容易被忽略的点其实顺序扫描本身就是天然缓存无关的。你把一个数组从头到尾读一遍不管cache多大、块多大每个块刚好被读一次已经是I/O最优。所以很多Cache-Oblivious算法里base case最后都是退化成顺序扫描。排序是另一个经典场景。普通归并排序在数据规模大时归并过程会在内存里来回跳cache miss很多。缓存无关的Funnelsort通过构造一个多路漏斗让归并阶段的数据流恰好按块连续访问排序总的I/O次数能达到O((N/B) log_{M/B}(N/B))这是外部排序的最优下界。第四章里还提到了缓存无关的优先队列思路类似用分层缓冲和批量合并来保持局部性。这些结构在理论界很热工程里直接用的不多但其中的分块和批量思想影响了很多数据库和存储引擎的设计。3. 一个可运行的Cache-Oblivious布局搜索示例3.1 从教科书到代码先序递归布局的BST教材里的vEB布局要实现完整版并不简单尤其是搜索时还要做块内二分。我实际练手时先写了一个简化版本用递归分块的方式把平衡二叉树节点存在数组里保证每棵子树的节点在物理内存上是连续的一段。这不算严格意义的vEB但它把“子树连续存储”这件事体现得非常直观。#include vector #include algorithm struct Node { int key; int left; int right; }; std::vectorNode nodes; int build(std::vectorint keys, int l, int r) { if (l r) return -1; int mid (l r) / 2; int id (int)nodes.size(); nodes.push_back({keys[mid], -1, -1}); // 先放根节点 int leftChild build(keys, l, mid); // 左子树连续分配 int rightChild build(keys, mid 1, r); // 右子树连续分配 nodes[id].left leftChild; nodes[id].right rightChild; return id; } bool search(int id, int key) { while (id ! -1) { if (key nodes[id].key) return true; id key nodes[id].key ? nodes[id].left : nodes[id].right; } return false; } int main() { std::vectorint keys; for (int i 0; i 1000000; i) keys.push_back(i * 2); int root build(keys, 0, (int)keys.size()); // 查询一个不存在的key触发整棵树的搜索路径 bool found search(root, 999999); return found ? 1 : 0; }这段代码的要点是nodes数组里每个子树的所有节点都在连续区间。左子树在根节点后面紧接着分配右子树再往后的连续区域。搜索时不管往左还是往右进入的都是一块连续内存cache命中率比乱序好很多。3.2 如何验证cache miss减少理论说了半天到底快没快还是要拿数据说话。最直接的指标不是时间而是cache miss数量。Linux上可以用perf工具perf stat -e cache-misses,cache-references ./your_program我拿这个简化版和另一种“数组随机分配节点”的版本对比在百万节点规模时递归布局版本cache miss能明显少一截。前提是数据规模得超过L3 cache否则数据整个就在cache里怎么测都差不多。还有几个坑要提醒第一编译要开-O2否则递归和函数调用开销会淹没访问差异第二搜索的key别按顺序来顺序访问会命中预取器测不出真实差距第三跑之前多预热几次让TLB和分支预测器进入稳定状态。3.3 真正vEB布局的方向我上面给的代码是“对半分块”要让复杂度真正达到O(log_B N)需要按平方根划分块。大致思路是设当前子树节点数为n先构造一个包含约√n个节点的顶层子树然后递归构造√n个大小约√n的子树块。搜索时先在顶层子树内做二分定位再进入对应子块继续搜索。因为顶层子树和子块在内存里都连续所以每次定位都相当于读一两个cache line。我自己没有把完整版写出来因为对工程来说简化版已经能达到大部分局部性收益。但如果你想彻底理解这一章建议还是照着书上的伪代码实现一遍完整vEB布局那个过程能帮你把“递归分块、块内连续、块间跳转”这三个概念焊死在脑子里。4. 实操经验设计缓存无关结构最容易踩的坑4.1 缓存无关不等于无脑递归递归分块是核心理念但递归本身有代价。每次函数调用都有栈帧开销而且小规模数据递归分块的意义不大。我记得第一次实现时一路递归到叶子节点结果构建过程慢得离谱搜索也没快到哪去。后来加了阈值当子问题大小小于等于16或者32时直接按顺序处理不再继续递归。这个阈值在Cache-Oblivious结构里叫base case选得好不好直接影响实际性能。选择阈值有个经验法则让base case的大小尽量接近cache line能容纳的元素个数或者稍微小一点。比如64字节的cache line如果存8字节的整数那一个块大约8个元素但考虑到不同层级取16、32作为阈值都合理。这个参数不用精确因为它本来就是用来吸收递归开销的不是用来做算法正确性判断的。4.2 不要忽略构建成本Cache-Oblivious结构往往需要预先重排数据构建成本可能很高。比如上面那个搜索树构建时要递归分配节点、写整棵树的连续数组建树本身就有可能要比普通数组多花时间。如果数据是只读的、查询特别多那构建成本完全值得摊薄但如果数据频繁更新每次更新都要重新布局那就得不偿失。这其实是所有静态布局方案的通病也是动态缓存无关B-tree存在的理由。工程上常见的折中是数据写入时先堆在内存缓冲区里缓冲区到了一定大小再做批量重排。这样既保持构建成本可控又让查询能享受到连续布局的好处。这招在LSM-tree里见得特别多思想根源就在这。4.3 测量方式决定优化方向刚开始做缓存优化很容易只盯运行时间。时间当然是最重要的指标但它受干扰项影响太大操作系统调度、CPU降频、后台进程都会让几十毫秒的波动覆盖掉cache miss的收益。更可靠的指标是cache-misses和cache-references的比值也就是miss rate。miss rate掉下来运行时间通常也会跟着掉。但miss rate不是唯一标准。有时候分支预测错误、TLB miss、内存带宽饱和也会成为瓶颈。我踩过的一个典型例子是递归布局降低了cache miss但引入的大量分支判断让分支预测失败率上升最后时间反而没太大改善。所以正确姿势是先用perf看整体事件再针对占比最高的事件做优化而不是一刀切只盯cache。4.4 缓存无关和缓存感知不是对立关系学完这一章我反而对缓存感知方案更宽容了。Cache-Oblivious的优势是你不知道目标机器参数也能接近最优但如果你真的知道参数那Cache-Aware能做更精细的调整。比如递归布局的阈值、分块大小都可以根据本机L2容量微调数据库里预取距离、页大小这些参数也还是得按硬件配置调。所以正确的理解是Cache-Oblivious提供了一个稳健的默认方案Cache-Aware是在此之上再做局部榨取。项目里如果要求跨平台部署优先用Cache-Oblivious设计如果确定只跑在一类机器上可以在基础布局上再叠一层硬件参数微调。5. 常见问题与排查技巧速查表问题可能原因排查建议递归布局后反而更慢数据规模太小全部在缓存里用超过L3总容量的数据再测构建时间占比过高递归过深、阈值太小增大base case阈值避免到叶子才停查询时间没有改善key按顺序访问触发了预取器改用随机key访问或随机搜索路径测试结果波动大没有关闭频率调节或后台干扰固定CPU频率多次运行取中位数miss率降了但时间没降分支丢失或递归函数调用开销高开O2优化尝试尾递归或迭代搜索动态插入后性能退化没有批量重排布局被破坏参考缓存无关B-tree做批量更新这个表是我自己在调优时反复用到的排查逻辑。很多时候性能问题不是单一因素先看miss率、再查分支、再看TLB一层层往下基本都能定位到瓶颈。6. 从理论到工程这个思想还能用在哪Cache-Oblivious的价值远不止搜索树。空间数据里常用的Z-order曲线本质上就是把二维点映射成一维连续序列让空间上近邻的点在存储上也近邻这就是缓存无关的局部性思想。列式数据库按列连续存储也是为了让同列数据扫起来有完美的顺序访问。GPU和CPU多级缓存差异更大一个不需要根据具体缓存参数调整的分块算法在异构平台上能省掉一大堆到处适配的工作。学完这一章我个人最大的变化是写算法不再只看时间复杂度了。遇到大规模数据遍历或查询我会先问一句——这个数据结构的内存访问模式是否连续如果不是能不能用递归分块改成连续这句自我提问比记住任何一个具体结构都值钱。最后分享一个小技巧想快速感受Cache-Oblivious的威力就拿一个超过L3大小的数组分别用普通二分搜索和递归布局搜索去查随机key再用perf看cache-misses。第一次看到差距的时候你会真正理解什么叫“访问模式决定了性能上限”。
返回列表