Cache Lab · Dancing With the Cache¶
Verified locally
./driver.py → 53 / 61: csim 27/27, transpose misses 288 / 1180 / 1993 for 32×32 / 64×64 / 61×67.
Two halves, one lesson. Part A asks you to model a cache; Part B asks you to outsmart one. Together they turn "cache" from an abstraction into something you can feel in a miss count.
The assignment
- Part A —
csim.c. Write a cache simulator that takes(-s, -E, -b)— set-index bits, lines per set, block-offset bits — replays avalgrindmemory trace, and reports hits, misses, evictions, with LRU replacement. - Part B —
trans.c. Transpose a matrixB = A^T, scored purely by the misses it causes on a 1 KB direct-mapped cache with 32-byte blocks (32 sets, one line each, 8ints per block). Lower is better.
Part A · Simulating the cache¶
Anatomy of an address¶
A cache never sees "a variable" — only an address, split into three fields. The block offset is irrelevant to a hit/miss decision, so the simulator computes just the set index and the tag:
63 b+s b 0
+---------------------------+---------+-----------+
| tag | set | offset |
+---------------------------+---------+-----------+
address >> (b+s) s bits b bits
uint32_t set_index = (address >> b) & ((0x1 << s) - 1); // (1)
uint64_t tag = address >> (b + s); // (2)
- Shift past the offset, then mask to
sbits — the set this address maps to. - Everything above the set field identifies which block currently lives in a line.
The cache in memory¶
Each line stores just enough to answer "is this block here, and how recently was it used?" The cache is an array of sets, each a lazily-allocated array of E lines:
typedef struct {
uint8_t valid;
uint64_t tag;
uint32_t LRU_counter; // larger = used longer ago
} cache_line;
cache_line **cache; // cache[set] is allocated on first touch
Hit, miss, evict¶
manipulate decodes the address and looks through the set. A matching valid line is a hit; otherwise it's a miss that must load the block — filling an empty line if one exists, or evicting the least-recently-used line if the set is full:
for (int i = 0; i < E; i++) {
if (cache[set_index][i].valid && cache[set_index][i].tag == tag) {
hits++;
cache[set_index][i].LRU_counter = 1; // (1)
update(set_index);
return;
}
}
insert(set_index, tag); // (2)
- On a hit, mark this line the youngest, then age the set (below).
- No match →
insertcounts the miss and finds a home; a full set falls through toevict, which scans for the largestLRU_counter— the line untouched the longest — and overwrites it.
LRU without a global scan¶
The neat trick is the replacement clock. Every access ages the current set by bumping the counter of each valid line, after resetting the just-used line to the minimum. So the most-recently-used line always carries the smallest counter and the LRU victim the largest:
void update(uint32_t set_index) {
cache_line *set = cache[set_index];
for (int j = 0; j < E; j++)
if (set[j].valid) set[j].LRU_counter++; // (1)
}
- Because all activity for one access is confined to a single set, only that set is aged — dropping the per-access cost from
O(S·E)toO(E). No global timestamp sweep is ever needed.
Driving it¶
load_trace parses each " %c %lx,%d" line. The one wrinkle is the M (modify) operation — a load immediately followed by a store — so it drives the cache twice; L and S once; I (instruction fetch) is ignored:
case 'M': // read-modify-write: hits/misses counted twice
manipulate(identifier, address, size);
manipulate(identifier, address, size);
break;
case 'L': case 'S':
manipulate(identifier, address, size);
break;
Part B · Transpose under a hostile cache¶
The cache that fights back¶
Transposing is trivial arithmetic; the whole difficulty is spatial. The grading cache has 32 sets, one line each, 8 ints per block. A 32×32 matrix row is 32 ints = 4 blocks, so 8 rows fill the entire cache — meaning rows i and i+8 collide in the same sets. For 64×64, a row is 8 blocks, so only 4 rows fill the cache and rows i and i+4 collide:
| Matrix | ints / row |
blocks / row | rows to fill cache | rows that conflict |
|---|---|---|---|---|
| 32×32 | 32 | 4 | 8 | i and i+8 |
| 64×64 | 64 | 8 | 4 | i and i+4 |
Worse, A and B are laid out so that A[k][k] and B[k][k] land in the same set — the diagonal elements fight each other. Everything below is about dodging these two collisions.
32×32 — block by 8, read before you write¶
Process the matrix in 8×8 tiles (one tile row = one cache block). The move that matters: read all 8 values of an A row into local variables first, then write them down a column of B. The eight registers break the read/write interleaving, so A's cache block is fully consumed before any B write can evict it:
for (i = 0; i < N; i += 8)
for (j = 0; j < M; j += 8)
for (k = i; k < i + 8; k++) {
v1 = A[k][j]; v2 = A[k][j+1]; v3 = A[k][j+2]; v4 = A[k][j+3];
v5 = A[k][j+4]; v6 = A[k][j+5]; v7 = A[k][j+6]; v8 = A[k][j+7]; // (1)
B[j][k] = v1; B[j+1][k] = v2; B[j+2][k] = v3; B[j+3][k] = v4;
B[j+4][k] = v5; B[j+5][k] = v6; B[j+6][k] = v7; B[j+7][k] = v8; // (2)
}
- One full read of an
Ablock into registers — a single miss brings in all eight. - Then eight column writes into
B. On diagonal tiles this ordering is what keeps theA/Bset-conflict down to a single unavoidable miss instead of one per element.
32×32, take two — defer the diagonal¶
trans.c holds a second, never-registered version of the same idea — transpose_32. It goes back to plain element-wise copying and keeps a single temporary; the only trick is that on diagonal tiles the diagonal element is held back until its row is finished:
for (i1 = i; i1 < i + 8; i1++) {
for (j1 = j; j1 < j + 8; j1++) {
if (j == i && j1 == i1)
tmp = A[j1][j1]; // (1)
else
B[j1][i1] = A[i1][j1];
}
if (i == j)
B[i1][i1] = tmp; // (2)
}
B[k][k]maps to the same set as theArow being read — written mid-row, it would evict that row. Park the value intmpinstead.- Once the row is fully consumed, pay the conflict exactly once.
"Read all eight into registers" and "don't touch the conflicting line until the row is done" are two answers to the same eviction, and the cache can't tell them apart: run through csim-ref, this version scores 288 misses — hits, misses, and evictions all identical to the submission. Same score, an eighth of the temporaries; it just was never wired into registerFunctions, so the driver never saw it.
64×64 — the four-quadrant shuffle¶
Here 8×8 blocking self-destructs: within a tile, rows k and k+4 conflict. The fix is to treat each 8×8 tile as four 4×4 quadrants and use B's own upper-right quadrant as a scratch buffer, so data is staged in cache-friendly 4×4 pieces. The numbered labels below always refer to quadrants of A; transposition changes where each one belongs in B:
A source B after pass 1
+--------+--------+ +--------+--------+
| (1) | (2) | | (1)^T | (2)^T* |
+--------+--------+ +--------+--------+
| (3) | (4) | | ? | ? |
+--------+--------+ +--------+--------+
B after pass 2 B final (after pass 3)
+--------+--------+ +--------+--------+
| (1)^T | (3)^T | | (1)^T | (3)^T |
+--------+--------+ +--------+--------+
| (2)^T | ? | | (2)^T | (4)^T |
+--------+--------+ +--------+--------+
* (2)^T is parked in B's upper-right; its final home is lower-left.
The transpose runs in three passes:
-
Top half of
A, with (2) parked. ReadA's rowsi..i+3(quadrants (1)(2)). Write(1)^TintoB's upper-left, and park(2)^TinB's upper-right — a temporary home, since its final position is lower-left. -
Put both off-diagonal quadrants in their final homes. For each column, read
A's quadrant (3) and the parked(2)^Tvalues. OverwriteB's upper-right with(3)^T, then move the parked(2)^Tdown intoB's lower-left. One column at a time keeps every touched line resident: -
Finish the bottom-right. A straight
4×4transpose writes(4)^Tinto the only empty quadrant.
The payoff: what would be a storm of conflict misses becomes a handful, because no pass ever keeps two conflicting rows live at once.
The general case¶
For any other M×N, correctness matters more than the last few misses, so a plain 16×16 blocked transpose with bounds guards covers it: