σμ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 学习路线›模块 3 · 整台机器›主题 3.3·~55分钟高级

伪共享与填充

路线进度模块 3/3 · 主题 3/7
3.3 伪共享与填充
—实验得分— / 100总 XP—连续天数—

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

  1. 01每个核心都有自己的缓存
  2. 02每个核心一个计数器
  3. 03要填充多少
  4. 04由真实日期驱动的计数器
  5. 05你要实现什么

四个线程各自更新自己的计数器,什么都没有共享,循环却比应有的速度慢了 40 倍。原因是四个计数器挤在同一条缓存行里,各个核心为它争抢不休。本讲搭一个缓存一致性的小模型,说明在一个由 SPY 表日期驱动的循环里伪共享要付出多少代价,并用几个字节的填充把它解决。

01

每个核心都有自己的缓存

到目前为止,一个程序只在一个核心上运行。真实的机器有多个核心,每个核心都有自己小而快的缓存。这就带来一个问题:如果核心 0 和核心 1 都持有某条缓存行的副本,而核心 0 修改了它,核心 1 就不能继续读旧值。防止这种情况的硬件规则叫缓存一致性。最简单的实现办法是:在一个核心写某条缓存行之前,把其他所有核心里这条行的副本都丢掉。这就是一次失效。

  1. 1
    先看自己的缓存行不在,这次访问就是缺失。
  2. 2
    写入时,把其他副本清掉其他每个持有这条行的核心都失去副本:每个算一次失效。
  3. 3
    留下这条行之后这条行就在本核心的缓存里,等待它的下一次访问。

下面的类为每个核心记录它持有的行的集合。我们重放三步:核心 0 写字节 0,核心 1 写字节 8(同一条行),然后核心 0 再写字节 0。

main.pyPython
class CoherentCaches:    def __init__(self, n_cores):        self.lines = [set() for _ in range(n_cores)]        self.misses = 0        self.invalidations = 0    def access(self, core, address, write):        line = address // LINE_BYTES        hit = line in self.lines[core]        if not hit:            self.misses += 1        if write:            for other, held in enumerate(self.lines):1                if other != core and line in held:                    held.discard(line)                    self.invalidations += 1        self.lines[core].add(line)        return hitcaches = CoherentCaches(2)print(caches.access(0, 0, True), caches.access(1, 8, True), caches.access(0, 0, True))print("misses:", caches.misses, "invalidations:", caches.invalidations)
False False False
misses: 3 invalidations: 2
  1. 1写入会把这条行从其他所有持有它的核心中移除,每个副本计一次失效。

三次访问都是缺失。核心 1 的写入使核心 0 的副本失效,核心 0 接着的那次写入又使核心 1 的副本失效。核心 0 和核心 1 从没写过同一个字节,写的只是相邻的字节,可它们每次写入都要把这条行从对方手里夺走。

定义伪共享

两个核心写的是不同的变量,但这些变量恰好在同一条缓存行里。程序是正确的,但这条行在核心之间来回弹跳,就像它们共享着一个变量。

02

每个核心一个计数器

并行统计事件的常见做法是给每个核心各自一个计数器,这样没有核心需要等别的核心。每个计数器 8 字节。把它们一个接一个放进数组,四个就挤进一条 64 字节的缓存行。我们构造 50 轮更新的写入序列,每轮每个核心写一次,并分别在计数器相隔 8 字节(紧凑)和 64 字节(带填充)时重放。时间按核心自己缓存里的访问 1 ns、从别的核心取行的缺失 60 ns 来算。

main.pyPython
def counter_address(core, stride_bytes):    return core * stride_bytesdef update_trace(n_cores, n_updates, stride_bytes):    return [(core, "w", counter_address(core, stride_bytes)) for _ in range(n_updates) for core in range(n_cores)]def run_trace(trace, n_cores):    caches = CoherentCaches(n_cores)    for core, op, address in trace:        caches.access(core, address, op == "w")    return {"accesses": len(trace), "misses": caches.misses, "invalidations": caches.invalidations}def time_ns(stats):    return (stats["accesses"] - stats["misses"]) * HIT_NS + stats["misses"] * MISS_NSfor name, stride in (("packed", 8), ("padded", 64)):    stats = run_trace(update_trace(4, 50, stride), 4)    print(name, stats, time_ns(stats), "ns")
packed {'accesses': 200, 'misses': 200, 'invalidations': 199} 12000.0 ns
padded {'accesses': 200, 'misses': 4, 'invalidations': 0} 436.0 ns

