σμ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 2 · Layout and traversal›Topic 2.1·~55 minIntermediate

Array of structs against struct of arrays

Skill positionModule 2 of 3 · Topic 1 of 2
2.1 Array of structs against struct of arrays
—Lab score— / 100Total XP—Streak—

5 min readCode examples: 4

  1. 01One table, two layouts
  2. 02A scan of one column
  3. 03Reading whole rows flips the result
  4. 04Let the counts choose
  5. 05What you will build

A table of ticks can sit in memory in two ways, and the right one depends on how you read it. Scanning one column of a row-by-row table loads five times the memory you use; reading whole rows from separate columns wastes just as much the other way round. This lecture counts the misses of both layouts and lets the counts choose.

01

One table, two layouts

Memory is a long row of bytes, so a two-dimensional table has to be flattened. There are two natural ways. In an array of structs the five values of a day (open, high, low, close, volume) are stored together, then the five values of the next day, and so on. In a struct of arrays all the opens come first, then all the highs, and so on. The values and the program are the same, but the addresses differ.

The address of a value follows from its row and column. In AoS the value number is row times the number of columns plus the column. In SoA it is column times the number of rows plus the row. Multiply by 8 bytes for a float64. The code below prints the addresses of the closes of the first four days in a table of 100 days and 5 columns.

main.pyPython
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. 1In AoS the next row is n_cols values further, so consecutive closes are 5 values (40 bytes) apart.

In AoS the closes of consecutive days are 40 bytes apart. In SoA they are 8 bytes apart, one right after the other, which is exactly the in-order read of the first topic of this skill.

02

A scan of one column

Averaging the closes reads one column of the table. We turn that read into a list of addresses, push them through the cache you built in the last topics, and count the misses for 1,000 days. The last number we print is the useful share.

main.pyPython
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
useful share = value bytes ÷ row bytes (while a row fits in one line)(1)in AoS a column scan loads whole rows: 8 ÷ 40 = 20%; in SoA every loaded byte is a close: about 100%

The SoA scan misses 125 times for 1,000 values, one miss per eight values and nothing wasted. The AoS scan misses 625 times, five times more, and only 20% of the loaded bytes are closes. The other 80% are opens, highs, lows and volumes that the average never looks at, and they push useful lines out of the cache.

03

Reading whole rows flips the result

Many tick programs ask a different question: for this tick, give me the price, the size and the side together. That reads every value of a row. In AoS the whole row is one 40-byte block, which sits in one or two lines. In SoA the five values are in five different arrays, so each row opens five lines. The code reads one row in every seven and counts the misses.

main.pyPython
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

Now AoS wins by a factor of three: 214 misses against 625. The layout that was five times worse for the column scan is three times better for the row read. Neither layout is best; each is best for one way of reading.

You already use both layouts

A NumPy two-dimensional array in the default order is an AoS layout: each row is contiguous. A pandas DataFrame stores each column of one type together, which is close to SoA, and this is one reason column operations on it are fast.

04

Let the counts choose

A real program does both kinds of read, so the choice depends on the mix. For each layout add up the misses of the workload: column scans times the column misses plus row reads times the row misses. The layout with the smaller total wins, and a tie goes to AoS. The code below tries four mixes on the same table.

main.pyPython
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
Choose from the access pattern, not from habit

With one column scan for every two row reads the winner is AoS; with one scan for every row read it is already SoA. The break-even sits near half a scan per row read, and your real workload decides which side you are on. Count the reads your program really does before you reorganise a table.

What the real data saysSPY daily bars, 5,405 rows × 5 columns, 32 KB 8-way cache
Scanning the closes: SoA67799.8% of loaded bytes used
Scanning the closes: AoS3,37820% of loaded bytes used
Reading 772 rows: AoS1,159one row in every seven
Reading 772 rows: SoA3,383five lines opened for each row
Misses of the closes scan and of the row reads, by layout
Column, SoA677
Column, AoS3378
Rows, AoS1159
Rows, SoA3383
05

What you will build

The lab follows the lecture: the two layouts, the two kinds of read, the comparison, the choice for a workload, the useful share and the study on the real SPY table.

  • Function · questionBuilds on
  • address(layout, row, col, n_rows, n_cols)Where does this value live?Plain arithmetic
  • scan_column(layout, col, n_rows, n_cols)Which addresses does a column scan read?address
  • scan_rows(layout, rows, n_rows, n_cols)Which addresses does a row read touch?address
  • compare(n_rows, n_cols, col, rows)How many misses does each read cost in each layout?scan_columnscan_rows
  • best_layout(...)Which layout suits this workload?compare
  • useful_fraction(layout, col, n_rows, n_cols)How much of what is loaded is used?scan_column
  • study(bars)Which layout suits the SPY table?comparebest_layout

Key takeaways

  • A layout is a set of strides. AoS makes a column scan a stride of the row width; SoA makes it a stride of one value.
  • Count the useful bytes. A column scan of a 5-column AoS table uses only 20% of what it loads, and the rest pushes useful lines out.
  • Choose by the workload. Multiply each kind of read by its misses in each layout and pick the smaller total; the break-even was about half a scan per row read.
Quick check
A table has 4 columns of 8-byte values stored as AoS. A scan of one column loads rows of 32 bytes, two to a 64-byte line. What share of the loaded bytes is used?
Pick an answer.

Next topic: the layout decided the stride of one table. In Traversal order and loop tiling the table is a matrix and you choose the order of the loops, then cut the loops into tiles so that a block of the matrix stays in the cache while it is used.

Lab · what's graded

7 tasks · 100 pts
  • The address of a value15 pts
  • A column scan10 pts
  • Reading whole rows10 pts
  • The misses of both reads20 pts
  • The layout for a workload15 pts
  • The useful share15 pts
  • The study on the SPY table15 pts
Explorer · lab-cl-5
▾ lab-cl-5
▾ data
…
bash · in-browser
learner@quantcraft:~/lab-cl-5$
Python not loaded yetPythonLn 1, Col 1Spaces: 4UTF-8

Reset this topic?

Everything in lab-cl-5 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