本技能教过的一切,在一张逐笔数据表里汇合。八列,三种以不同方式读取它的工作,三种内存布局。你要为每个值的地址建模,用自己造的缓存数出每项工作的缺失,用延迟模型算出代价,并证明哪种布局胜出。没有哪种布局赢下所有工作;混合工作负载由混合布局赢下,缺失比结构体数组少 1.5 倍。
一张表,三项工作
逐笔数据表有 8 列、每列 8 字节:时间戳、买价和卖价及其数量、成交价、成交量和一些标志位。一行正好 64 字节,等于一条缓存行。有三项工作读取这张表。扫描读取每一行的价格列,用来算移动平均。报价工作读取每行的买价、卖价和买量,用来构建订单簿。查找读取表中任选的 1,000 行的完整逐笔记录,就像调试器或回放所做的那样。第一个主题里内存有层次,第二个主题里有 64 字节的缓存行,第五个主题里有表的两种布局:结构体数组(同一行的值放在一起)和数组结构体(每列单独存放)。现在有了第三条路,混合布局:把热的报价列放在一起,把冷的列放在一起。
布局就是一组列分组
三种布局其实是同一个想法。布局是一组列分组。同一分组的值逐行存放,各分组依次相连。把所有列放进一个分组就是结构体数组;每列一个分组就是数组结构体;两个各含四列的分组就是我们的混合布局。所以一个函数就能给出任意布局中任意值的地址:跳过包含该列的分组之前的所有分组,再在其中前进 row * len(group) + position 个值。
def address(groups, row, col, n_rows, elem_bytes=ELEM_BYTES): base = 0 for group in groups: if col in group: return base + (row * len(group) + group.index(col)) * elem_bytes base += n_rows * len(group) * elem_bytes1 raise ValueError("column %d is in no group" % col)print(address([[0, 1, 2]], 2, 1, 10))print(address([[0], [1], [2]], 2, 1, 10))print(address([[1, 2], [0]], 3, 0, 10))
56 96 184
- 1一个分组占 n_rows * len(group) * elem_bytes 字节,下一个分组紧接在它后面开始。
手算检查这三个结果。一个含三列的分组里,第 2 行第 1 列是第 2 × 3 + 1 = 7 个值,在第 56 字节。每列一个分组时,第 1 列从第 0 列的 10 个值之后的第 80 字节开始,第 2 行再往后 16 字节:96。混合布局 [[1, 2], [0]] 中,第 0 列的分组排第二,在 10 行每行 2 个值(160 字节)之后,第 3 行再往后 24 字节:184。
缓存从来看不到列和行,只看到地址。所以几行代码就能比较那些在 C++ 里需要不同结构体和不同循环的布局。
计数、定价、证明
一项工作就是它在访问的每一行上要读的那些列。它的访问轨迹是:按顺序对每一行给出这些列的地址;缓存把轨迹变成缺失;第一个主题的延迟模型再把缺失变成时间,命中 1 ns、缺失 80 ns。比较就是对各布局做循环,证明就是最小的那个数。在 4,000 条逐笔数据上跑本讲的三项工作,你会看到最佳布局取决于工作本身。
def workload_trace(groups, n_rows, reads, rows): return [address(groups, row, col, n_rows) for row in rows for col in reads]def layout_misses(groups, n_rows, reads, rows=None): rows = range(n_rows) if rows is None else rows return count_misses(workload_trace(groups, n_rows, reads, rows))def compare_layouts(layouts, n_rows, reads, rows=None): return {name: layout_misses(groups, n_rows, reads, rows) for name, groups in layouts.items()}def best_layout(misses): return min(misses, key=misses.get)def time_ns(accesses, misses, hit_ns=HIT_NS, miss_ns=MISS_NS): return (accesses - misses) * hit_ns + misses * miss_nsprint(workload_trace([[0, 1]], 4, [1, 0], [2]))for job, reads in WORKLOADS.items(): rows = sampled_rows(4000) if job == "lookup" else None result = compare_layouts(LAYOUTS, 4000, reads, rows) print(job, result, "->", best_layout(result))
[40, 32]
scan {'aos': 4000, 'soa': 500, 'hybrid': 2000} -> soa
quote {'aos': 4000, 'soa': 1500, 'hybrid': 2000} -> soa
lookup {'aos': 1000, 'soa': 8000, 'hybrid': 2000} -> aos扫描只要一列:数组结构体每条缓存行装 8 个价格(500 次缺失),而结构体数组每次加载的行有八分之七被浪费(4,000 次缺失)。点查找要一行的全部八个值:结构体数组只要一条缓存行(1,000 次缺失),数组结构体要八条(8,000 次)。报价读热分组四列中的三列,所以混合布局不错,但不是最好。选哪种布局取决于每项工作运行的频率,而这正是混合负载要衡量的。
在扫描上获胜的布局,可能在查找上输八倍。改动真实程序的数据结构之前,先写下有哪些工作在读它、频率各是多少,并像这里一样数出每种布局的缺失。
def study(bars): n_rows = min(int(bars.shape[0]), 4000) misses = {} accesses = 0 for job, reads in WORKLOADS.items(): rows = sampled_rows(n_rows) if job == "lookup" else None misses[job] = compare_layouts(LAYOUTS, n_rows, reads, rows) accesses += len(reads) * (len(rows) if rows is not None else n_rows) mixed = {name: sum(misses[job][name] for job in WORKLOADS) for name in LAYOUTS} best = best_layout(mixed) return {"mixed": mixed, "mixed_best": best, "reduction": mixed["aos"] / mixed[best], "ns": {name: time_ns(accesses, mixed[name]) for name in LAYOUTS}}out = study(load_bars())print(out["mixed"], "->", out["mixed_best"])print(round(out["reduction"], 2), "times fewer misses than the array of structs")print({name: round(ns / 1000) for name, ns in out["ns"].items()}, "microseconds")
{'aos': 9000, 'soa': 10000, 'hybrid': 6000} -> hybrid
1.5 times fewer misses than the array of structs
{'aos': 735, 'soa': 814, 'hybrid': 498} microseconds合计起来,结构体数组缺失 9,000 次,数组结构体 10,000 次,混合布局 6,000 次。混合布局没有哪项工作赢得很漂亮,也没有哪项输得很难看:扫描和报价的代价是数组结构体的两倍,查找的代价是结构体数组的两倍,而混合负载偏爱那个从不糟糕的布局。在延迟模型里,这是 498 µs 对结构体数组的 735 µs:缺失少 1.5 倍,时间少三分之一。这就是结业项目要求的证明,而且只对这一组工作的搭配成立。
热列是大多数工作会一起读的列(这里是买价、卖价和两个数量);冷列很少被读(时间戳、成交价、成交量、标志位)。把每一类放在一起,常见的工作只碰很少的缓存行,少见的工作也不会太贵。
你要实现什么
实验沿着本讲走:任意布局中一个值的地址、一项工作的访问轨迹、一项工作在一种布局和在所有布局中的缺失、最佳布局、一项工作的时间,以及对逐笔表的研究和混合负载的证明。
- 函数 · 问题依赖
- address(groups, row, col, n_rows)这个值在内存的什么位置?简单算术
- workload_trace(groups, n_rows, reads, rows)这项工作会碰到哪些地址?address
- layout_misses(groups, n_rows, reads, rows)这种布局下缺失多少次?workload_tracecount_misses
- compare_layouts(layouts, n_rows, reads, rows)每种布局呢?layout_misses
- best_layout(misses)哪一个胜出?简单算术
- time_ns(accesses, misses)时间上要花多少?简单算术
- study(bars)哪种布局赢下混合工作?compare_layoutsbest_layouttime_ns
下一个技能:性能的内存部分到此完成。优化路线上接下来的技能转向处理器:在向量寄存器里对许多值同时执行同一个操作,以及在内核之间不加锁地共享数据。
实验:评分内容
7 个任务 · 100 分- 值的地址20 分
- 工作的访问轨迹15 分
- 工作的缺失10 分
- 每种布局10 分
- 最佳布局5 分
- 工作的时间10 分
- 基于 SPY 表的研究30 分