紧凑摆放时,200 次写入全部缺失,其中 199 次使上一个写入者的副本失效:12,000 ns。加了填充后,每个核心的计数器各占一条缓存行:开头 4 次冷缺失,之后每次写入都命中,没有失效:436 ns。算术完全一样,却快了 27 倍,因为四个核心不再互相抢这条行。

03

要填充多少

要让每个计数器独占一条缓存行,两个计数器之间的距离必须是行大小的倍数,所以元素大小要向上取整到下一个 64 的倍数。8 字节的计数器占 64 字节,浪费 56 字节;65 字节的元素占 128 字节。下面的函数完成这个取整。

main.pyPython
def padding_needed(item_bytes):    return math.ceil(item_bytes / LINE_BYTES) * LINE_BYTESprint([padding_needed(size) for size in (8, 63, 64, 65, 100)])
[64, 64, 64, 128, 128]
在真实代码里怎么写

在 C++ 里对保存线程计数器的结构体写 alignas(64)(或用 std::hardware_destructive_interference_size),Rust 里用 #[repr(align(64))],Java 里用 @Contended。在 Python 的 NumPy 里,把计数器数组做成 (n_cores, 8) 的形状并只用第 0 列,让每个计数器从新的 64 字节行开始。

只对核心会写的数据加填充

只读不会让任何东西失效,所以很多核心可以免费地读同一条行。填充会为每个 8 字节计数器浪费 56 字节,对只读数据或成千上万个元素来说很不划算。只把填充留给线程经常更新的、每线程一份的少数热点变量。

04

由真实日期驱动的计数器

实验里四个核心各自统计自己负责的交易日,SPY 表的第 i 天交给核心“星期几 % 4”。前 1,000 天按日期顺序驱动这些写入。因为日期按日历推进,同一个核心很少连续写两次,缓存行于是被一次又一次地抢走。计数器紧凑摆放时,1,000 次写入中有 818 次缺失、817 次使另一个核心的副本失效;加了填充后,只有最初 4 次写入缺失。

真实数据怎么说SPY 的 1,000 个交易日,4 个核心,命中 1 ns,缺失 60 ns
挤在一条行里的计数器49,262ns缺失 818 次,失效 817 次
填充到 64 字节的计数器1,236ns4 次冷缺失,没有失效
填充带来的加速39.9×同样的工作,每个独占一行
为此付出的内存56bytes每个 8 字节计数器浪费的量
1,000 次更新的时间(微秒)
紧凑49.3
带填充1.2
05

你要实现什么

实验搭建一致性模型、按给定步长生成每核计数器的轨迹、重放、填充规则、时间模型,以及对 SPY 日期的研究。

  • 函数 · 问题依赖
  • CoherentCaches(n_cores).access(core, address, write)命中还是缺失,谁会丢掉副本?简单算术
  • counter_address(core, stride_bytes)一个核心的计数器在哪里?简单算术
  • update_trace(n_cores, n_updates, stride_bytes)各个核心会做哪些写入?counter_address
  • run_trace(trace, n_cores)缺失与失效各多少次?CoherentCaches
  • padding_needed(item_bytes)元素之间要隔多远?简单算术
  • time_ns(stats)这次运行要多久?命中与缺失的代价
  • study(bars)在 SPY 日期上伪共享代价多大?run_tracepadding_neededtime_ns

要点

  • 写入会夺走这条行。 为了写入,核心会使其他所有核心里这条行的副本失效,它们下次使用时就会缺失。
  • 邻居命运相连。 同一条 64 字节行里的变量对核心来说是一个整体:伪共享会让并行代码比串行代码还慢。
  • 对每核的热点数据加填充。 把每个元素向上取整到 64 字节:这里每个计数器浪费 56 字节,换来 40 倍的速度;只读数据则不要填充。
快速检查
两个线程各自给自己的 8 字节计数器加一,这两个计数器在数组里相邻。会发生什么?
请选择一个答案。

下一个主题:伪共享说的是共用一个插槽的核心。在有两个插槽的机器上,内存本身也被分开了:一部分离某个核心近,另一部分离得远。在 NUMA 效应里,你要把页放到各个节点上,数出本地与远端访问,并估算两者的差价。

实验:评分内容

7 个任务 · 100 分
  • 一致性模型30 分
  • 计数器的地址5 分
  • 多轮更新15 分
  • 重放一次运行15 分
  • 让元素独占一行的填充10 分
  • 一次运行的耗时10 分
  • 基于 SPY 表的研究15 分
资源管理器 · lab-cl-9
▾ lab-cl-9
▾ data
…
bash · 浏览器内
learner@quantcraft:~/lab-cl-9$
Python 尚未加载PythonLn 1, Col 1Spaces: 4UTF-8

要重置此主题吗?

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

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