逐笔表在内存里可以有两种放法,哪种合适取决于你怎么读。对按行存放的表只扫一列,会加载你用到的内存的五倍;对分列存放的表去读整行,则在反方向上浪费同样多。本讲数出两种布局的缺失数,让数字来做选择。
一张表,两种布局
内存是一长串字节,所以二维的表必须被摊平。有两种自然的方式。在结构体数组里,一天的五个值(开、高、低、收、成交量)放在一起,接着是下一天的五个值,依此类推。在数组结构体里,先是所有的开盘价,再是所有的最高价,依此类推。值和程序都没变,变的是地址。
一个值的地址由它的行和列决定。在 AoS 里,值的编号是“行 × 列数 + 列”;在 SoA 里是“列 × 行数 + 行”。float64 再乘以 8 字节。下面的代码打印一张 100 天、5 列的表中前四天收盘价的地址。
def address(layout, row, col, n_rows, n_cols, elem_bytes=8): if layout == "aos": return (row * n_cols + col) * elem_bytes1 return (col * n_rows + row) * elem_bytesclose = 3print("aos:", [address("aos", row, close, 100, 5) for row in range(4)])print("soa:", [address("soa", row, close, 100, 5) for row in range(4)])
aos: [24, 64, 104, 144] soa: [2400, 2408, 2416, 2424]
- 1在 AoS 里下一行在 n_cols 个值之后,所以相邻两个收盘价相距 5 个值(40 字节)。
AoS 里相邻天的收盘价相距 40 字节;SoA 里只相距 8 字节,一个紧挨着一个,正是本技能第一个主题里的“按顺序读”。
扫描一列
对收盘价求平均,就是读表的一列。我们把这次读取变成地址列表,送进前面几个主题里搭好的缓存,数 1,000 天的缺失。最后打印的数是有效比例。
def scan_column(layout, col, n_rows, n_cols): return [address(layout, row, col, n_rows, n_cols) for row in range(n_rows)]for layout in ("aos", "soa"): misses = count_misses(scan_column(layout, 3, 1000, 5)) print(layout, "misses:", misses, "- useful share:", round(1000 * 8 / (misses * 64), 3))
aos misses: 625 - useful share: 0.2 soa misses: 125 - useful share: 1.0
SoA 的扫描在 1,000 个值上缺失 125 次,每八个值一次,毫无浪费。AoS 的扫描缺失 625 次,多了五倍,加载的字节里只有 20% 是收盘价。其余 80% 是平均值根本不看的开、高、低和成交量,它们还会把有用的行挤出缓存。
读整行会让结果反过来
很多逐笔程序问的是另一个问题:对这一笔,把价格、数量和方向一起给我。这会读一行的全部值。在 AoS 里整行是一块 40 字节,落在一两条缓存行里;在 SoA 里这五个值在五个不同的数组中,所以每一行要打开五条缓存行。代码每七行读一行,并数出缺失。
def scan_rows(layout, rows, n_rows, n_cols): return [address(layout, row, col, n_rows, n_cols) for row in rows for col in range(n_cols)]rows = list(range(0, 1000, 7))for layout in ("aos", "soa"): print(layout, "misses:", count_misses(scan_rows(layout, rows, 1000, 5)))
aos misses: 214 soa misses: 625
这回 AoS 以三倍的优势获胜:缺失 214 次对 625 次。扫描一列时差五倍的布局,在读整行时好三倍。没有哪种布局始终最好,各有各擅长的读法。
NumPy 的二维数组按默认顺序就是 AoS 布局:每一行是连续的。pandas 的 DataFrame 把同类型的列放在一起,接近 SoA,这也是它按列运算很快的原因之一。
让计数来选择
真实的程序两种读法都有,所以选择取决于两者的比例。对每种布局把整个负载的缺失加起来:扫列次数 × 列缺失 + 读行次数 × 行缺失。总数小的布局获胜,平局给 AoS。下面的代码在同一张表上试了四种比例。
def compare(n_rows, n_cols, col, rows): out = {} for layout in ("aos", "soa"): out[layout] = { "column": count_misses(scan_column(layout, col, n_rows, n_cols)), "rows": count_misses(scan_rows(layout, rows, n_rows, n_cols)), } return outdef best_layout(n_column_scans, n_row_reads, n_rows, n_cols, col, rows): counts = compare(n_rows, n_cols, col, rows) totals = {layout: n_column_scans * counts[layout]["column"] + n_row_reads * counts[layout]["rows"] for layout in counts} return "soa" if totals["soa"] < totals["aos"] else "aos"rows = list(range(0, 1000, 7))for scans, reads in ((1, 4), (1, 2), (1, 1), (4, 1)): print(scans, "column scans per", reads, "row reads:", best_layout(scans, reads, 1000, 5, 3, rows))
1 column scans per 4 row reads: aos 1 column scans per 2 row reads: aos 1 column scans per 1 row reads: soa 4 column scans per 1 row reads: soa
每两次读行才有一次扫列时,AoS 赢;每次读行就有一次扫列时,已经是 SoA 赢。盈亏平衡点大约在每次读行配半次扫列附近,你的真实负载决定你站在哪一边。重排一张表之前,先数清你的程序实际做哪些读取。
你要实现什么
实验沿着本讲走:两种布局、两种读法、比较、按负载选择、有效比例,以及对真实 SPY 表的研究。
- 函数 · 问题依赖
- address(layout, row, col, n_rows, n_cols)这个值在哪里?简单算术
- scan_column(layout, col, n_rows, n_cols)扫一列会读哪些地址?address
- scan_rows(layout, rows, n_rows, n_cols)读整行会碰到哪些地址?address
- compare(n_rows, n_cols, col, rows)每种读法在每种布局下缺失多少次?scan_columnscan_rows
- best_layout(...)这个负载适合哪种布局?compare
- useful_fraction(layout, col, n_rows, n_cols)加载的内容里用到了多少?scan_column
- study(bars)SPY 表适合哪种布局?comparebest_layout
下一个主题:布局决定了一张表的步长。在遍历顺序与循环分块里,表变成矩阵,你要选循环的顺序,再把循环切成小块,让矩阵的一小块在被使用期间一直留在缓存里。
实验:评分内容
7 个任务 · 100 分- 一个值的地址15 分
- 扫描一列10 分
- 读取整行10 分
- 两种读法的缺失20 分
- 适合负载的布局15 分
- 有效比例15 分
- 基于 SPY 表的研究15 分