σμ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.5·~1小时高级

分配器与内存池

路线进度模块 3/3 · 主题 5/7
3.5 分配器与内存池
—实验得分— / 100总 XP—连续天数—

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

  1. 01地址从哪里来?
  2. 02临时对象会把你的节点摊开
  3. 03你要实现什么

程序创建的每个链表节点、树节点或订单,都来自某个分配器,而分配器决定它们得到哪些地址。通用的 malloc 靠头部信息,再加上程序自己产生的临时对象,把小对象摊得很开;内存池则把它们紧紧排在一起。本讲为两者建模,并说明同一个链表从内存池遍历,缓存缺失少 3.3 倍。

01

地址从哪里来?

程序为一个对象申请内存时,分配器给它一块内存并返回地址。通用分配器必须记住每块有多大,所以在每块前面放一小段头部,并把块大小向上取整到 16 字节的倍数,这样什么类型的值都能存进去。最简单的模型从不重用内存:每次请求就紧接在上一块之后取一块,程序得到的是头部之后的那个地址。

main.pyPython
def round_up(n, align):    return -(-n // align) * aligndef malloc_addresses(sizes, header=HEADER_BYTES, align=ALIGN):    addresses = []    start = 0    for size in sizes:        addresses.append(start + header)1        start += round_up(size + header, align)    return addressesprint(malloc_addresses([24, 100, 24]))print([round_up(size + 16, 16) for size in (24, 100)])
[16, 64, 192]
[48, 128]
  1. 1块大小是请求加头部,再向上取整到 16 的倍数:24 字节需要 48 字节的块,100 字节需要 128 字节。

一个 24 字节的节点(一个收盘价、一个成交量和一个 next 指针,各 8 字节)拿到 48 字节的块,是需要的两倍;一个 100 字节的临时对象拿到 128 字节。三个地址是 16、64 和 192:第二次请求从第一块(48 字节)之后开始,第三次从第二块(128 字节)之后开始。

02

临时对象会把你的节点摊开

真实的代码不会凭空构造链表。两个节点之间,它要格式化一条消息、构造一个键或解析一行文本,这些临时对象也要分配内存。用一个从不重用内存的简单分配器,它们就夹在节点之间。下面第一个函数构造请求计划:一个节点后面跟三个 100 字节的临时对象;第二个函数执行这个计划,只保留节点的地址。作为对比,我们还写了一个内存池,它把固定大小的槽位一个紧接一个地发出去。

main.pyPython
def alloc_plan(n_nodes, garbage_per_node, garbage_bytes, node_bytes):    sizes = []    node_index = []    for _ in range(n_nodes):        node_index.append(len(sizes))        sizes.append(node_bytes)        sizes.extend([garbage_bytes] * garbage_per_node)    return sizes, node_indexdef node_addresses(n_nodes, garbage_per_node, garbage_bytes, node_bytes):    sizes, node_index = alloc_plan(n_nodes, garbage_per_node, garbage_bytes, node_bytes)    addresses = malloc_addresses(sizes)    return [addresses[i] for i in node_index]def pool_addresses(n, slot_bytes, base=0):    return [base + i * slot_bytes for i in range(n)]print(node_addresses(3, 3, 100, 24))print(node_addresses(3, 0, 100, 24))print(pool_addresses(3, 24))
[16, 448, 880]
[16, 64, 112]
[0, 24, 48]

每个节点带三个临时对象时,节点之间相隔 432 字节:节点本身 48,加上 3 × 128 的垃圾。没有临时对象时相隔 48 字节。在内存池里相隔 24 字节,正好是一个节点的大小,没有头部也没有垃圾。一条 64 字节的缓存行,在分散的情形里装 1 个节点,只有头部时装 1.3 个,在内存池里装 2.7 个。现在我们遍历 3,000 个节点,读每个节点的第一个和最后一个字节,经过前面几个主题里的缓存。

main.pyPython
def traversal_misses(addresses, node_bytes):    cache = Cache()    for address in addresses:        cache.access(address)        cache.access(address + node_bytes - 1)    return cache.missesn = 3000for name, addresses in (("scattered", node_addresses(n, 3, 100, 24)), ("headers only", node_addresses(n, 0, 100, 24)), ("pool", pool_addresses(n, 24))):    print(name, traversal_misses(addresses, 24), "misses, last node at", addresses[-1] // 1024, "KB")
scattered 3750 misses, last node at 1265 KB
headers only 2250 misses, last node at 140 KB
pool 1125 misses, last node at 70 KB

分散的链表缺失 3,750 次,铺开 1,265 KB。装着完全相同的 3,000 个节点的内存池,只缺失 1,125 次,只占 70 KB:少了 3.3 倍,节点甚至能放进二级缓存。程序、数据和遍历顺序都没变,变的只是地址。

定义内存池与内存区

内存池从一整块连续内存里发出大小相同的槽位,并通过空闲链表收回它们。内存区(arena)只把一个指针往前移,不带头部,任何大小都能发,并一次性全部释放。两者都能让一起使用的对象挨在一起。

Python 里也是同样的故事

一个含 3,000 个浮点数的 Python 列表,存的是指向 3,000 个独立浮点对象的指针,这些对象被分配器散落各处;而 NumPy 数组把这 3,000 个值挨着存放。遍历数组只碰几百条缓存行,遍历列表要碰几千条。这是 NumPy 快的又一个原因。

内存池用灵活性换局部性

内存池要求对象大小一致,并要能大致估计数量;内存区则没法提前只释放某一个对象。把它们用在程序里的热点结构上,比如订单簿或逐笔列表,其余交给通用分配器。

真实数据怎么说3,000 个 SPY 交易日的链表,24 字节节点,32 KB 8 路缓存
被临时对象摊开3,750次缺失,铺开 1,265 KB
只有 malloc 头部2,250次缺失,铺开 140 KB
内存池1,125次缺失,紧凑地放在 70 KB 里
内存池的收益3.3×同样的遍历,缺失少这么多倍
按分配器的遍历缺失数
分散3750
仅头部2250
内存池1125
03

你要实现什么

实验沿着本讲走:取整、简单的通用分配器、内存池、带临时对象的请求计划、节点地址、一次遍历的缺失计数,以及对 SPY 交易日链表的研究。

  • 函数 · 问题依赖
  • round_up(n, align)n 向上取整到倍数是多少?简单算术
  • malloc_addresses(sizes)通用分配器把每个块放在哪里?round_up
  • pool_addresses(n, slot_bytes)内存池呢?简单算术
  • alloc_plan(...)程序会发出哪些请求?简单算术
  • node_addresses(...)节点最终落在哪里?alloc_planmalloc_addresses
  • traversal_misses(addresses, node_bytes)一次遍历要缺失多少次?Cache
  • study(bars)内存池在 SPY 链表上能省多少?node_addressespool_addressestraversal_misses

要点

  • 分配器决定地址。 头部和取整让 24 字节的节点变成 48 字节,中间分配的临时对象又把节点隔开 432 字节。
  • 分散的节点会带来缺失。 遍历同样的 3,000 个节点,分散时缺失 3,750 次,用内存池只缺失 1,125 次。
  • 把热点对象紧凑存放。 内存池和内存区让一起使用的对象挨在一起;把它们用在少数热点结构上,其余交给 malloc。
快速检查
24 字节的节点在内存池里一个接一个排列。读 3,000 个节点,至少要碰到多少条 64 字节的缓存行?
请选择一个答案。

下一个主题:到目前为止每一项代价都来自内存。本技能的最后一个硬件效应来自处理器的猜测。在分支预测里,你要为预测器建模,在带 if 的循环里数出它猜错的次数,并看清为什么同一个循环在排好序的数据上更快。

实验:评分内容

7 个任务 · 100 分
  • 向上取整5 分
  • 简单的通用分配器20 分
  • 内存池10 分
  • 程序的请求15 分
  • 节点的地址10 分
  • 一次遍历的代价15 分
  • 基于 SPY 表的研究25 分
资源管理器 · lab-cl-11
▾ lab-cl-11
▾ data
…
bash · 浏览器内
learner@quantcraft:~/lab-cl-11$
Python 尚未加载PythonLn 1, Col 1Spaces: 4UTF-8

要重置此主题吗?

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

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