Why Cache-Aware Blocking / Tiling Matters
Two programs can compute exactly the same matrix product, perform the same number of multiplications, and move different amounts of data through memory. The difference is often locality: whether a value is still nearby when it is needed again. A cache fetches a whole line of neighboring words, so using a small region repeatedly can be much cheaper than returning to it after its lines have been evicted.
Cache-aware blocking, also called tiling, divides a loop iteration space into small pieces chosen with the memory hierarchy in mind. Finish useful work on one piece while its data is resident, then move on. The transformation changes traversal and reuse; it does not automatically change the mathematical task or reduce its arithmetic complexity.
This lesson uses dense matrix multiplication as a concrete example. The Python, C++, and Java programs compare an untiled row-major traversal with a tiled traversal through the same fully associative LRU cache simulator. Both calculate every product term, preserve partial sums, handle rectangular edge tiles, and count line fills plus dirty writebacks.
You will trace every tile of a small product, relate tile dimensions to cache capacity, and distinguish word references from block transfers. The same reasoning applies to transpose, image blocks, stencil neighborhoods, and suitable dynamic-programming loops, provided their dependencies permit the reordering. Page-oriented locality from the earlier external-memory lessons becomes cache-line locality here.
Tiling groups computation so a small working set is reused before eviction. Choose a block size that fits the relevant cache footprint, preserve all dependencies and partial sums, and count data transfers rather than assuming fewer arithmetic operations. A useful tile can reduce line traffic even when it adds loop overhead or scalar references.
Core Concepts and Terminology
Let $A$ have shape $r\times k$, $B$ shape $k\times c$, and $C$ shape $r\times c$. The task is $$ C_{ij}=\sum_{x=0}^{k-1}A_{ix}B_{xj}, \qquad 0\le i\lt r,\quad 0\le j\lt c. $$ Matrices are flat row-major arrays: $A_{ix}$ is at offset $ik+x$, $B_{xj}$ at offset $xc+j$, and $C_{ij}$ at offset $ic+j$. The innermost $j$ loop walks contiguous row segments of $B$ and $C$. The scalar $A_{ix}$ is loaded once and held across that segment.
A tile is a bounded group of iterations, not a unit of storage transfer. Tile width $T$ creates row starts ii, column starts jj, and inner-dimension starts kk. One full cubic tile product updates a $T\times T$ output block using two $T\times T$ input blocks. With scalar word width $w=8$ logical bytes, its useful data is about $3T^2w$ bytes.
The cache model has $\lambda$ words per line and $Q$ line frames, so its data capacity is $M=Q\lambda$ words or $8Q\lambda$ bytes. Address $a$ maps to line $\lfloor a/\lambda\rfloor$ and slot $a\bmod\lambda$. The model is fully associative with LRU replacement: any line can occupy any frame, and the least recently referenced resident line is evicted when space is needed.
Write policy matters. A missing load or store fetches one full line: write allocate applies to stores. A store updates the cached line and marks it dirty. A clean eviction has no writeback; a dirty eviction writes a full line to backing memory. A final flush writes all remaining dirty lines, then marks them clean. Thus $$ \text{transfers}=\text{line reads}+\text{dirty eviction writes}+\text{final flush writes}. $$ Word references and cache hits are different counts. Every load/store is one reference, but only a miss causes a line read. The kernel reads $C_{ij}$ immediately before storing its update, so its stores hit; the general simulator still implements store-miss allocation for other access patterns.
Address layout and measurement boundary. $A$ begins at word address zero. $B$ starts at the next line boundary after $A$, and $C$ at the next line boundary after $B$. Padding is zero and is never treated as a matrix entry. Input loading, initial zero-filled $C$, and final inspection of flushed backing memory are outside the cache-event count. Both compared runs start cold with identical input images and include their own final dirty flush.
The simple sizing rule $3T^2\lesssim M$ leaves room for the useful entries, but actual line footprint depends on alignment and row stride. A two-word row segment can span two lines if misaligned. Leave headroom and inspect the distinct lines touched; fitting a count of words alone is not a universal performance guarantee. The worked $T=2$, $\lambda=2$, $Q=7$ case has fourteen cache words and twelve useful tile words.
| Term | Exact meaning in this lesson | Example or role |
|---|---|---|
| Word | One logical eight-byte integer | Input values and output accumulators |
| Cache line | $\lambda$ adjacent words transferred together | $\lambda=2$ gives $16$ bytes/line |
| Cache capacity | $Q\lambda$ data words | $Q=7$ gives $14$ words or $112$ bytes |
| Tile | Bounded iteration subspace | $2\times2\times2$ product updates eight terms |
| Spatial locality | Nearby words used close together | Inner $j$ loop walks a row segment |
| Temporal locality | A resident value reused soon | Reuse a B tile across nearby output rows |
| Row-major stride | Distance between corresponding row positions | Four-column matrix: stride four words |
| LRU | Evict the least recently referenced line | Clean evictions need no writeback |
| Dirty line | Cached data newer than backing memory | Updated C line must eventually be written |
| Edge tile | Smaller final block in any dimension | Five rows with $T=2$: final row range $[4,5)$ |
| Transfer | One full line read or write | Final flush writes count too |
| Register scalar | Temporary outside the cache data model | $A_{ix}$ held across one inner row segment |
How Cache-Aware Blocking / Tiling Works, Step by Step
1. Fix layout and a baseline. Both programs compute $C=AB$ from a zero output using an inner i,k,j kernel. This already makes $j$ contiguous in row-major $B$ and $C$. The comparison isolates tiling of that kernel; it does not compare against a deliberately strided i,j,k baseline or a tuned BLAS implementation.
2. Choose a tile size. Use $T$ as a candidate, estimate the three-block working set, and account for line fragments and other live data. Cache-aware code chooses/tunes $T$ using a target cache or measured behavior. The simulator accepts a tile even if it exceeds cache capacity: that remains mathematically valid and is useful for demonstrating poor choices.
3. Partition all three dimensions. Loop ii over output row blocks, jj over output column blocks, then kk over the reduction/inner blocks. Ends are clamped independently with min(start + T, dimension). Nonmultiple and rectangular shapes require this in every dimension.
4. Reuse within a tile. For each $i$ in the row block and each inner index $x$ in the current reduction block, load $A_{ix}$ once. For each $j$ in the column block, load $B_{xj}$, load the current $C_{ij}$, and store $C_{ij}+A_{ix}B_{xj}$. Cached words remain available until replacement evicts their lines. There is no explicit copy into packed tile arrays in these programs.
5. Preserve the output between inner tiles. $C$ is zeroed once during setup. The next kk block adds another partial dot product to the same output region. Resetting $C$ inside the kk loop would discard earlier terms. The next figure follows all row-pair updates of the first tile product.
6. Move to the next output tile. Only after processing all reduction blocks for a given ii,jj pair does the outer traversal select another output tile. The loops still compute every $(i,x,j)$ product exactly once.
7. Materialize dirty results fairly. After the kernel, flush every remaining dirty cache line and include those writes in the transfer count. Compare complete output arrays only after this flush. Output inspection is an uninstrumented observation of backing memory, not a counted second cache pass. Empty dimensions perform no kernel accesses; their output zeros already exist in the initialized image.
flowchart TD
A["Validate shapes and cache parameters"] --> B["Align A, B, C regions; zero C; start cold cache"]
B --> C{"All dimensions positive?"}
C -->|No| P["Flush remaining dirty lines and return result"]
C -->|Yes| D["Choose next ii,jj output tile"]
D --> E["Choose next kk reduction tile and clamp all ends"]
E --> F["Choose i,x; load A scalar"]
F --> G["Choose j; load B and C; store accumulated C"]
G --> H{"More j in tile?"}
H -->|Yes| G
H -->|No| I{"More i,x in tile?"}
I -->|Yes| F
I -->|No| J{"More kk blocks?"}
J -->|Yes| E
J -->|No| K{"More ii,jj blocks?"}
K -->|Yes| D
K -->|No| P
G -.-> L["Each reference updates LRU; a miss reads one line"]
L -.-> M["A dirty eviction writes one line"]
Worked Example with a Full Trace
Set $r=k=c=4$, $T=2$, $\lambda=2$ words per line, and $Q=7$ lines. Each line is $16$ bytes; cache capacity is $112$ bytes. Use $$ A=\begin{bmatrix} 1&2&3&4\\5&6&7&8\\9&10&11&12\\13&14&15&16 \end{bmatrix}, \qquad B=\begin{bmatrix} 1&2&3&4\\0&1&0&1\\2&0&1&0\\1&1&1&1 \end{bmatrix}. $$ $A$ occupies word addresses $0$ through $15$ and lines $0$ through $7$. $B$ occupies addresses $16$ through $31$, lines $8$ through $15$. Zero-initialized $C$ occupies addresses $32$ through $47$, lines $16$ through $23$. Each row segment of two aligned words is exactly one line.
First tile's complete cache-reference trace. For ii=jj=kk=0, a reference sequence uses L for load and S for store, followed by the global line ID. M means miss/read, H means hit. The four row-pair updates each make seven references:
- $i=0,x=0$:
L0 M, L8 M, L16 M, S16 H, L8 H, L16 H, S16 H. Output row pair becomes $[1,2]$. - $i=0,x=1$:
L0 H, L10 M, L16 H, S16 H, L10 H, L16 H, S16 H. Pair becomes $[1,4]$. - $i=1,x=0$:
L2 M, L8 H, L18 M, S18 H, L8 H, L18 H, S18 H. Pair becomes $[5,10]$. - $i=1,x=1$:
L2 H, L10 H, L18 H, S18 H, L10 H, L18 H, S18 H. Pair becomes $[5,16]$.
This tile has twenty-eight references, six line reads, twenty-two hits, and zero writes so far. Lines $16$ and $18$ are dirty; the seven-line cache has only six residents at this point. LRU order is $[0,16,8,2,10,18]$, oldest first.
All eight tile products. The following list records every row-pair arithmetic update, the complete affected C tile afterward, and all cumulative cache state. Each row-pair result gives both column values after its two consecutive $j$ updates. Together they account for all sixty-four multiply-adds. Unmentioned output tiles remain unchanged. Dirty lists contain global line IDs; counters are before the final flush.
- Tile start
(0,0,0).i=0, x=0→ $[1,2]$;i=0, x=1→ $[1,4]$;i=1, x=0→ $[5,10]$;i=1, x=1→ $[5,16]$. C tile = $\begin{bmatrix}1&4\\5&16\end{bmatrix}$. Cumulative references $28$, hits $22$, line reads $6$, eviction writes $0$. LRU =[0, 16, 8, 2, 10, 18]; dirty =[16, 18].
- Tile start
(0,0,2).i=0, x=2→ $[7,4]$;i=0, x=3→ $[11,8]$;i=1, x=2→ $[19,16]$;i=1, x=3→ $[27,24]$. C tile = $\begin{bmatrix}11&8\\27&24\end{bmatrix}$. Cumulative references $56$, hits $46$, line reads $10$, eviction writes $0$. LRU =[10, 1, 16, 12, 3, 14, 18]; dirty =[16, 18].
- Tile start
(0,2,0).i=0, x=0→ $[3,4]$;i=0, x=1→ $[3,6]$;i=1, x=0→ $[15,20]$;i=1, x=1→ $[15,26]$. C tile = $\begin{bmatrix}3&6\\15&26\end{bmatrix}$. Cumulative references $84$, hits $68$, line reads $16$, eviction writes $1$. LRU =[18, 0, 17, 9, 2, 11, 19]; dirty =[18, 17, 19].
- Tile start
(0,2,2).i=0, x=2→ $[6,6]$;i=0, x=3→ $[10,10]$;i=1, x=2→ $[22,26]$;i=1, x=3→ $[30,34]$. C tile = $\begin{bmatrix}10&10\\30&34\end{bmatrix}$. Cumulative references $112$, hits $92$, line reads $20$, eviction writes $2$. LRU =[11, 1, 17, 13, 3, 15, 19]; dirty =[17, 19].
- Tile start
(2,0,0).i=2, x=0→ $[9,18]$;i=2, x=1→ $[9,28]$;i=3, x=0→ $[13,26]$;i=3, x=1→ $[13,40]$. C tile = $\begin{bmatrix}9&28\\13&40\end{bmatrix}$. Cumulative references $140$, hits $114$, line reads $26$, eviction writes $3$. LRU =[19, 4, 20, 8, 6, 10, 22]; dirty =[19, 20, 22].
- Tile start
(2,0,2).i=2, x=2→ $[31,28]$;i=2, x=3→ $[43,40]$;i=3, x=2→ $[43,40]$;i=3, x=3→ $[59,56]$. C tile = $\begin{bmatrix}43&40\\59&56\end{bmatrix}$. Cumulative references $168$, hits $138$, line reads $30$, eviction writes $4$. LRU =[10, 5, 20, 12, 7, 14, 22]; dirty =[20, 22].
- Tile start
(2,2,0).i=2, x=0→ $[27,36]$;i=2, x=1→ $[27,46]$;i=3, x=0→ $[39,52]$;i=3, x=1→ $[39,66]$. C tile = $\begin{bmatrix}27&46\\39&66\end{bmatrix}$. Cumulative references $196$, hits $160$, line reads $36$, eviction writes $5$. LRU =[22, 4, 21, 9, 6, 11, 23]; dirty =[22, 21, 23].
- Tile start
(2,2,2).i=2, x=2→ $[38,46]$;i=2, x=3→ $[50,58]$;i=3, x=2→ $[54,66]$;i=3, x=3→ $[70,82]$. C tile = $\begin{bmatrix}50&58\\70&82\end{bmatrix}$. Cumulative references $224$, hits $184$, line reads $40$, eviction writes $6$. LRU =[11, 5, 21, 13, 7, 15, 23]; dirty =[21, 23].
The final matrix is $$ C=\begin{bmatrix} 11&8&10&10\\27&24&30&34\\43&40&50&58\\59&56&70&82 \end{bmatrix}. $$ Before flush, tiled execution has forty line reads and six dirty eviction writes. Resident dirty lines $21$ and $23$ contain the last output-column segments; flushing them adds two writes. Total tiled transfers are $40+6+2=48$ lines, or $768$ bytes. Its $224$ references equal $184$ hits plus $40$ misses.
The untiled i,k,j traversal produces the identical matrix with $208$ references, $160$ hits, $48$ line reads, six eviction writes, and two flush writes: $56$ transfers, or $896$ bytes. Tiling therefore saves eight line transfers here while making sixteen additional A references. It has not reduced the sixty-four multiplications or the sixty-four additions, and the simulator does not claim an elapsed-time speedup.
Trace It by Hand
Pause after the first tile leaves $C[0:2,0:2]=\begin{bmatrix}1&4\\5&16\end{bmatrix}$. The next reduction tile contributes $\begin{bmatrix}10&4\\22&8\end{bmatrix}$. Predict the updated output block.
Reveal: add rather than reset, giving $\begin{bmatrix}11&8\\27&24\end{bmatrix}$. Zeroing the block again would lose the first two inner-dimension terms.
Now reduce cache capacity to $Q=6$, keeping the same line and tile sizes. Untiled execution uses $48$ reads and $8$ writes; tiled uses $44$ reads and $12$ writes. Both total $56$ transfers. Looking only at read misses would overstate the benefit. With $Q=5$, untiled totals $56$ while tiled totals $64$: a tile can be correct yet unhelpful.
Finally set $T=4$. There is one tile covering this whole example, so its kernel order becomes exactly the untiled order and every cache counter matches. For a rectangular $5\times3$ by $3\times7$ multiplication with $T=2$, the final row, inner, and column tiles all have length one; clamp all three ends.
Implementation in Python, C++, and Java
Save Python as main.py and run python3 main.py. Save C++ as main.cpp and compile with g++ -std=c++17 -O2 main.cpp -o tiled-multiply. Save Java as Main.java and run javac Main.java followed by java Main. Python 3.7 or newer supplies dataclasses. C++ uses long long and Java uses long for integer words; all examples use exact integer arithmetic within the declared bounds.
The API is multiply(a, b, rows, inner, cols, tile, line_words, frames, blocked), with corresponding camel-case parameter names in Java. Pass flat row-major Python lists, C++ vector<Word>, or Java long[]. Null/nonsequence inputs are outside the contract. blocked=false runs the untiled i,k,j kernel; true uses ii,jj,kk,i,k,j traversal. Python defaults to blocked mode; C++ also supplies that default, while Java calls pass the boolean explicitly.
Dimensions must be integers from zero through $64$. Tile size and line width are each $1$ through $64$, and cache line capacity is $1$ through $128$. Array lengths must equal rows*inner and inner*cols, and entries must be integers in $[-1000,1000]$. With at most sixty-four terms, the magnitude of any complete or partial output sum is at most $64\cdot10^6$, so these test-domain products fit the 64-bit accumulators without overflow.
Out-of-range dimensions raise DIMENSION, invalid tile/cache limits raise PARAMETER, wrong array lengths raise SHAPE, and invalid entries raise VALUE, in that order. Python first checks exact parameter types and an exact boolean mode, rejecting booleans/floats used as integer dimensions, parameters, or entries. Both inputs remain unchanged. A zero dimension performs no kernel accesses; if the inner dimension is zero, output is the zero matrix already present in the initialized backing image.
Cache mechanics. The cache stores actual copied line values and dirty flags, with at most frames resident lines. Every modeled word load/store goes through fetch. Eviction writes dirty values back before discarding a line; final flush writes all remaining dirty lines and clears their dirty flags. A clean line is never written merely because it is evicted. C++ uses an LRU list plus hash map; Python uses OrderedDict; Java uses access-ordered LinkedHashMap.
Results contain the flat output values and references, hits, line reads, eviction writebacks, final-flush writebacks, evictions, and peak resident lines. Transfers are the sum of reads and both write categories. The final array is observed directly from flushed backing memory without creating more cache events. Setup/validation and output inspection are outside measurement; the matrix kernel and final dirty flush are inside it.
The simulator itself uses host arrays for the entire backing memory and output observations. Only resident line payloads obey the modeled $Q\lambda$-word capacity; metadata, scalar registers, inputs, backing arrays, and returned output use additional host memory. Python's objects are not physically eight-byte integers here. The printed byte counts describe the logical model, not measured machine traffic. Compilers, actual caches, prefetchers, SIMD, and allocation are not being benchmarked by these instrumented programs.
Expected standard output, identical in all three languages:
shape=4x4 * 4x4 tile=2 line_words=2 cache_lines=7line_bytes=16 cache_bytes=112 equal=trueC=[11,8,10,10,27,24,30,34,43,40,50,58,59,56,70,82]untiled accesses=208 hits=160 reads=48 eviction_writes=6 flush_writes=2 transfers=56 evictions=41 peak_lines=7tiled accesses=224 hits=184 reads=40 eviction_writes=6 flush_writes=2 transfers=48 evictions=33 peak_lines=7
Complexity, Correctness, and Invariants
Arithmetic and references. For positive dimensions, both kernels perform exactly $rkc$ multiplications and $rkc$ additions, so ordinary arithmetic time is $\Theta(rkc)$. The tiled traversal adds block-loop work and may reload an A scalar once per output-column tile. Its exact modeled word-reference count is $$ U_{\mathrm{untiled}}=rk+3rkc, \qquad U_{\mathrm{tiled}}=rk\left\lceil\frac{c}{T}\right\rceil+3rkc. $$ The factor three counts one B load, one C load, and one C store per product. These formulas assume every dimension is positive; otherwise the guarded kernels make zero references. For the sample, the counts are $16+192=208$ and $32+192=224$. A reference is not necessarily a transfer.
Exact transfer accounting. If $R$ is line reads, $W_e$ dirty eviction writes, and $W_f$ final flush writes, measured transfers are $R+W_e+W_f$, with $(R+W_e+W_f)8\lambda$ logical bytes. Also $U=\text{hits}+R$. These counts depend on dimensions, tile order, alignment, line width, and cache capacity. Since there is no data-dependent sparsity shortcut, they are identical for best/average/worst numeric values at fixed geometry and parameters. There is no general exact closed form for LRU misses over every shape; replay the address trace as the simulator does.
For positive dimensions and independently line-aligned matrix regions, let $$ D_A=\left\lceil\frac{rk}{\lambda}\right\rceil, \quad D_B=\left\lceil\frac{kc}{\lambda}\right\rceil, \quad D_C=\left\lceil\frac{rc}{\lambda}\right\rceil. $$ Every input/output line is touched, so cold line reads are at least $D_A+D_B+D_C$, and materializing all updated output requires at least $D_C$ line writes in this write-back model. The compulsory lower bound is therefore $D_A+D_B+2D_C$ transfers. If the cache can retain the entire touched working set, both traversals achieve it. Otherwise repeated loads or dirty evictions can increase traffic. The sample has eight lines per matrix, so its compulsory bound is $32$ transfers; neither $56$ nor $48$ reaches that full-residency bound.
Choosing a tile and stating assumptions. Three useful square tiles need about $3T^2$ words. A practical candidate uses $3T^2\le\alpha M$ with a safety factor $0\lt\alpha\lt1$, but actual resident lines, other live data, and set mapping matter. An unaligned row segment of length $T$ can require up to $\lceil T/\lambda\rceil+1$ lines; three tiles can therefore touch up to roughly $3T(\lceil T/\lambda\rceil+1)$ lines even before accounting for neighbors retained from earlier work. Packing and alignment can reduce this waste. Cornell's array-blocking workshop motivates keeping the input/output working blocks together in cache.
Under an idealized suitable layout/cache with enough capacity and headroom for those blocks, a full tile does $\Theta(T^3)$ arithmetic work for $O(T^2/\lambda)$ line movement when $T$ is at least on the scale of $\lambda$. For large square $n\times n$ inputs with compatible block geometry, this gives the classical blocked scale $$ O\left(\frac{n^3}{\lambda T}+\frac{n^2}{\lambda}\right). $$ Choosing $T=\Theta(\sqrt M)$ yields $O(n^3/(\lambda\sqrt M)+n^2/\lambda)$ under a tall-cache/layout regime where line fragments do not dominate. This is an idealized asymptotic analysis, not a guarantee for every $T,Q,\lambda$ accepted by the finite simulator or for a real set-associative processor cache. Poor tile choices can erase or reverse the benefit, as the capacity experiments show.
Space and simulator cost. The numerical kernel requires the matrices/output and constant scalar state; this version does not allocate packed tile copies. Cache payload capacity is $Q\lambda$ words with $O(Q)$ LRU metadata. Backing/returned arrays use $O(rk+kc+rc+\lambda)$ host words, plus resident lines. Validation, initial zeroing, and result copying are linear in stored array sizes. With expected constant-time hash lookup, the instrumented simulator additionally copies $\lambda$ words per line read/write, so its software runtime includes $O(U+\lambda(R+W_e+W_f))$ work beyond setup. That simulation overhead is not the uninstrumented kernel's hardware execution time.
Correctness by coverage. Half-open, clamped row/column/inner tiles partition each dimension without gaps or overlap. Every triple $(i,x,j)$ belongs to exactly one combination of outer blocks and one inner iteration. Thus every required product $A_{ix}B_{xj}$ is added exactly once to the correct row-major output address.
Partial-sum invariant. For a fixed output tile, before reduction block kk, each entry equals the sum over inner indices below kk. The kernel adds exactly the indices from kk to the clamped block end; it never resets the previous sum. After the last reduction block, the entry equals the full dot product. Within an output entry, inner indices are processed in increasing order in both traversals. Integer bounds make every partial sum exact in these programs.
Cache coherence invariant. For every address, the newest value is in its dirty resident line if one exists, otherwise in backing memory. Reads use resident data or copy the current backing line. Dirty eviction writes the newest values before removal. Final flush therefore leaves all C values materialized in backing memory for observation. Locality affects transfers, while these invariants preserve the answer.
| Traversal or technique | Arithmetic / CPU view | Locality and movement | Main assumption or tradeoff |
|---|---|---|---|
Untiled i,k,j baseline | $\Theta(rkc)$; A scalar reused across full row | Contiguous B/C row access, but reuse across distant rows can be lost | Good loop order alone does not ensure whole working set fits |
Tiled ii,jj,kk,i,k,j | Same products; additional block/A-reference work | Reuse small regions; sample transfers $56\to48$ | Tile footprint, alignment, and capacity must suit the cache |
Strided i,j,k product | Same products with a scalar C sum | Row-major B column accesses have large stride | Registers can reduce C references; layout still matters |
| Packed/register-blocked GEMM | Same classical product count; fewer load instructions possible | Pack panels and retain small C blocks in registers | Extra packing work/storage; architecture-specific tuning |
| Recursive cache-oblivious multiplication | Same classical arithmetic for ordinary recursion | Adapt subproblems across levels without one fixed cache parameter | Base cases, layout, and ideal-cache assumptions matter |
| Blocked transpose | $\Theta(rc)$ element movement | Converts long-stride destinations into smaller reused regions | Writes, alignment, conflict misses, and tails still matter |
| Tiled stencil / dependency-sensitive DP | Problem-specific operation count | Reuse neighborhoods or dependency regions | Reordering must respect dependencies; halos/wavefronts may be required |
Clamped half-open tiles partition every required $(i,x,j)$ product exactly once, and each output tile retains the sum from earlier reduction blocks. Cached dirty data remains authoritative until eviction/flush writes it back. These arithmetic and memory-coherence invariants preserve the result while traversal changes locality and transfer counts.
Variants, Pitfalls, and Optimizations
Keep reduction state across tiles. Initialize output once, outside all reduction blocks. Reinitializing a C tile each time kk changes keeps only the last partial product. The diagram below contrasts the first output block's correct sum with that failure. Test with multiple reduction blocks; one-block examples cannot expose it.
Clamp all dimensions. A square example divisible by $T$ hides tail bugs. Use independent row, column, and inner end bounds, and compute row-major offsets with each array's actual leading dimension. $A$ has row stride $k$; $B$ and $C$ have row stride $c$. Confusing them can appear correct for square inputs and fail for rectangles.
Measure write traffic as well as misses. Fewer line reads need not mean fewer total transfers. A schedule that evicts dirty output more often can cancel its read savings. Flush resident dirty lines at the same boundary for both variants; omitting the tiled run's final flush would unfairly count a result still waiting in cache as already materialized.
Tile size is a tuning parameter. Larger tiles reduce block-loop overhead but increase the live footprint; very small tiles can waste lines or repeat scalar/panel loads. Capacity-fit estimates need headroom and line geometry. This fully associative model excludes set conflicts; real row strides and power-of-two dimensions can map many lines to the same set. Padding, alignment, associativity, and packing can change outcomes.
More levels need more than one block size. A large cache panel and a small register tile serve different purposes. Hierarchical blocking can target several cache levels, while a microkernel keeps part of C in registers and vectorizes contiguous work. Non-square tile dimensions can reflect matrix shape and panel reuse. The finite code uses one cubic tile size to keep the invariant and trace visible.
Loop interchange and tiling are separate choices. Changing i,j,k to i,k,j improves row-major stride before tiling is considered. The supplied comparison gives both variants the same inner order. Intel's memory-access optimization guide discusses both transformations and measurement. Do not attribute every improvement from a changed loop nest solely to the block boundaries.
Dependencies and aliasing. Independent dense output entries permit this reordering because each receives the same ordered reduction. In-place updates, triangular solves, stencils, and dynamic programs may have dependencies crossing tiles. Use legal dependency order, halos, or wavefronts as needed. GPU shared-memory tiling also requires correct ownership, synchronization, and boundary handling; a CPU cache does not have the same explicit programming contract.
Numerical behavior. The sample uses bounded integers so exact output comparison is appropriate. Floating-point reductions can change rounding when a transformation changes accumulation order, vector reduction structure, or fused operations. These loops preserve per-entry inner-index order, but a different register/parallel microkernel may not. Compare with a suitable tolerance and numerical error model when moving beyond the integer laboratory.
Avoid reading too much into the simulator. The modeled line width, fully associative LRU policy, write allocation, and zero-initialized output are explicit choices. Real prefetchers, multiple cache levels, TLB misses, coherence, non-temporal stores, store buffers, compiler transformations, and memory bandwidth are absent. Values held in CPU temporaries and metadata are outside the cache-data model. Production multiplication with C initialized in registers or specialized stores may have a different C read/write schedule.
Validate outputs before timing. Use an independent dot-product oracle, rectangular and tail dimensions, negative/zero values, and configurations that force eviction. Then benchmark uninstrumented kernels or established numerical libraries with repeated controlled measurements. The simulator teaches data movement; its map/list calls and line copies should not be mistaken for realistic GEMM timings.
Zero C only once, retain previous reduction-block sums, and clamp row, column, and inner tile ends independently. Tune against actual line footprint and total read-plus-writeback traffic, not tile words or read misses alone. Reordering is valid only when dependencies, aliasing, and numerical requirements permit it.
Practice Problems and Real-World Use
Compare both traversals with an independent reference that computes each output as a direct dot product. Test non-square shapes, every dimension smaller than the tile, dimensions just above multiples, zero dimensions, all-zero/all-one data, negative values, and tiles larger than the whole problem. A tile covering every dimension should reproduce the untiled address order and all counters.
For cache verification, generate the reference address trace separately and replay recency using timestamps rather than the implementation's LRU container. Check all reads, dirty writes, hits, evictions, peak residents, and the final matrix. Exercise generic store misses and dirty evictions independently too: multiplication's immediate C load/store pair does not by itself cover every cache-policy branch.
All three implementations agree on $3851$ shared inputs: $3834$ valid cases and $17$ rejected cases. The tests compare every output entry with independent dot products and every cache counter with a timestamp-based replay. They cover exhaustive small shape/parameter combinations, randomized rectangular products, all three dimension limits including $64\times64\times64$, ragged tiles, tiny/large caches, partial lines, invalid limits/shapes, and bounded extreme values. Python also passed $36$ exact-type rejection checks, $3200$ generic cache load/store checks with independent backing-memory verification and idempotent flush, and $200$ value-independence comparisons. C++ ran with address and undefined-behavior sanitizers; all sample output matches byte for byte.
To explore a machine, first identify the actual scalar representation and memory layout, then measure candidate rectangular tiles and cache levels. Record output correctness, elapsed time, and relevant hardware counters. Keep source initialization, kernel, flush/observation boundaries, thread placement, and compiler choices consistent so an apparent gain has a clear cause.
| Exercise or application | What to implement or verify | Difficulty |
|---|---|---|
| First-tile hand trace | Reproduce all twenty-eight references and six cold line reads | Beginner |
| Rectangular tail test | Multiply $5\times3$ by $3\times7$ with $T=2$ and clamp every end | Beginner |
| Tile/cache sweep | Plot reads, dirty writes, and total transfers separately | Intermediate |
| Blocked matrix transpose | Preserve each element while improving destination-line reuse | Intermediate |
| Image/stencil tiles | Budget neighborhoods/halos and preserve boundary dependencies | Advanced |
| Packed GEMM microkernel | Compare panel packing, register tiles, and scalar-reference reduction | Advanced |
| Dependency-aware DP | Prove a legal tiled/wavefront order before optimizing | Advanced |
| Hardware validation | Compare the simulator's predictions with uninstrumented timing and cache counters | Advanced |