Module 8: Matrix Math and Matmul Labs
Introduction: The Kernel Pattern Behind Modern AI
C = A × B looks simple. In practice, matrix multiply is the dominant kernel in transformer inference and training.
This module ports the full Lab 1 → Lab 2 → Lab 3 arc from TT-Metalium™ docs into the interactive lesson style used by the rest of this collection.
You will move from:
- One core + tiled matmul (correctness and dataflow)
- Many cores + split work (parallel decomposition)
- Multicast + reuse (network-aware optimization)
By the end, you should be able to explain not just what command to run, but why each optimization exists.
What You'll Learn
- ✅ Why 32×32 tiling is the practical unit for matrix kernels
- ✅ How reader/compute/writer kernels compose a full matmul pipeline
- ✅ How
split_work_to_cores(...)maps output tiles across a core grid - ✅ Why multicast + semaphores reduce DRAM pressure in multi-core kernels
- ✅ How to run and study the same progression on hardware or simulator
Upstream Labs (Source of Truth)
Use these as the canonical references while you work through this module:
- Lab 1: Single Core Matrix Multiplication
https://github.com/tenstorrent/tt-metal/blob/main/docs/source/tt-metalium/tt_metal/labs/matmul/lab1/lab1.rst - Lab 2: Multi Core Matrix Multiplication
https://github.com/tenstorrent/tt-metal/blob/main/docs/source/tt-metalium/tt_metal/labs/matmul/lab2/lab2.rst - Lab 3: Multicast for Improved Data Reuse
https://github.com/tenstorrent/tt-metal/blob/main/docs/source/tt-metalium/tt_metal/labs/matmul/lab3/lab3.rst
Part 1: Concept Primer (Before You Run Anything)
Matmul refresher
For A[M×K], B[K×N], output C[M×N]:
C[i, j] = Σ (A[i, k] * B[k, j]) for k in [0, K)
A naive kernel streams every operand from DRAM repeatedly. The labs show why that is too expensive and how to move reuse closer to compute.
Why tiles matter
In these labs, matmul is expressed as tile operations (commonly 32×32):
- Tiles fit naturally into local scratchpad workflows
- Reader kernels stage tile blocks for compute
- Compute kernels execute matrix ops on staged tiles
- Writer kernels flush output tiles back to DRAM
Think of each output tile as an independent unit of work that can be assigned to one core (Lab 1) or many cores (Lab 2/3).
Dataflow mental model
DRAM A/B tiles -> Reader kernel -> Circular buffers in L1
-> Compute kernel -> Accumulator tiles
-> Writer kernel -> DRAM C tiles
In Lab 3, the first arrow is optimized further with NoC multicast so multiple cores can consume one producer's data movement.
Part 2: Environment Setup (Hardware or Simulator)
If ~/tt-metal is missing (common on TT-QuietBox® 2 preconfigured images), start here first:
🛠️ Build TT-Metalium from Source
Build programming examples used by this module:
cd ~/tt-metal
./build_metal.sh --build-programming-examples
cd ~/tt-metal && ./build_metal.sh --build-programming-examples
Optional simulator path (hardware-free)
mkdir -p ~/sim
wget -q https://github.com/tenstorrent/ttsim/releases/download/v1.8.4/libttsim_wh.so -O ~/sim/libttsim_wh.so || { echo "ERROR: failed to download libttsim_wh.so"; exit 1; }
wget -q https://github.com/tenstorrent/ttsim/releases/download/v1.8.4/libttsim_bh.so -O ~/sim/libttsim_bh.so || { echo "ERROR: failed to download libttsim_bh.so"; exit 1; }
wget -q https://github.com/tenstorrent/ttsim/releases/download/v1.8.4/libttsim_wh_x2.so -O ~/sim/libttsim_wh_x2.so || { echo "ERROR: failed to download libttsim_wh_x2.so"; exit 1; }
wget -q https://github.com/tenstorrent/ttsim/releases/download/v1.8.4/libttsim_bh_x2.so -O ~/sim/libttsim_bh_x2.so || { echo "ERROR: failed to download libttsim_bh_x2.so"; exit 1; }
wget -q https://github.com/tenstorrent/ttsim/releases/download/v1.8.4/libttsim_wh_x8.so -O ~/sim/libttsim_wh_x8.so || { echo "ERROR: failed to download libttsim_wh_x8.so"; exit 1; }
if [ -n "$TT_METAL_HOME" ]; then
cp $TT_METAL_HOME/tt_metal/soc_descriptors/wormhole_b0_80_arch.yaml ~/sim/soc_descriptor.yaml || { echo "ERROR: failed to copy SOC descriptor"; exit 1; }
cp $TT_METAL_HOME/tests/tt_metal/tt_fabric/custom_mock_cluster_descriptors/n300_cluster_desc.yaml ~/sim/n300_cluster_desc.yaml || { echo "WARNING: n300 cluster desc copy skipped (optional for N300 sim)"; }
else
echo "TT_METAL_HOME not set — SOC descriptor copy skipped"
fi
echo "ttsim v1.8.4 ready (wh + bh + wh_x2 + bh_x2 + wh_x8)"# Wormhole simulator
export TT_METAL_SIMULATOR=$HOME/sim/libttsim_wh.so
# Blackhole simulator (P-series)
# export TT_METAL_SIMULATOR=$HOME/sim/libttsim_bh.so
# Required for many simulator workflows
export TT_METAL_SLOW_DISPATCH_MODE=1
cd ~/tt-metal
More simulator workflows: Twenty-and-Ten Things You Can Do with ttsim
Part 3: Lab 1 Deep Dive — Single-Core Tiled Matmul
Goal of Lab 1
Build intuition for tiled data movement and kernel decomposition on a single core.
Run it
cd ~/tt-metal
./build/programming_examples/metal_example_matmul_single_core
What success looks like
Metalium vs Golden -- PCC = ...
Test Passed
Code walkthrough: exact host program path
Source file:
tt_metal/programming_examples/matmul/matmul_single_core/matmul_single_core.cpp
uint32_t Mt = M / TILE_HEIGHT;
uint32_t Kt = K / TILE_WIDTH;
uint32_t Nt = N / TILE_WIDTH;
auto reader_id = tt_metal::CreateKernel(
program,
"matmul/matmul_single_core/kernels/dataflow/reader_single_core_mm.cpp",
core,
tt_metal::DataMovementConfig{...});
auto writer_id = tt_metal::CreateKernel(
program,
"matmul/matmul_single_core/kernels/dataflow/writer_single_core_mm.cpp",
core,
tt_metal::DataMovementConfig{...});
tt_metal::CreateKernel(
program,
"matmul/matmul_single_core/kernels/compute/mm.cpp",
core,
tt_metal::ComputeConfig{.math_fidelity = math_fidelity, .compile_args = {Mt, Kt, Nt}});
tt_metal::SetRuntimeArgs(program, reader_id, core, {src0_addr, src1_addr, Mt, Kt, Nt});
tt_metal::SetRuntimeArgs(program, writer_id, core, {dst_addr, Mt, Kt, Nt});
Read this snippet line-by-line (iota-by-iota):
Mt/Kt/Ntconverts element dimensions into tile dimensions.- Reader/writer kernels are distinct dataflow kernels.
- Compute kernel receives tile-shape constants at compile time (
{Mt, Kt, Nt}). - Reader runtime args bind input addresses + tile extents for this launch.
- Writer runtime args bind output address + tile extents for this launch.
What to inspect while reading Lab 1 code
- Tile dimensions and how matrix dimensions map to tile counts
- Reader runtime args (which tile block is loaded)
- Compute loop structure over
Ktiles - Writer mapping from tile index -> output tensor location
Why this matters
Lab 1 is the correctness baseline. If you cannot reason about tile lifecycle here, Lab 2 and Lab 3 optimizations become opaque.
Checkpoint questions
- If
Kdoubles, which loop grows and why? - Why is output accumulation tied to the
Ksweep? - What changes if matrices are not multiples of tile size?
Part 4: Lab 2 Deep Dive — Multi-Core Work Distribution
Goal of Lab 2
Scale the same tiled algorithm across many cores by partitioning output tiles.
Run baseline multi-core
cd ~/tt-metal
./build/programming_examples/metal_example_matmul_multi_core
Run reuse-oriented variant
cd ~/tt-metal
./build/programming_examples/metal_example_matmul_multicore_reuse
Core idea
split_work_to_cores(...) (or equivalent decomposition logic) partitions output tiles so each core receives a contiguous share of the output space.
Code walkthrough: work split + per-core args
Source file:
tt_metal/programming_examples/matmul/matmul_multi_core/matmul_multi_core.cpp
auto [num_cores, all_cores, core_group_1, core_group_2, work_per_core1, work_per_core2] =
split_work_to_cores(core_grid, num_output_tiles_total);
for (const auto& [ranges, work_per_core] : work_groups) {
for (const auto& range : ranges.ranges()) {
for (const auto& core : range) {
tt_metal::SetRuntimeArgs(program, reader_id, core, {
src0_dram_buffer->address(), src1_dram_buffer->address(),
Mt, Kt, Nt, work_offset, work_per_core});
tt_metal::SetRuntimeArgs(program, writer_id, core, {
dst_dram_buffer->address(), work_per_core, work_offset});
tt_metal::SetRuntimeArgs(program, compute_kernel_id, core, {
work_per_core, Kt});
work_offset += work_per_core;
}
}
}
Read this snippet line-by-line (iota-by-iota):
split_work_to_coresforms two groups when work is uneven.- The host loops every core in both groups and writes core-specific runtime args.
work_offsetis the start tile index for that core's output region.work_per_coreis the tile count assigned to that core.- Reader + writer + compute all consume the same partition contract.
What to inspect in Lab 2
- How total output tiles are divided by core count
- How per-core runtime args encode tile ranges
- Whether compute is balanced (similar work per core)
- Where synchronization/barrier points appear between stages
Performance intuition
Lab 2 often improves throughput because:
- More cores compute output tiles concurrently
- Per-core work can stay local in L1 buffers
- The kernel pipeline overlaps data movement and compute better
But scaling is never free: load imbalance and DRAM contention can flatten speedup.
Checkpoint questions
- If one core gets extra tiles, what happens to total runtime?
- Why can more cores still bottleneck on the same DRAM channels?
- Which dimensions (
M,N,K) most influence partition quality?
Part 5: Lab 3 Deep Dive — Multicast Data Reuse
Goal of Lab 3
Reduce redundant memory traffic by sharing operand tiles across cores through NoC multicast rather than issuing repeated DRAM reads.
Conceptual flow
Producer core reads source tile once
-> multicasts tile to receiver core set
-> semaphore/handshake guarantees readiness
-> receivers consume tile for compute
Toolkit runnable multicast check
cd ~/tt-metal
if [ -x ./build/programming_examples/contributed/multicast ]; then
./build/programming_examples/contributed/multicast
else
echo "multicast example not found yet. Rebuild with ./build_metal.sh --build-programming-examples and follow Lab 3 source walkthrough."
fi
Code walkthrough: sender/receiver synchronization
Source file:
ttnn/examples/lab_multicast/lab_multicast.cpp
CoreCoord sender_core_device = prog_state.mesh_device->worker_core_from_logical_core(sender_core_logical);
uint32_t receivers_ready_semaphore = CreateSemaphore(prog_state.program, all_cores_logical, 0);
uint32_t tile_sent_semaphore = CreateSemaphore(prog_state.program, all_cores_logical, INVALID);
SetRuntimeArgs(
prog_state.program, mcast_sender_id, sender_core_logical,
{receiver_cores_device.start_coord.x, receiver_cores_device.start_coord.y,
receiver_cores_device.end_coord.x, receiver_cores_device.end_coord.y,
receivers_ready_semaphore, tile_sent_semaphore, src_mesh_buffer.address(), n_tiles, num_dests});
SetRuntimeArgs(
prog_state.program, mcast_receiver_id, receiver_cores_logical,
{sender_core_device.x, sender_core_device.y, receivers_ready_semaphore, tile_sent_semaphore, n_tiles});
Read this snippet line-by-line (iota-by-iota):
- Host converts logical → device coords before NoC-facing runtime args.
- Two semaphores coordinate readiness and "tile sent" signaling.
- Sender runtime args include the multicast destination rectangle.
- Receiver runtime args include sender device coordinates + semaphore IDs.
- Same semaphore IDs are shared across sender/receiver kernels for handshake correctness.
What to inspect in Lab 3
- Sender/receiver role assignment
- NoC destination group construction
- Semaphore increment/wait sequence correctness
- How many DRAM reads are eliminated versus unicast pattern
Why this matters for transformers
Attention and MLP blocks repeatedly reuse shared weights/activations. Multicast patterns directly target those reuse opportunities and reduce bandwidth pressure.
Checkpoint questions
- Why is synchronization mandatory before receivers consume multicast data?
- What failure mode appears if a receiver reads before producer completion?
- Which workloads benefit most from multicast (high fanout vs low fanout)?
Part 6: Guided RST-to-Interactive Study Plan
Use this sequence to get the "full thrust" of the original labs while staying hands-on:
- Read Lab 1 objective + run single-core binary
- Trace one output tile end-to-end (reader -> compute -> writer)
- Read Lab 2 partition section + run multi-core binaries
- Map output tile ranges to cores and confirm balance assumptions
- Read Lab 3 multicast section + run multicast example / source walk-through
- Sketch traffic difference: repeated DRAM reads vs one read + multicast fanout
If you're teaching this module, require students to answer all checkpoint questions before advancing.
Part 7: Troubleshooting and Validation Signals
Build or binary missing
cd ~/tt-metal
./build_metal.sh --build-programming-examples
Simulator runs are very slow
That is expected with TT_METAL_SLOW_DISPATCH_MODE=1. Use it for understanding correctness and dataflow, not throughput benchmarking.
Numerical mismatch or failed validation
- Re-run on small dimensions first
- Confirm environment variables are sane
- Verify build is fresh after any source edits
Repro checklist
TT_METAL_SIMULATORset correctly (or unset on hardware)TT_METAL_SLOW_DISPATCH_MODE=1for simulator- Programming examples freshly built
- Expected
Test Passed/ positive PCC signal observed
Upstream drift check (tt-metal main)
Use this test to detect drift between this lesson's tracked matmul upstream sources and tenstorrent/tt-metal main:
npm run check:matmul-lesson-drift
This reports:
- per-file SHA match/mismatch
- drift percentage (
changed_files / tracked_files) - non-zero exit code if any tracked source changed upstream
Part 8: Mastery Checklist
Before moving on, confirm you can explain:
- ✅ Why tiled matmul beats naive scalar formulations on this architecture
- ✅ How Lab 2 partitions output work across cores
- ✅ Why balancing core work matters for wall-clock runtime
- ✅ How Lab 3 multicast reduces redundant DRAM reads
- ✅ Where semaphore synchronization protects correctness in multicast flows
- ✅ How these patterns transfer directly to transformer kernels
What's Next?
- Revisit simulator experiments in ttsim Twenty-and-Ten and repeat the flow with controlled dimensions.
- Continue with Explore Metalium to inspect lower-level kernel/runtime details.