σμ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 AcademyRoadmapsSkills
Ctrl K
AO
+1.2k Learners
LoginJoin ↗
RoadmapsSkills
AO
+1.2k Learners
Login
  • 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 AcademyRoadmapsSkills
Ctrl K
AO
+1.2k Learners
LoginJoin ↗
RoadmapsSkills
AO
+1.2k Learners
Login
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
Cache locality skill›Module 3 · The wider machine›Topic 3.5·~1 hrAdvanced

Allocators and memory pools

Skill positionModule 3 of 3 · Topic 5 of 7
3.5 Allocators and memory pools
—Lab score— / 100Total XP—Streak—

5 min readCode examples: 3

  1. 01Where do addresses come from?
  2. 02Temporary objects scatter your nodes
  3. 03What you will build

Every list node, tree node or order that a program creates comes from an allocator, and the allocator decides which addresses they get. A general-purpose malloc spreads small objects out with headers and with the program's own temporary objects; a pool packs them. This lecture models both and walks the same list with 3.3 times fewer cache misses from a pool.

01

Where do addresses come from?

When a program asks for memory for an object, an allocator gives it a block and returns the address. A general-purpose allocator has to remember how big each block is, so it keeps a small header in front of it, and it rounds blocks up to a multiple of 16 bytes so that any kind of value can be stored in them. The simplest model of this never reuses memory: each request takes a block right after the previous one, and the program gets the address just after the header.

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. 1The block is the request plus the header, rounded up to a multiple of 16: 24 bytes need a 48-byte block, 100 bytes need 128.

A node of 24 bytes (a close, a volume and a next pointer, 8 bytes each) gets a block of 48 bytes, twice what it needs, and a temporary object of 100 bytes gets 128. The three addresses are 16, 64 and 192: the second request starts after the 48-byte block of the first one, the third after the 128-byte block of the second.

02

Temporary objects scatter your nodes

Real code does not build a list in a vacuum. Between two nodes it formats a message, builds a key or parses a line, and those temporary objects are allocated too. With a simple allocator that never reuses memory they sit between the nodes. The first function below builds the plan of requests, a node followed by three temporaries of 100 bytes, and the second serves the plan and keeps the addresses of the nodes only. For comparison we also write a pool, which gives out fixed slots one right after the other.

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]

With three temporaries per node the nodes are 432 bytes apart: 48 for the node and 3 × 128 for the garbage. Without temporaries they are 48 bytes apart. In a pool they are 24 bytes apart, exactly the size of a node, with no header and no garbage. A 64-byte line holds one scattered node, 1.3 nodes with headers only, and 2.7 nodes in the pool. We now walk 3,000 nodes, reading the first and the last byte of each, through the cache of the earlier topics.

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

The scattered list takes 3,750 misses and spreads over 1,265 KB. The pool, which holds the very same 3,000 nodes, takes 1,125 misses over 70 KB: 3.3 times fewer, and the nodes would even fit in an L2 cache. The program, the data and the order of the walk are the same; only the addresses changed.

DefinitionPool and arena

A pool hands out equal-sized slots from one contiguous chunk and takes them back on a free list. An arena only moves a pointer forward, gives out any size with no header, and frees everything at once. Both keep objects that are used together next to each other.

The same story in Python

A Python list of 3,000 floats holds pointers to 3,000 separate float objects scattered by the allocator, while a NumPy array stores the 3,000 values next to each other. Walking the array touches a few hundred lines; walking the list touches thousands. This is one more reason why NumPy is fast.

Pools trade flexibility for locality

A pool needs objects of one size and a good guess of how many there will be, and an arena cannot free a single object early. Use them for the hot structures of a program, such as an order book or a list of ticks, and leave the rest to the general-purpose allocator.

What the real data saysa list of 3,000 SPY days, 24-byte nodes, 32 KB 8-way cache
Scattered by temporaries3,750misses, spread over 1,265 KB
malloc headers only2,250misses, spread over 140 KB
Pool1,125misses, packed in 70 KB
Gain of the pool3.3×fewer misses for the same walk
Misses of walking the list, by allocator
Scattered3750
Headers only2250
Pool1125
03

What you will build

The lab follows the lecture: the rounding, the simple general-purpose allocator, the pool, the plan of requests with temporaries, the addresses of the nodes, the count of misses of a walk and the study on a list of SPY days.

  • Function · questionBuilds on
  • round_up(n, align)What is n rounded up to a multiple?Plain arithmetic
  • malloc_addresses(sizes)Where does a general-purpose allocator put each block?round_up
  • pool_addresses(n, slot_bytes)And a pool?Plain arithmetic
  • alloc_plan(...)Which requests does the program make?Plain arithmetic
  • node_addresses(...)Where do the nodes end up?alloc_planmalloc_addresses
  • traversal_misses(addresses, node_bytes)How many misses does a walk cost?Cache
  • study(bars)How much does a pool save on the SPY list?node_addressespool_addressestraversal_misses

Key takeaways

  • The allocator chooses addresses. Headers and rounding double a 24-byte node to 48 bytes, and temporaries allocated in between spread the nodes 432 bytes apart.
  • Spread nodes cost misses. Walking the same 3,000 nodes missed 3,750 times when scattered and 1,125 times from a pool.
  • Pack the hot objects. Pools and arenas keep objects used together next to each other; use them for the few hot structures and leave the rest to malloc.
Quick check
Nodes of 24 bytes sit one after another in a pool. At least how many 64-byte lines does reading 3,000 of them touch?
Pick an answer.

Next topic: so far every cost came from memory. The last hardware effect of this skill comes from the processor's guesses. In Branch prediction you model a predictor, count its mistakes on a loop with an if inside and see why the same loop is faster on sorted data.

Lab · what's graded

7 tasks · 100 pts
  • Rounding up5 pts
  • A simple general-purpose allocator20 pts
  • A pool10 pts
  • The requests of a program15 pts
  • The addresses of the nodes10 pts
  • The cost of a walk15 pts
  • The study on the SPY table25 pts
Explorer · lab-cl-11
▾ lab-cl-11
▾ data
…
bash · in-browser
learner@quantcraft:~/lab-cl-11$
Python not loaded yetPythonLn 1, Col 1Spaces: 4UTF-8

Reset this topic?

Everything in lab-cl-11 goes back to the starter files and the terminal is cleared. Your current solution will be lost — this can't be undone.

Will be reset
  • main.py → starter code
  • Terminal history and test output
  • Unsubmitted changes
Will be kept
  • Best lab score
  • Your XP and streak
  • Lecture progress and notes