Why Parallel Matrix Multiplication Matters
A company has an order matrix A whose rows are branches and columns are products. A second matrix B maps products to several price scenarios. Multiplying them produces every branch's total under every scenario. Each result combines one row of A with one column of B; many different output entries can be computed independently.
Parallel matrix multiplication appears in linear transformations, dense numerical methods, neural-network layers, batched simulations, and many data-processing pipelines. Its arithmetic is regular, but a fast implementation must also reuse data, expose enough independent work, and prevent workers from corrupting shared sums. A hundred available workers help little if one large task owns all the output, or if they repeatedly fetch the same inputs from slow memory.
This lesson implements classical dense multiplication with exclusive output-tile ownership. Each tile job computes all shared-index contributions to its own output cells. Jobs can run independently because they read the same immutable inputs and write disjoint output regions. We group the shared dimension into bands and carefully handle partial edge tiles.
The three programs are deterministic worker simulators, not threaded implementations. They print every scalar product, every updated partial sum, tile snapshots after each band, and a clearly defined arithmetic-only timing model. This makes correctness and scheduling visible before introducing actual concurrency or hardware-specific optimization.
Parallel matrix multiplication computes independent output entries or tiles concurrently while reducing each entry's row-by-column products. Give every output tile one owner across all shared-index bands, preserve its partial sums, and handle edge bounds so each required product contributes exactly once.
Core Concepts and Terminology
Let $A\in\mathbb{Z}^{M\times K}$ and $B\in\mathbb{Z}^{K\times N}$. The inner dimensions must match. Their product $C=AB\in\mathbb{Z}^{M\times N}$ is defined by
$$C_{ij}=\sum_{t=0}^{K-1}A_{it}B_{tj},\qquad 0\le i\lt M,\quad 0\le j\lt N.$$
Indices start at zero. This is row times column, not entry-by-entry multiplication and not row times row. In general $AB\ne BA$, and one of these products may not even have compatible dimensions. If $K=0$, the empty sum is zero: C can be a nonempty zero matrix even though both flat input arrays are empty. If $M=0$ or $N=0$, C has no entries.
The implementation stores matrices in flat row-major arrays. A matrix with R rows and S columns stores row i, column j at offset $iS+j$. Therefore A uses $iK+t$, B uses $tN+j$, and C uses $iN+j$. Explicit M, K, N preserve zero-dimension shapes that cannot be recovered reliably from a bare empty nested list. A flat input must have exactly $MK$ or $KN$ entries respectively; there are no hidden row lengths or strides in this API.
With tile side T, output tile row r and column c covers rows $[rT,\min((r+1)T,M))$ and columns $[cT,\min((c+1)T,N))$. The half-open bounds include their start and exclude their end. Its owner processes all t values, grouped into bands $[q,\min(q+T,K))$. Clamping all three dimensions handles matrices whose sizes are not multiples of T.
A scalar MAC step in our model performs one multiplication and one addition: $C_{ij}\leftarrow C_{ij}+A_{it}B_{tj}$. We charge that pair one logical tick. This is an analysis unit, not a claim that integer multiplication/addition execute as one instruction or that a floating-point fused multiply-add is used. The code counts multiplications and additions separately, including addition to the initial zero.
A job executes its owned tile serially, one scalar MAC step at a time. Independent jobs can run on different workers; jobs within a wave start together, and the next wave waits for the slowest job. Work counts all MAC steps; tile span means the longest independent tile-job chain before scheduling barriers. The actual wave schedule can take longer. The diagram illustrates this distinction on the worked example: job costs are 12, 6, 6, and 3, so MAC work is 27, tile span is 12, and two-worker wave time is 18 ticks. None of these counts includes memory traffic or setup.
| Term | Example | Role |
|---|---|---|
| Compatible shapes | $A:3\times3$, $B:3\times3$, $C:3\times3$ | Shared K dimension indexes the products in each dot product |
| Output entry | $C_{00}=1\cdot9+2\cdot6+3\cdot3=30$ | One row-by-column reduction |
| Row-major offset | B row t, column j uses $tN+j$ | Uses the column count as the row stride |
| Output tile | Rows $[0,2)$, columns $[0,2)$ | Defines one job's exclusive write ownership |
| Shared-index band | $[0,2)$ followed by $[2,3)$ | Groups contributions without resetting partial sums |
| Scalar MAC | Multiply 2 by 6, then add 12 to the accumulator | One modeled tick, two counted arithmetic operations |
| Partial edge tile | Rows $[2,3)$, columns $[2,3)$ | Performs only valid entries' work |
| Worker / wave | Job $j$ uses worker $j\bmod P$, wave $\lfloor j/P\rfloor$ | Makes assignment deterministic |
| Work / tile span | 27 MACs total, longest tile costs 12 | Separates total arithmetic from dependency depth |
| Barrier / idle time | Short job finishes at tick 6 but waits until 12 | A scheduling policy can add delay beyond data dependencies |
How Parallel Matrix Multiplication Works, Step by Step
Use immutable flat A and B, a fresh zero-filled C, tile side T, and P logical workers. Every tile owns a disjoint rectangular region of C for its entire computation, not just one shared-index band.
- Validate before indexing. Require worker count $1\le P\le16$, tile side $1\le T\le8$, dimensions $0\le M,K,N\le8$, exact flat-array lengths, and integer values in $[-1000,1000]$. Validate in that order. The limits keep complete traces readable and all arithmetic exact in every language.
- Allocate C once. Fill its $MN$ entries with zero. Do not zero it again when the next K band begins. For $K=0$ this initialization already produces the required zero result. With no output entries there are no tile jobs.
- Enumerate output owners. Visit row-tile starts in increasing order and, within each, column-tile starts in increasing order. Clamp the row and column ends. Job j uses worker $j\bmod P$ and wave $\lfloor j/P\rfloor$. The tile rectangles partition all output coordinates exactly once.
- Start a wave. All its jobs have the same modeled start tick. Within one job, loop over shared-index bands, then t within a band, then owned rows i, then owned columns j. Read $A_{it}$ and $B_{tj}$, compute their product, and add it to this owner's $C_{ij}$. Increment the job's MAC step count and record its operands, product, old/new accumulator, and logical completion tick.
- Keep and record the partial tile. After each K band, save the owned tile's current values in row-major order. That snapshot is a partial result, not a reset or a new independent output. Continue through every t value in increasing order.
- Finish the wave and continue. A job with d MAC steps finishes at start plus d. The wave ends at its longest job's finish tick; shorter jobs idle until then. The next wave starts at that end tick. Return C, all trace records, operation counts, job/wave counts, tile span, and schedule ticks.
The code executes one job at a time on the host while recording the arithmetic schedule they could follow concurrently. Its stdout groups updates by job, so a later printed job may contain earlier tick values. These are logical event records, not a globally time-sorted execution log. Within any one job, steps and shared indices increase normally.
For real threads, distinct tile owners need no lock for their C entries as long as A and B stay read-only and result memory does not alias either input. There is no required barrier between K bands of different tiles in this ownership design. The wave barrier is a scheduling choice; production task queues can let idle workers take the next tile instead. A cooperative GPU implementation that shares loaded A/B tiles among threads introduces its own memory-scope barriers and ownership rules, which are not implemented here.
flowchart TD
A["Validate workers, tile, dimensions, sizes, values"] --> B["Allocate zero C; enumerate disjoint output tiles"]
B --> C{"Any wave left?"}
C -->|No| D["Return output, counts, and trace"]
C -->|Yes| E["Start all wave jobs at the same logical tick"]
E --> F["For each owner, keep its accumulator tile"]
F --> G{"More shared-index bands?"}
G -->|Yes| H["For each valid t, row, column: multiply and add"]
H --> I["Record every update and the partial owned tile"]
I --> G
G -->|No| J{"More jobs in this wave?"}
J -->|Yes| F
J -->|No| K["Wave barrier: wait for maximum job finish"]
K --> C
Worked Example with a Full Trace
Take $M=K=N=3$, $T=2$, and $P=2$, with rows
- A:
[1,2,3],[4,5,6],[7,8,9]. - B:
[9,8,7],[6,5,4],[3,2,1].
The expected C rows are [30,24,18], [84,69,54], and [138,114,90]. For example, $C_{12}=4\cdot7+5\cdot4+6\cdot1=54$. Each cell has three products. Starting from zero means three accumulation additions per cell, even though a hand-written sum of three already formed products can use only two additions.
There are four output jobs in row-major tile order. Job 0 owns rows $[0,2)$ and columns $[0,2)$; job 1 owns rows $[0,2)$ and columns $[2,3)$; job 2 owns rows $[2,3)$ and columns $[0,2)$; job 3 owns rows $[2,3)$ and columns $[2,3)$. Their cell counts are 4, 2, 2, and 1, so their MAC costs are 12, 6, 6, and 3. Every job uses K bands $[0,2)$ and $[2,3)$.
Job 0, worker 0, wave 0. In its first band, t=0 updates the owned tile from zero to rows [9,8] and [36,32]. Then t=1 adds rows [12,10] and [30,25], producing partial rows [21,18] and [66,57], flattened as [21,18,66,57]. In the final band, t=2 adds rows [9,6] and [18,12], yielding [30,24,84,69]. Logical ticks 1–4 perform t=0, 5–8 perform t=1, and 9–12 perform t=2. The job finishes at tick 12.
Job 1, worker 1, wave 0. For its right-edge tile, t=0 contributes [7,28]; t=1 adds [8,20], so the first band finishes with [15,48]. The final band adds [3,6] and gives [18,54]. It finishes at tick 6 and idles until wave 0 ends at tick 12. At that barrier C is [[30,24,18],[84,69,54],[0,0,0]].
Job 2, worker 0, wave 1. It starts at tick 12. For the lower-left tile, t=0 gives [63,56] and t=1 adds [48,40], yielding [111,96]. The final band adds [27,18] and gives [138,114]. Its six MAC steps complete at tick 18.
Job 3, worker 1, wave 1. Also starting at tick 12, its lone cell follows $0\to49\to81\to90$ from products 49, 32, and 9. It finishes at tick 15 and idles until wave 1 ends at tick 18. Now every cell is final. The diagram below lists all 27 products and all 27 resulting partial sums, with the same completion ticks as the programs.
Total arithmetic is 27 multiplications and 27 additions, or 27 MAC steps under the paired-step model. Two waves take $\max(12,6)+\max(6,3)=18$ modeled ticks, while independent tile span is 12. One worker takes 27 ticks; three workers take 15 under this wave policy; four or more take 12. Those differences reflect task grouping and idle time, not a change in C or in the total number of products.
Trace It by Hand
Pause after job 0's first shared-index band, when its tile is [21,18,66,57] in row-major order. Predict the four contributions from t=2 and the final tile. Does the accumulator start again at zero?
Reveal: the t=2 A values are 3 and 6, and the relevant B values are 3 and 2. The four products are 9, 6, 18, and 12. Add them to the saved partial tile to get [30,24,84,69]. Resetting the tile at this band would incorrectly return only [9,6,18,12].
Next use the same shapes and values with tile side 1 and two workers. There are nine three-MAC jobs, so five waves take 15 modeled ticks, versus 18 for tile side 2. Explain why a finer task grid improves this particular arithmetic schedule without proving that smaller tiles run faster on real hardware.
Implementation in Python, C++, and Java
Each panel is a complete standalone program with identical inputs, loop order, edge bounds, trace records, and output. Python needs 3.9 or later. C++ uses C++17; compile with g++ -std=c++17 -O2 main.cpp -o matmul and run ./matmul. Save Java as Main.java, then use javac Main.java and java Main. Run Python with python3 main.py. The programs simulate workers sequentially; no threading or numeric libraries are required.
The shared API is multiply(a,b,m,k,n,tile,processors), with both input matrices supplied as flat row-major arrays. Explicit dimensions support zero rows, zero columns, and a zero shared dimension without ambiguity. C++ uses long long entries, Java uses long, and Python uses exact integers. Dimensions, tile sizes, and worker counts are bounded small integers. Validate errors in order: PROCESSORS, TILE, DIMENSION, SIZE, VALUE. Python additionally rejects booleans, floats, strings, and nulls where exact integers are required. The input containers must be non-null and flat; C++/Java primitive numeric arrays/vectors enforce their element types. Inputs are never modified, and C is freshly allocated.
With $K\le8$ and input magnitudes at most 1000, every product has magnitude at most $10^6$ and every partial sum at most $8\cdot10^6$. The programs therefore agree exactly without floating-point rounding or integer overflow. Every MAC increments both multiplication and addition counters, including $0+\text{first product}$. A printed tick is the logical completion time of that modeled MAC, not a timestamp or an observed CPU cycle.
Update records operands, product, old/new sum, coordinates, shared index, and completion tick. Block stores the K-band bounds, its updates, and the owned tile's snapshot. Job stores its exclusive output rectangle, worker, start/finish ticks, and blocks. Wave stores its jobs and barrier time; Result stores the output and totals. The printed tile_span is the largest independent serial tile cost, before this P-worker scheduling policy adds wave barriers. Retained snapshots are of owned tiles, not copies of the whole C after every arithmetic update.
For zero shared dimension, jobs still describe output ownership, but have no blocks or arithmetic steps. All output entries are zero, and model_ticks and tile_span are zero even if several zero-duration waves are listed. Allocation, validation, job bookkeeping, snapshots, input loads, output stores, and printing still take real time; they are deliberately excluded from the arithmetic-only model.
Verification: each language passed 6,567 input/configuration cases, including 6,555 accepted and 12 rejected. The suite covers every shape with M, K, N from 0 through 8 at six tile/worker combinations, exhaustive small integer/binary matrices, identity multiplication on both sides, extreme values, edge tiles, and seeded random cases. Independent dot-product and prefix-sum oracles checked 323,317 MAC updates, all intermediate products/partial sums and tile snapshots, exact per-cell ownership, logical event ticks, wave durations, counters, and unchanged inputs. Full formatted traces agree across languages; sample stdout matches byte for byte. Eleven additional Python checks reject invalid runtime types. C++ passed address/undefined-behavior sanitizer runs; Java compiled with all lint warnings enabled.
Identical expected output (each item is one printed line; events are grouped by owner rather than globally sorted by tick):
m=3 k=3 n=3 tile=2 processors=2A=[[1,2,3],[4,5,6],[7,8,9]]B=[[9,8,7],[6,5,4],[3,2,1]]wave=0 ticks=[0,12)job=0 worker=0 rows=[0,2) cols=[0,2) ticks=[0,12) macs=12k=[0,2)C[0,0] t=0 tick=1 a=1 b=9 product=9 old=0 new=9C[0,1] t=0 tick=2 a=1 b=8 product=8 old=0 new=8C[1,0] t=0 tick=3 a=4 b=9 product=36 old=0 new=36C[1,1] t=0 tick=4 a=4 b=8 product=32 old=0 new=32C[0,0] t=1 tick=5 a=2 b=6 product=12 old=9 new=21C[0,1] t=1 tick=6 a=2 b=5 product=10 old=8 new=18C[1,0] t=1 tick=7 a=5 b=6 product=30 old=36 new=66C[1,1] t=1 tick=8 a=5 b=5 product=25 old=32 new=57tile_after=[21,18,66,57]k=[2,3)C[0,0] t=2 tick=9 a=3 b=3 product=9 old=21 new=30C[0,1] t=2 tick=10 a=3 b=2 product=6 old=18 new=24C[1,0] t=2 tick=11 a=6 b=3 product=18 old=66 new=84C[1,1] t=2 tick=12 a=6 b=2 product=12 old=57 new=69tile_after=[30,24,84,69]job=1 worker=1 rows=[0,2) cols=[2,3) ticks=[0,6) macs=6k=[0,2)C[0,2] t=0 tick=1 a=1 b=7 product=7 old=0 new=7C[1,2] t=0 tick=2 a=4 b=7 product=28 old=0 new=28C[0,2] t=1 tick=3 a=2 b=4 product=8 old=7 new=15C[1,2] t=1 tick=4 a=5 b=4 product=20 old=28 new=48tile_after=[15,48]k=[2,3)C[0,2] t=2 tick=5 a=3 b=1 product=3 old=15 new=18C[1,2] t=2 tick=6 a=6 b=1 product=6 old=48 new=54tile_after=[18,54]wave=1 ticks=[12,18)job=2 worker=0 rows=[2,3) cols=[0,2) ticks=[12,18) macs=6k=[0,2)C[2,0] t=0 tick=13 a=7 b=9 product=63 old=0 new=63C[2,1] t=0 tick=14 a=7 b=8 product=56 old=0 new=56C[2,0] t=1 tick=15 a=8 b=6 product=48 old=63 new=111C[2,1] t=1 tick=16 a=8 b=5 product=40 old=56 new=96tile_after=[111,96]k=[2,3)C[2,0] t=2 tick=17 a=9 b=3 product=27 old=111 new=138C[2,1] t=2 tick=18 a=9 b=2 product=18 old=96 new=114tile_after=[138,114]job=3 worker=1 rows=[2,3) cols=[2,3) ticks=[12,15) macs=3k=[0,2)C[2,2] t=0 tick=13 a=7 b=7 product=49 old=0 new=49C[2,2] t=1 tick=14 a=8 b=4 product=32 old=49 new=81tile_after=[81]k=[2,3)C[2,2] t=2 tick=15 a=9 b=1 product=9 old=81 new=90tile_after=[90]C=[[30,24,18],[84,69,54],[138,114,90]]multiplications=27 additions=27 model_ticks=18 tile_span=12 jobs=4 waves=2
Complexity, Correctness, and Invariants
Assume constant-size exact values and constant-cost arithmetic. The asymptotic discussion describes the same algorithm without the small teaching limits. Use M rows, K shared coordinates, N output columns, tile side T, and P workers; keep them distinct instead of silently assuming square matrices.
Arithmetic work. Each of the $MN$ output entries processes K terms, giving
$$W_{\mathrm{MAC}}=MNK,\qquad W_{\times}=MNK,\qquad W_{+}=MNK.$$
This implementation performs $2MNK$ counted arithmetic operations because it adds each product to an initially zero accumulator. The common alternative count $MNK$ multiplications plus $MN(K-1)$ additions applies to nonempty dot products initialized from their first product; it is not this program's counter convention. All supported values, including zeros, trigger the same loop structure. Best, average, and worst arithmetic work are identical for fixed shapes. For two square matrices with $M=K=N=s\ge1$, classical work is $\Theta(s^3)$, not $\Theta(s^2)$.
Tile jobs and independent span. Let
$$R=\left\lceil\frac{M}{T}\right\rceil,\qquad S=\left\lceil\frac{N}{T}\right\rceil,\qquad Q=RS.$$
Each tile job q owns $r_q$ rows and $c_q$ columns and has cost $d_q=Kr_qc_q$ MAC ticks. These jobs are independent but execute their own cells serially. Consequently, for nonempty output, the tile-job DAG has span
$$D_{\mathrm{tile}}=\max_qd_q=K\min(T,M)\min(T,N).$$
With no output entries define this span as zero. If $K=0$, it is also zero. This is the span of the independent arithmetic jobs before the scheduler's wave barriers; it is not the span of the already serialized host program, nor a claim about a GPU block whose threads cooperate inside a tile. With $T=1$, output cells are separate jobs and span is K. A single tile containing all output entries has serial job cost $MNK$, so this teaching job grouping can expose no arithmetic concurrency in that case.
Exact wave schedule. Consecutive groups of at most P tile jobs form waves. If wave w contains job set $G_w$, its start follows the preceding wave and its duration is $\max_{q\in G_w}d_q$. Thus
$$T_{\mathrm{wave}}=\sum_w\max_{q\in G_w}d_q,\qquad \text{wave count}=\left\lceil\frac{Q}{P}\right\rceil.$$
For positive work, $T_{\mathrm{wave}}\ge\max(W_{\mathrm{MAC}}/P,D_{\mathrm{tile}})$ and $T_{\mathrm{wave}}\le W_{\mathrm{MAC}}$. One worker takes exactly $W_{\mathrm{MAC}}$ ticks; P at least Q takes exactly $D_{\mathrm{tile}}$. If both output dimensions are multiples of T, all tiles have cost $KT^2$, so $T_{\mathrm{wave}}=KT^2\lceil Q/P\rceil$. Partial edges produce unequal costs, so multiplying the wave count by one assumed uniform tile cost is generally wrong. A suitable dynamic work-conserving list schedule on independent jobs can obtain $O(W_{\mathrm{MAC}}/P+D_{\mathrm{tile}})$ arithmetic time; do not assume our fixed barrier groups always attain that bound tightly.
On the worked input, $W_{\mathrm{MAC}}=27$, $D_{\mathrm{tile}}=12$, and $T_{\mathrm{wave}}=18$ for two workers. Arithmetic-only speedup against the same one-worker job schedule is $27/18=1.5$ and efficiency is $1.5/2=0.75$. These are modeled quantities. Actual initialization, input/output, memory bandwidth, cache reuse, parallel startup, and barriers can alter measured speedup substantially.
A different decomposition gives a different span. Assigning one worker per cell but accumulating its K products sequentially gives span $\Theta(K)$. Computing a cell's products in parallel and reducing them with a balanced tree can lower arithmetic span to $\Theta(1+\log K)$ for positive K, while retaining $\Theta(MNK)$ work for positive dimensions. A straightforward all-products temporary array uses $O(MNK)$ space. Private partial tiles plus reduction offer intermediate tradeoffs; a shared unsynchronized accumulator is not a valid replacement. Changing the reduction tree also changes floating-point grouping.
Space and real simulator cost. C occupies $\Theta(MN)$ values. Input storage is $\Theta(MK+KN)$ and is supplied by the caller. Without the saved history, output ownership needs Q task descriptors and small loop state, with no extra arrays of A/B tiles in this particular code. With full history, there are $MNK$ update records and $MN\lceil K/T\rceil$ saved owned-tile entries. Since T is at least one, the history plus output occupies $O(MN(K+1)+1)$ records; no full-matrix snapshot is saved per update. Job/wave descriptors are bounded by $O(MN)$ when output exists. Including validation and output initialization, valid-input sequential host work is
$$\Theta(MK+KN+MN+MNK+1)$$
in a word/record model, before accounting for text lengths. The host does not run in $T_{\mathrm{wave}}$ time. For $K=0$, creating a nonempty zero output still costs $\Theta(MN)$ real work even though the arithmetic model reports zero ticks. For zero output size, validating a nonempty input can still take time.
Correctness invariant. For each owned cell, immediately before processing shared index t, its accumulator equals
$$C_{ij}^{(t)}=\sum_{u=0}^{t-1}A_{iu}B_{uj}.$$
Initialization establishes the empty prefix sum at t=0. One update adds exactly $A_{it}B_{tj}$, establishing the formula for t+1. Bands only group consecutive t values; they do not alter this induction or reset its accumulator. After all K values, the cell equals the required dot product. The tile grid covers each output coordinate exactly once, different owners write disjoint cells, and all inputs stay immutable. Therefore independently executing the tile jobs preserves every cell invariant and yields the correct matrix. Zero shared dimension follows directly from the empty-sum convention.
| Strategy | Arithmetic work | Arithmetic dependency span / bottleneck | Space and assumptions |
|---|---|---|---|
| Serial classical multiplication | $\Theta(MNK)$ for positive dimensions | $\Theta(MNK)$ serial chain | $\Theta(MN)$ output; no worker concurrency |
| One owner per cell, serial dot product | $\Theta(MNK)$ | $\Theta(K)$ with enough workers | Output plus tasks; direct race-free ownership |
| Serial tile-owner jobs as shown | $MNK$ MACs | $K\min(T,M)\min(T,N)$ independent-tile span | Output plus tile tasks; partial edges have smaller costs |
| This fixed P-worker wave schedule | $MNK$ MACs | Exact time $\sum_w\max_{q\in G_w}d_q$ | Wave barriers may add idle time; excludes nonarithmetic costs |
| Products plus balanced per-cell reduction | $\Theta(MNK)$ | $\Theta(1+\log K)$ for positive K | Naive explicit products use $O(MNK)$ temporary values |
| Cooperative blocked CPU/GPU GEMM | Classical $\Theta(MNK)$ arithmetic | Depends on within-tile parallelism, data movement, and hardware | Packing/shared-memory/register reuse; not modeled by serial tile jobs |
| Full trace simulator | $\Theta(MK+KN+MN+MNK+1)$ host work | Sequential host execution | $O(MN(K+1)+1)$ output/history records; input storage separate |
For every output cell, the accumulator is exactly the sum of the already processed shared-index products. Initialization, one multiply-add per index, persistent partial sums across bands, and exclusive tile ownership preserve this invariant until all K contributions have been included.
Variants, Pitfalls, and Optimizations
Do not share an unprotected output reduction. Parallelizing t while multiple workers execute C[i,j] += product can lose updates. For $C_{00}$, the first products are 9 and 12. If two workers both read zero, one can write 9 and the other write 12 from its stale read; the final product 9 then produces 21 instead of 30. A normal read-modify-write is not atomic. Keep one output owner, or compute private partial sums/tiles and combine them through a defined reduction. Atomics may serialize or introduce numerical nondeterminism; they are a design choice, not a free substitute for ownership.
Initialize once and clamp every dimension. Resetting C at each K band discards previous terms. Forgetting the row, column, or shared-index edge guard can access past an array or multiply unintended data. For T=2 and dimension 3, final ranges have length 1. The loops use actual bounds and do no padded arithmetic. This differs from kernels that deliberately compute masked or zero-padded full tiles; do not reuse their operation counts for this code.
Use the correct strides and dimensions. A uses stride K, B and C use stride N. Accidentally using M as a stride may pass square tests and fail rectangular matrices. A transpose changes both shape and indexing; it is not achieved by replacing one stride. The API's explicit dimensions reject missing/excess entries, including shapes with zero shared dimension. If introducing leading dimensions or submatrix views, add bounds and stride contracts rather than assuming dense contiguous rows.
Blocking is about reuse as well as scheduling. Our K bands organize loop traversal, but the code does not explicitly pack input tiles, load shared memory, use vector instructions, or measure cache traffic. For a full T-by-T-by-T tile product, $T^3$ MACs can reuse two $T^2$ input tiles; this is the locality opportunity. Effective reuse still depends on cache/register capacity, loop order, layout, and hardware. One commonly used CPU cache-sizing heuristic considers space for A, B, and C tiles, about $3T^2$ elements, but real kernels often use different rectangular blocking levels and packing buffers. A larger tile can reduce task count and reuse input while also reducing exposed parallelism in our serial-tile model. These effects must be measured separately.
Cooperative GPU kernels need their own barriers. A GPU block commonly lets threads load an A/B tile, synchronize before consuming shared data, accumulate private register results, and synchronize before overwriting that shared tile for the next K band. Those synchronization points protect the shared buffers; they are different from our logical wave barrier and from the absence of inter-tile data dependence. Respect the scope and participation requirements of the platform's barriers, including boundary threads. This lesson's sequential loops are not a ready-to-launch GPU kernel.
Use a deliberate numeric policy. Exact bounded integers avoid overflow here. With arbitrary C++ signed integers, overflow is undefined; Java fixed-width integer operations wrap. Promote operands before multiplication and validate bounds or use checked/wider arithmetic as required. Floating-point addition is not associative: a tree or split-K reduction can differ from a serial dot product. This particular owner loop preserves increasing-t accumulation order for each cell across T and P, but real libraries may reorder operations or use fused/mixed-precision instructions. Define tolerances, reproducibility requirements, and accumulator precision when comparing floating-point results.
Choose the larger algorithm to fit the data. Tuned GEMM typically computes $C\leftarrow\alpha\,\operatorname{op}(A)\operatorname{op}(B)+\beta C$ and supports layouts/transposition beyond this lesson's $C=AB$. Sparse multiplication changes representation, work, and load balance; do not apply dense MNK counts to it. Batched small products need overhead amortization. Distributed 2D block algorithms must also route/broadcast panels and manage communication. Strassen-style methods reduce square multiplication's arithmetic exponent but introduce extra additions, temporary storage, cutoffs, and numerical tradeoffs; they do not follow automatically from parallelizing these loops. For application code, a suitable tuned linear-algebra implementation is usually the baseline worth measuring against.
Never let two unsynchronized workers update the same output accumulator, reset partial sums at a new shared-index band, or assume every edge tile is full size. Distinguish arithmetic-only timing from memory and launch costs, and recheck strides, ownership, and numeric precision when changing the implementation.
Practice Problems and Real-World Use
Start with a direct sequential row-by-column reference, then inspect the recorded prefix sum after every shared index. On rectangular cases, verify the exact row-major offsets for all three matrices. Count output owners and confirm their rectangles neither overlap nor omit a cell. Changing tile side or worker count must preserve the exact integer output and the multiset of coordinate/product contributions, while scheduling and trace grouping can change.
Useful properties include $AI=A$, $IA=A$ for compatible identity sizes, multiplication by a zero matrix, and $(AB)^{\mathsf T}=B^{\mathsf T}A^{\mathsf T}$. Over exact arithmetic, distributivity also provides tests when shapes match. Keep generated test values within the API's bounds. Include $K=0$ with nonempty output, $M=0$, $N=0$, negative values, cancellation, singleton dimensions, skinny/tall products, and sizes just below/at/above a tile boundary. Test invalid lengths and dimensions before trusting randomized arithmetic tests.
For a concurrent extension, retain one output owner and replace the deterministic host loop with actual independent tasks. First compare outputs and ownership under repeated runs. Then add a task queue, tile reuse or packing, and an appropriate cutoff for tiny jobs. A split-K experiment should use private partial tiles plus a reduction, making its extra memory and different reduction order visible. Never interpret the simulator's tick field as a measured race test or a hardware benchmark.
For performance reports, record matrix shapes, data types, layouts, tile/block sizes, workers, arithmetic policy, reference implementation, and whether allocation/transfers are included. Compare arithmetic throughput alongside elapsed time, memory movement, and load imbalance. A larger tile's cache benefit cannot be inferred from this arithmetic-only scheduler, and a smaller tile's modeled improvement can be outweighed by task overhead on real systems.
Further reading: Netlib's GEMM specification defines the broader general matrix-multiplication operation and shape parameters. NVIDIA's matrix multiplication background discusses tiling and hardware performance considerations. CUTLASS's tiling explanation illustrates hierarchical reuse and accumulation. Their hardware/library discussions provide context; the exact counts, ownership, and wave timing above describe this lesson's serial-tile simulator.
| Practice problem or application | What it tests | Difficulty |
|---|---|---|
| Rectangular product with partial tiles | Three distinct dimensions, correct strides, and all edge bounds | Beginner |
| Identity/zero-dimension test suite | Algebraic reference properties and explicit shape metadata | Intermediate |
| Owner map and dynamic task queue | Exclusive writes, job coverage, and idle-time reduction | Intermediate |
| Split-K multiplication | Private partial tiles, safe reduction, memory, and sum ordering | Advanced |
| Packed/blocked CPU matrix product | Cache reuse, vectorization, and an honest tuned baseline | Advanced |
| Neural-network dense layer | Batch/feature shapes, bias handling, and precision policy | Advanced |
| Batched geometric transforms | Many small products and task/launch overhead | Intermediate |
| Distributed dense linear algebra | Panel communication, 2D partitioning, and load balance | Advanced |