Mojo Tiles and Tiling

Tiling (also called blocking) is a technique that divides a large dataset into smaller chunks called tiles, processing each tile completely before moving to the next. Tiles are sized to fit inside the CPU cache, which is many times faster than main memory. Tiling transforms a cache-unfriendly algorithm into a cache-friendly one.

Why Cache Size Matters

  Memory hierarchy — speed and size:

  CPU Registers  │ ~0.3 ns  │ ~1 KB     ← fastest
  L1 Cache       │ ~1 ns    │ ~64 KB
  L2 Cache       │ ~4 ns    │ ~512 KB
  L3 Cache       │ ~12 ns   │ ~8 MB
  RAM            │ ~60 ns   │ ~16 GB
  SSD            │ ~100 µs  │ ~1 TB     ← slowest

  RAM is 200× slower than L1 cache.
  Keeping hot data in cache = massive speedup.

Cache Misses Without Tiling

Problem: Naive matrix multiply accesses memory column-by-column
for a 1000×1000 matrix stored row-by-row.

  Row-major storage:
  [ row0_col0, row0_col1, ... row0_col999,
    row1_col0, row1_col1, ... row1_col999,
    ...
    row999_col0, ... ]

  Accessing column j requires jumping 1000 elements per step.
  Each jump likely evicts the previous cache line → cache miss storm.
  Performance: slow, dominated by memory latency.

Tiling Concept

Without tiling (row × full column):
  Process: row 0 × all 1000 cols → big memory jumps

With tiling (row-tile × col-tile):
  Process: rows 0–31 × cols 0–31  → fits in L1 cache
  Process: rows 0–31 × cols 32–63 → fits in L1 cache
  ...

  Each tile is small enough to stay in cache during computation.
  No evictions = no cache misses = full cache speed.

Using tile in Mojo

Mojo's algorithm module provides a tile function that handles the tiling loop structure for you.

from algorithm import tile
from memory import UnsafePointer

fn process_row(data: UnsafePointer[Float32], row_start: Int, tile_size: Int):
    for i in range(tile_size):
        data[row_start + i] = data[row_start + i] * 2.0

fn main():
    let n = 64
    var arr = UnsafePointer[Float32].alloc(n)
    for i in range(n):
        arr.init_pointee_copy(Float32(i + 1))

    alias tile_size = 16

    @parameter
    fn process_tile[size: Int](start: Int):
        for i in range(size):
            arr[start + i] *= 2.0

    tile[process_tile, tile_size](n)

    print(arr[0])   # 2.0   (1 × 2)
    print(arr[15])  # 32.0  (16 × 2)

    for i in range(n):
        (arr + i).destroy_pointee()
    arr.free()

Tiled Matrix Multiply

Matrix multiplication is the canonical example where tiling delivers enormous speedups. The naive version thrashes the cache; the tiled version stays cache-warm.

Naive matrix multiply (pseudocode):
  for i in range(N):
      for j in range(N):
          for k in range(N):
              C[i,j] += A[i,k] * B[k,j]   ← B accessed column-by-column

Tiled matrix multiply (pseudocode):
  for i in range(0, N, TILE):
      for j in range(0, N, TILE):
          for k in range(0, N, TILE):
              # Process tile [i:i+TILE, j:j+TILE]
              for ii in range(i, min(i+TILE, N)):
                  for jj in range(j, min(j+TILE, N)):
                      for kk in range(k, min(k+TILE, N)):
                          C[ii,jj] += A[ii,kk] * B[kk,jj]
Tiled access pattern visualization (4×4 matrix, tile=2):

  Step 1: Process tile A[0:2, 0:2] × B[0:2, 0:2] → C[0:2, 0:2]
  ┌──┬──┬──┬──┐   ┌──┬──┬──┬──┐   ┌──┬──┬──┬──┐
  │██│██│  │  │ × │██│██│  │  │ → │██│██│  │  │
  │██│██│  │  │   │██│██│  │  │   │██│██│  │  │
  │  │  │  │  │   │  │  │  │  │   │  │  │  │  │
  │  │  │  │  │   │  │  │  │  │   │  │  │  │  │
  └──┴──┴──┴──┘   └──┴──┴──┴──┘   └──┴──┴──┴──┘
  A                B               C  (partial result)

  Each ██ tile fits entirely in L1 cache during the inner loop.

Choosing Tile Size

Tile size selection:
  - L1 cache is typically 32–64 KB
  - Float32 = 4 bytes
  - Tile of 32×32 Float32 = 32 × 32 × 4 = 4 KB → fits in L1
  - Tile of 64×64 Float32 = 64 × 64 × 4 = 16 KB → fits in L1

  For matrix multiply (3 matrices in cache simultaneously):
  3 × 32×32 × 4 = 12 KB → safe for a 32 KB L1 cache
  alias TILE = 32   ← typical good starting point

Tiling Combined with Vectorization

For maximum performance, tile the outer loops and vectorize
the innermost loop:

  for each tile:            ← tiling (cache-friendly)
      for each row in tile:
          SIMD operation on tile row  ← vectorization (SIMD)
          parallelize across tiles   ← parallelism (multi-core)

  This three-level strategy underpins how high-performance
  libraries like BLAS achieve near-theoretical peak performance.

Key Takeaways

Tiling divides large datasets into cache-sized chunks to minimize slow main memory accesses. Tiles sized to fit in L1 or L2 cache let the CPU operate at cache speed throughout the inner loop. The classic application is matrix multiplication, where column-major access patterns cause cache thrashing without tiling. Mojo's tile function handles the tiling loop structure automatically. For peak performance, combine tiling with vectorization and parallelization — this three-level strategy mirrors what professional HPC libraries do internally.

Leave a Comment

Your email address will not be published. Required fields are marked *