σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
uantCraft Academy路线图学习路线
Ctrl K
AO
+1.2k 位学习者
登录加入 ↗
路线图学习路线
AO
+1.2k 位学习者
登录
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
uantCraft Academy路线图学习路线
Ctrl K
AO
+1.2k 位学习者
登录加入 ↗
路线图学习路线
AO
+1.2k 位学习者
登录
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
Cache locality 学习路线›模块 2 · 布局与遍历›主题 2.2·~55分钟中级

遍历顺序与循环分块

路线进度模块 2/3 · 主题 2/2
2.2 遍历顺序与循环分块
—实验得分— / 100总 XP—连续天数—

阅读约 5 分钟4 个代码示例

  1. 01哪个循环放在内层?
  2. 02当两个循环互相矛盾:转置
  3. 03你要实现什么

两个嵌套循环访问矩阵里完全相同的单元,仅仅因为哪个循环在内层,缓存缺失数就可能相差 七倍。本讲比较按行读和按列读,再把转置切成小块,让一块数据在被使用期间一直留在缓存里,并在一个由 SPY 收盘价构成的矩阵上测量这两者。

01

哪个循环放在内层?

n 行 n 列的矩阵按行优先顺序存放。沿着一行前进(改变 j)的循环读到的是相邻的值;沿着一列向下走(改变 i)的循环每一步都要跳过整整一行。下面两个循环访问的是同样的单元,一个把 j 放在内层,一个把 i 放在内层。我们打印一个 4×4 的 float64 矩阵的前六个地址。

main.pyPython
def row_major_trace(n):    return [(i * n + j) * 8 for i in range(n) for j in range(n)]def col_major_trace(n):    return [(i * n + j) * 8 for j in range(n) for i in range(n)]1print("row:", row_major_trace(4)[:6])print("col:", col_major_trace(4)[:6])
row: [0, 8, 16, 24, 32, 40]
col: [0, 32, 64, 96, 8, 40]
  1. 1i 在内层时,下一次读取要往后一整行(这里是 4 个值 = 32 字节)。

按行读时地址每次增加 8 字节,也就是第一个主题里的步长,一条缓存行能服务八次读取。按列读时这里每次跳 32 字节,在更大的矩阵里则每次跳一整行的长度。现在我们在一个 4 KB 的小缓存里,对 16×16 到 73×73 的矩阵数出两种顺序的缺失。

main.pyPython
for n in (16, 32, 48, 73):    print(n, "x", n, "row:", count_misses(row_major_trace(n)), "col:", count_misses(col_major_trace(n)))
16 x 16 row: 32 col: 32
32 x 32 row: 128 col: 1024
48 x 48 row: 288 col: 2304
73 x 73 row: 667 col: 4573

16×16 的矩阵只有 2 KB,装得进缓存,加载之后两种顺序都是 32 次缺失,一样。从 32×32 起矩阵就装不下了,按列的顺序每次读都未命中:1,024 次对 128 次,差八倍,正好是一条缓存行里的八个值。到 73×73 时差距接近七倍。循环的形状没变,变的是数据的大小。

不要只看形状,也要看大小

矩阵比缓存大时,交换循环不花任何代价就能见效;矩阵装得下时则毫无作用。拿小矩阵做测试可能完全看不出问题,到了真实数据上程序才变慢。要在真实大小上测量。

02

当两个循环互相矛盾:转置

有时任何循环顺序都帮不上忙。转置 B[j][i] = A[i][j] 读 A 写 B,循环若沿着 A 的行走,就会沿着 B 的列往下走。不论哪个循环在内层,两个矩阵里总有一个要以大步长被遍历。解决办法是循环分块:把矩阵分成小的方块来处理,让一块碰到的那几条行在被替换之前被多次使用。

  1. 1
    选择块大小 t小到让 A 的一块和 B 的一块能同时装进缓存。
  2. 2
    遍历各个块两层外循环,步长为 t:ii 和 jj 给出一个块的角。
  3. 3
    在块内部处理两层内循环只访问这个块的 t×t 个单元,与之前一样。
  4. 4
    进入下一个块到这时程序才丢下旧块的那些行。

下面的代码只列出 4×4 矩阵在不分块和用大小为 2 的块时各单元被访问的顺序。分块后,程序先把左上角 2×2 的块做完,才去碰别的单元。

main.pyPython
def visit_order(n, tile=None):    step = n if tile is None else tile    order = []    for ii in range(0, n, step):1        for jj in range(0, n, step):            for i in range(ii, min(ii + step, n)):                for j in range(jj, min(jj + step, n)):                    order.append((i, j))    return orderprint("plain:", visit_order(4)[:8])print("tile 2:", visit_order(4, 2)[:8])
plain: [(0, 0), (0, 1), (0, 2), (0, 3), (1, 0), (1, 1), (1, 2), (1, 3)]
tile 2: [(0, 0), (0, 1), (1, 0), (1, 1), (0, 2), (0, 3), (1, 2), (1, 3)]
  1. 1两层外循环遍历各个块,两层内循环遍历一个块的内部。

