只有程序必须等待时,一次缺失才会花 80 ns。预取器会赶在程序之前请求缓存行,等到读取真正到来时,等待已经结束。本讲搭两个预取器,用覆盖率和准确率给它们打分,并说明它们在哪里有效(步长规则的循环),在哪里无能为力(顺序无法预测的读取)。
在需要之前就请求
到目前为止,缓存只在读取未命中时才加载缓存行。预取则把加载提前。如果猜对了,之后到来的读取就会发现这条行已经在缓存里,直接命中。现代处理器都有硬件预取器,监视读取的地址并猜接下来会读什么。最简单的猜测是:读了第 L 行的人接下来会要第 L + 1 行。我们提供的缓存有个 fill 方法,加载一条行而不计命中也不计缺失,这正是预取。
def baseline_misses(trace): cache = Cache() for address in trace: cache.access(address) return cache.missesdef next_line_misses(trace): cache = Cache() for address in trace: cache.access(address) cache.fill((address // LINE_BYTES + 1) * LINE_BYTES)1 return cache.missestrace = [8 * i for i in range(800)]print("no prefetch:", baseline_misses(trace), "- next line:", next_line_misses(trace))jumps = [i * 4096 for i in range(50)]print("jumps of 4096 bytes:", baseline_misses(jumps), "- next line:", next_line_misses(jumps))
no prefetch: 100 - next line: 1 jumps of 4096 bytes: 50 - next line: 50
- 1这里预取下一条缓存行不花任何代价:
fill既不算命中也不算缺失。
按顺序读 800 个值,普通缓存每条缓存行缺失一次,共 100 次。用了“下一行预取器”,总共只缺失一次:在第一次读之后,接下来需要的那条行总是已经在缓存里了。第二个例子显示了它的局限。如果程序每次跳 4096 字节,当前行的下一行毫无用处,预取器什么也省不下:50 次读取仍然缺失 50 次。
预测下一条行:步长
真实的预取器会在程序读取的缓存行里找规律。最常见的是固定步长:程序读第 0、3、6、9 行,每次跳三行。步长检测器记住上一条行和上一个差值,当新的差值等于上一个时就认定有规律,下一条行就是当前行加上这个差值。要有两个相等的差值才能确定,所以前两次读取它从不猜。
- 1忽略重复再读同一条行,说明不了任何步长。
- 2计算差值新行减去上一行。
- 3与上一个差值比较差值相等就是有规律:预测新行加上差值。
- 4记住把新差值和新行留给下一次读取。
class StridePrefetcher: def __init__(self): self.last_line = None self.last_delta = None def observe(self, line): if line == self.last_line: return None prediction = None if self.last_line is not None: delta = line - self.last_line if delta == self.last_delta and line + delta >= 0:1 prediction = line + delta self.last_delta = delta self.last_line = line return predictionp = StridePrefetcher()print([p.observe(line) for line in [0, 3, 6, 9, 12]])p = StridePrefetcher()print([p.observe(line) for line in [5, 5, 5, 6, 7]])
[None, None, 9, 12, 15] [None, None, None, None, 8]
- 1连续两个相等的差值:这是一个步长,所以下一行就是当前行加上差值。
第一个序列是三行一跳的步长。检测器在前两次读取时什么都不说,之后预测 9、12、15,始终领先一步。第二个序列里对第 5 行的重复读取被忽略,步长 1 直到最后一次读取才被确认。好的检测器很谨慎:猜错是有代价的,下面就会看到。
预取器值多少
用两个数给预取器打分。覆盖率说明它解决了多少问题;准确率说明它的工作有多少是有用的。为了测量,我们让轨迹通过缓存:先做一次读取,问预取器接下来要装什么,记下哪些行是被预取的,并在一次读取命中其中某条时计数。
def run_with_prefetcher(trace, prefetcher): cache = Cache() marked = set() prefetches = 0 useful = 0 for address in trace: line = address // LINE_BYTES if cache.access(address): if line in marked: useful += 1 marked.discard(line) else: marked.discard(line) target = prefetcher.observe(line) if target is not None and not cache.contains(target * LINE_BYTES): cache.fill(target * LINE_BYTES) prefetches += 1 marked.add(target) return {"demand_misses": cache.misses, "prefetches": prefetches, "useful": useful}def quality(trace): baseline = baseline_misses(trace) run = run_with_prefetcher(trace, StridePrefetcher()) coverage = 1 - run["demand_misses"] / baseline accuracy = run["useful"] / run["prefetches"] if run["prefetches"] else 0.0 return baseline, run, round(coverage, 3), round(accuracy, 3)import randomordered = [8 * i for i in range(2000)]shuffled = [8 * p for p in random.Random(1).sample(range(2000), 2000)]print("in order:", quality(ordered))print("shuffled:", quality(shuffled))
in order: (250, {'demand_misses': 3, 'prefetches': 248, 'useful': 247}, 0.988, 0.996)
shuffled: (250, {'demand_misses': 250, 'prefetches': 1, 'useful': 0}, 0.0, 0.0)对按顺序的读取,预取器消除了 98.8% 的缺失,99.6% 的预取都被用上。对打乱顺序的读取它什么也不做:没有重复的步长,它几乎从不发出预取(只有一次),因此也不会造成坏处。这正是期望的行为。一个老在瞎猜的预取器会把缓存塞满没人读的行。
无用的预取会占用内存带宽,还可能把程序仍然需要的缓存行挤出缓存。只有准确率高的预取器才值得拥有。这里的缓存足够大,能掩盖这种损害;在小缓存或繁忙的内存系统里,猜错的预取器会让程序比没有预取器时更慢。
读 SPY 收盘价的三种方式
实验用三种方式读 2,000 个收盘价:一个接一个存放、按顺序读;存放在每行五个值的行里(步长 40 字节);一个接一个存放,但按成交量从小到大的顺序读。前两种有规则的模式;第三种访问天数的顺序看起来是随机的,因为某一天的成交量对下一天的成交量没有任何提示。
关键在于预取器回答的问题:接下来是什么?如果答案藏在地址的规律里,硬件白送你。如果顺序取决于数据,比如按成交量排序后访问各行,谁也猜不出来。软件上的办法是改变数据布局,让访问重新变得规则,例如先把各行排好序,再按顺序读取。
你要实现什么
实验搭建两个预取器、给它们打分的模拟,以及对三种读 SPY 收盘价方式的研究。
- 函数 · 问题依赖
- baseline_misses(trace)不预取时缺失多少?Cache
- next_line_misses(trace)用下一行预取器时缺失多少?Cache.fill
- StridePrefetcher().observe(line)下一行是哪条?上一行与上一个差值
- run_with_prefetcher(trace, prefetcher)发生了什么:缺失、预取、有用的预取?Cache
- quality(trace)它的覆盖率和准确率是多少?baseline_missesrun_with_prefetcher
- study(bars)预取器在 SPY 收盘价上哪里有帮助?qualitynext_line_misses
下一个主题:到目前为止,地址就是缓存行,只需要关心缓存命中。在 TLB 与大页里,每个地址首先要从虚拟页翻译成物理页,而存放翻译结果的小缓存 TLB 也会未命中,其方式取决于页的大小。
实验:评分内容
6 个任务 · 100 分- 不预取10 分
- 下一行预取器15 分
- 步长预取器20 分
- 模拟预取器25 分
- 覆盖率与准确率15 分
- 基于 SPY 表的研究15 分