缓存只对你会再次读取的数据有帮助,关键在于多快会再读。本讲用一个数,重用距离,来衡量它,并用它一次性预测所有缓存大小的命中率,包括循环只是稍微大了一点就装不下的那道悬崖。
再次读同一行
前两个主题讲的是行放到哪里,这一个讲的是它们什么时候回来。一个程序读了一个价格,做点事,不久后又读同一个价格,这就是时间局部性。间隔越短,这条行还留在缓存里的机会就越大。想想书桌上的书:一分钟前用过的还在,上个月用过的早已放回书架。
对某条行的一次读取而言,从上一次读这条行到现在之间,读过的其他不同行的个数。一条行的第一次读取没有距离。
连续读两次第 5 行,距离是 0。在序列 1, 2, 3, 1 里,最后一次读的距离是 2,因为中间来过第 2 行和第 3 行。在两次读第 1 行之间读了十次第 2 行,也只算一条行。代码维护一个按最近使用时间排序的行列表,最近的在最后。一条行再次出现时,它在列表中后面的那些行,恰好就是此后用过的行。
def reuse_distances(lines): stack = [] out = [] for line in lines: if line in stack: position = stack.index(line) out.append(len(stack) - 1 - position)1 stack.pop(position) else: out.append(None) stack.append(line) return outprint(reuse_distances([1, 2, 3, 1, 2, 1, 1]))
[None, None, None, 2, 2, 1, 0]
- 1列表里排在它后面的行,正是自它上次被读以来用过的行:它们的个数就是距离。
从距离到命中率
这里有个很有用的事实。取一个能装 C 条行、并替换最近最少使用行的缓存,不论行号是什么。它任何时候装的都是最近用过的 C 条不同的行。一次读取命中,恰好是它的行在其中,也就是自上次使用以来读过的不同行少于 C 条。换句话说,当重用距离小于 C 时这次读取命中。
下面的代码验证这个说法。它对容量 1 到 4 模拟 LRU 缓存,同时只用距离、完全不用缓存来算命中率。两列结果一致。距离只需算一次,就能给出每种缓存大小的命中率。
def lru_hit_rate(lines, capacity): cache = [] hits = 0 for line in lines: if line in cache: hits += 1 cache.remove(line) elif len(cache) >= capacity: cache.pop(0) cache.append(line) return hits / len(lines)def hit_rate_from_distances(distances, capacity): hits = sum(1 for d in distances if d is not None and d < capacity) return hits / len(distances)lines = [1, 2, 3, 1, 2, 3, 4, 1, 2, 3]d = reuse_distances(lines)for capacity in (1, 2, 3, 4): print(capacity, "lines:", round(lru_hit_rate(lines, capacity), 3), round(hit_rate_from_distances(d, capacity), 3))1
1 lines: 0.0 0.0 2 lines: 0.0 0.0 3 lines: 0.3 0.3 4 lines: 0.6 0.6
- 1两列数字相同:只靠距离就能预测每种缓存大小的命中率。
没有距离的话,每测一种大小就得重新跑一次模拟器。有了距离,对轨迹遍历一遍就能得到命中率随缓存大小变化的整条曲线。性能工程师把它叫作缺失率曲线。
工作集与悬崖
一段读取的工作集是它们碰到的不同行的集合。工作集装得进缓存,程序几乎完全从缓存里运行;如果只比缓存大一点点,LRU 可能彻底失效。取一个把 48 条行按顺序读三遍的循环:每条行在读过 47 条别的行之后才被再读,所以所有重用距离都是 47。因此 47 条行及以下的缓存一次都不命中,而 48 条行的缓存在第一遍之后每次读都命中。
def sweep_lines(n_lines, passes): return list(range(n_lines)) * passeslines = sweep_lines(48, 3)for capacity in (16, 32, 47, 48): print(capacity, "lines:", round(lru_hit_rate(lines, capacity), 3))
16 lines: 0.0 32 lines: 0.0 47 lines: 0.0 48 lines: 0.667
当循环大到超出缓存时,命中率不会平滑下降。对于 LRU 和以同样顺序反复遍历同样行的循环,命中率会在工作集刚好装不下的那一条行处,从 0.667 一下掉到 0.0。一个程序可能在某个输入规模下很快,而在稍大一点的规模下慢上许多倍。
对真实价格做滚动均值
按笨办法算的滚动均值,每天都读最近 20 个值:也就是重读前一天读过的 20 个值中的 19 个。这是个时间局部性很强的程序。代码为前 600 个收盘价构造读取地址、转成行号,测量重用距离、四种缓存大小下的命中率,以及连续 20 次读取的工作集。
def rolling_mean_trace(n, window, elem_bytes): trace = [] for end in range(window - 1, n): for position in range(end - window + 1, end + 1): trace.append(position * elem_bytes) return tracedef working_set(lines, window): return max(len(set(lines[i:i + window])) for i in range(len(lines) - window + 1))lines = [address // 64 for address in rolling_mean_trace(600, 20, 8)]d = reuse_distances(lines)print(len(lines), "reads from", len(set(lines)), "lines")for capacity in (1, 2, 4, 8): print(capacity, "lines:", round(hit_rate_from_distances(d, capacity), 3))print("working set of 20 reads:", working_set(lines, 20), "lines")
11620 reads from 75 lines 1 lines: 0.831 2 lines: 0.831 4 lines: 0.994 8 lines: 0.994 working set of 20 reads: 4 lines
只要一条行,就能让 83% 的读取命中,因为数组的下一个值就在刚读的那条行里。四条行达到 99.4%。整个数据集的 75 条行根本不需要都装进去:20 次读取的工作集只有 4 条行,所以很小的缓存就够了。这就是数据大小与工作集大小的区别。
你要实现什么
实验要求你实现上面这些部分,再加一个为给定命中率找最小缓存的辅助函数,最后对真实的 SPY 收盘价做研究。
- 函数 · 问题依赖
- reuse_distances(lines)每条行多久会被再读?按最近使用顺序的列表
- lru_hit_rate(lines, capacity)模拟的 LRU 缓存命中多少?按 LRU 顺序的行
- hit_rate_from_distances(distances, capacity)不用缓存也能得到同样的命中率?reuse_distances
- working_set(lines, window)一段读取会碰到多少条行?滑动窗口
- capacity_for(lines, target)缓存最小可以多小?reuse_distanceshit_rate_from_distances
- rolling_mean_trace(n, window, elem_bytes)笨办法的滚动均值读哪些地址?简单算术
- study(bars)SPY 收盘价上的滚动均值局部性有多强?rolling_mean_tracecapacity_for
下一个主题:你现在知道布局如何变成访问轨迹、轨迹如何变成命中与缺失。在结构体数组与数组结构体里,你要用它为逐笔表选布局,比较两种布局下读列和读整行的表现。
实验:评分内容
7 个任务 · 100 分- 重用距离20 分
- 模拟的 LRU 缓存15 分
- 由距离得到的命中率10 分
- 窗口的工作集15 分
- 最小的缓存10 分
- 笨办法的滚动均值10 分
- 基于 SPY 表的研究20 分