现在做真正的转置。我们写出读 A 和写 B 的轨迹(B 紧接着 A 存放),并对 73×73 的矩阵数出不分块和块大小为 4、8、16、32 时的缺失。

main.pyPython
def transpose_trace(n, tile=None):    base_b = n * n * 8    step = n if tile is None else tile    trace = []    for ii in range(0, n, step):        for jj in range(0, n, step):            for i in range(ii, min(ii + step, n)):                for j in range(jj, min(jj + step, n)):                    trace.append((i * n + j) * 8)                    trace.append(base_b + (j * n + i) * 8)    return tracefor tile in (None, 4, 8, 16, 32):    print("tile", tile, "misses:", count_misses(transpose_trace(73, tile)))
tile None misses: 5996
tile 4 misses: 2603
tile 8 misses: 2036
tile 16 misses: 1960
tile 32 misses: 1714

不分块时转置缺失 5,996 次。块大小为 4 时就降到 2,603,块大小在 8 到 32 之间时保持在 2,036 到 1,714,大约少了三倍。块太小会浪费它加载的缓存行的边缘部分,所以收益随块变大而增长,直到块装不下为止。

最佳块大小取决于缓存

为 4 KB 缓存调好的块大小,对 32 KB 的缓存就不对了。这里 8 到 32 的各个值都在最佳值的 20% 以内,所以选个中等大小的块比较稳妥。换了机器就重新测量,不要照抄一个数。

库会替你分块

NumPy 和 BLAS 对大数组做乘法和转置时早就是分块做的。当你在 Numba、C++ 或 Rust 里自己写循环,或者自己写的核比它所替换的库调用还慢时,就会碰上分块。

真实数据怎么说5,405 个 SPY 收盘价排成 73 × 73 矩阵(41 KB),缓存 4 KB
按行读667每 8 个值(一条行)缺失一次
按列读4,573多 6.9 倍
转置,不分块5,996两个矩阵的读与写
转置,块大小 321,714缺失少 3.5 倍
转置按块大小的缺失数
不分块5996
块 42603
块 82036
块 161960
块 321714
03

你要实现什么

实验沿着本讲走:两种读取顺序、可带可不带分块的转置轨迹、按顺序和按块大小的缺失计数、最佳块大小的选择,以及对 SPY 矩阵的研究。

  • 函数 · 问题依赖
  • row_major_trace(n)按行读会碰到哪些地址?简单算术
  • col_major_trace(n)按列读呢?简单算术
  • transpose_trace(n, tile=None)转置(分块或不分块)会碰到哪些地址?四层嵌套循环
  • compare_orders(n)按列的顺序差多少?row_major_tracecol_major_trace
  • misses_by_tile(n, tiles)分块如何改变转置的缺失数?transpose_trace
  • best_tile(n, tiles)哪个块大小最好?misses_by_tile
  • study(bars)对 SPY 收盘价矩阵,分块能赢多少?compare_ordersbest_tile

要点

  • 把顺着存储顺序走的循环放在内层。 对按行存放的矩阵,内层循环应该改变 j;矩阵一旦超过缓存,按列的顺序每次读都会缺失。
  • 循环互相矛盾时就分块。 转置没法同时照顾两个矩阵;块大小 8 到 32 能把缺失数降到约三分之一。
  • 在真实规模下测量。 能装进缓存的矩阵会把这一切藏起来,而最佳块大小也随缓存而变。
快速检查
关于按列而不是按行读取矩阵,下面哪个说法正确?
请选择一个答案。

下一个主题:分块靠重用缓存行来掩盖缺失的延迟,预取则靠在需要之前就先请求缓存行来掩盖。在预取里,你要给缓存加一个步长检测器,看它能预测哪些访问模式,又有哪些(比如在表里随机游走)它预测不了。

实验:评分内容

7 个任务 · 100 分
  • 按行读10 分
  • 按列读10 分
  • 转置,分块或不分块25 分
  • 比较两种顺序15 分
  • 按块大小的缺失15 分
  • 最佳块大小10 分
  • 基于 SPY 矩阵的研究15 分
资源管理器 · lab-cl-6
▾ lab-cl-6
▾ data
…
bash · 浏览器内
learner@quantcraft:~/lab-cl-6$
Python 尚未加载PythonLn 1, Col 1Spaces: 4UTF-8

要重置此主题吗?

lab-cl-6 中的所有内容将恢复为初始文件,终端也会被清空。你当前的解答将丢失,且无法撤销。

将被重置
  • main.py → 初始代码
  • 终端历史与测试输出
  • 未提交的更改
将被保留
  • 实验最高得分
  • 你的 XP 与连续天数
  • 讲解进度与笔记