Tile - Memory-Efficient Tiled Processing
Overview
Building on the elementwise pattern, this puzzle introduces tiled processing - a fundamental technique for optimizing memory access patterns and cache utilization on GPUs. Instead of each thread processing individual SIMD vectors across the entire array, tiling organizes data into smaller, manageable chunks that fit better in cache memory.
Youâve already seen tiling in action with Puzzle 16âs tiled matrix multiplication, where we used tiles to process large matrices efficiently. Here, we apply the same tiling principles to vector operations, demonstrating how this technique scales from 2D matrices to 1D arrays.
Implement the same vector addition operation using Mojoâs tiled approach. Each GPU thread will process an entire tile of data sequentially, demonstrating how memory locality can improve performance for certain workloads.
Key insight: Tiling trades parallel breadth for memory locality - fewer threads each doing more work with better cache utilization.
Key concepts
In this puzzle, youâll learn:
- Tile-based memory organization for cache optimization
- Sequential processing within tiles
- Memory locality principles and cache-friendly access patterns
- Thread-to-tile mapping vs thread-to-element mapping
- Performance trade-offs between parallelism and memory efficiency
The same mathematical operation as elementwise: \[\Large \text{output}[i] = a[i] + b[i]\]
But with a completely different execution strategy optimized for memory hierarchy.
Configuration
- Vector size:
SIZE = 1024 - Tile size:
TILE_SIZE = 32 - Data type:
DType.float32 - SIMD width: GPU-dependent
- Layout:
row_major[SIZE]()(1D row-major)
Code to complete
comptime TILE_SIZE = 32
def tiled_elementwise_add[
LayoutT: TensorLayout,
dtype: DType,
simd_width: Int,
rank: Int,
size: Int,
tile_size: Int,
](
output: TileTensor[mut=True, dtype, LayoutT, MutAnyOrigin],
a: TileTensor[mut=False, dtype, LayoutT, MutAnyOrigin],
b: TileTensor[mut=False, dtype, LayoutT, MutAnyOrigin],
ctx: DeviceContext,
) raises:
@parameter
@always_inline
def process_tiles[
simd_width: Int, alignment: Int = align_of[dtype]()
](indices: Coord) capturing -> None:
var tile_id = Int(indices[0].value())
var output_tile = output.tile[tile_size](tile_id).to_layout_tensor()
var a_tile = a.tile[tile_size](tile_id).to_layout_tensor()
var b_tile = b.tile[tile_size](tile_id).to_layout_tensor()
# FILL IN (6 lines at most)
var num_tiles = (size + tile_size - 1) // tile_size
elementwise[process_tiles, 1, target="gpu"](num_tiles, ctx)
View full file: problems/p23/p23.mojo
Tips
1. Understanding tile organization
The tiled approach divides your data into fixed-size chunks:
var num_tiles = (size + tile_size - 1) // tile_size # Ceiling division
For a 1024-element vector with TILE_SIZE=32: 1024 Ă· 32 = 32 tiles exactly.
2. Tile extraction pattern
Check out the
TileTensor .tile documentation.
var tile_id = Int(indices[0].value()) # Each thread gets one tile to process
var output_tile = output.tile[tile_size](tile_id).to_layout_tensor()
var a_tile = a.tile[tile_size](tile_id).to_layout_tensor()
var b_tile = b.tile[tile_size](tile_id).to_layout_tensor()
The tile[size](id) method creates a view of size consecutive elements
starting at id Ă size.
3. Sequential processing within tiles
Unlike elementwise, you process the tile sequentially:
comptime for i in range(tile_size):
# Process element i within the current tile
This comptime for loop unrolls at compile-time for optimal performance.
4. Load and store within tile elements
var a_vec = a_tile.aligned_load[width=simd_width](Index(i)) # Load from position i in tile
var b_vec = b_tile.aligned_load[width=simd_width](Index(i)) # Load from position i in tile
var result = a_vec + b_vec # Addition at width simd_width
output_tile.store[simd_width](Index(i), result) # Store to position i in tile
Here simd_width is the inner functionâs own parameter, which the launch below
binds to 1âsee the solution for why.
5. Thread configuration difference
elementwise[process_tiles, 1, target="gpu"](num_tiles, ctx)
Note the 1 instead of SIMD_WIDTH - each thread processes one entire tile
sequentially.
6. Memory access pattern insight
Each thread accesses a contiguous block of memory (the tile), then moves to the next tile. This creates excellent spatial locality within each threadâs execution.
7. Key debugging insight
With tiling, youâll see fewer thread launches but each does more work:
- Elementwise: 256 invocations (for SIMD_WIDTH=4), each covering 4 elements
- Tiled: 32 invocations, each walking 32 elements one at a time
Running the code
To test your solution, run the following command in your terminal:
pixi run p23 --tiled
pixi run -e amd p23 --tiled
pixi run -e apple p23 --tiled
uv run poe p23 --tiled
Your output will look like this when not yet solved:
SIZE: 1024
simd_width: 4
tile size: 32
out: HostBuffer([0.0, 0.0, 0.0, ..., 0.0, 0.0, 0.0])
expected: HostBuffer([1.0, 5.0, 9.0, ..., 4085.0, 4089.0, 4093.0])
Solution
comptime TILE_SIZE = 32
def tiled_elementwise_add[
LayoutT: TensorLayout,
dtype: DType,
simd_width: Int,
rank: Int,
size: Int,
tile_size: Int,
](
output: TileTensor[mut=True, dtype, LayoutT, MutAnyOrigin],
a: TileTensor[mut=False, dtype, LayoutT, MutAnyOrigin],
b: TileTensor[mut=False, dtype, LayoutT, MutAnyOrigin],
ctx: DeviceContext,
) raises:
@parameter
@always_inline
def process_tiles[
simd_width: Int, alignment: Int = align_of[dtype]()
](indices: Coord) capturing -> None:
var tile_id = Int(indices[0].value())
var output_tile = output.tile[tile_size](tile_id).to_layout_tensor()
var a_tile = a.tile[tile_size](tile_id).to_layout_tensor()
var b_tile = b.tile[tile_size](tile_id).to_layout_tensor()
comptime for i in range(tile_size):
var a_vec = a_tile.aligned_load[width=simd_width](Index(i))
var b_vec = b_tile.aligned_load[width=simd_width](Index(i))
var ret = a_vec + b_vec
output_tile.store[simd_width](Index(i), ret)
var num_tiles = (size + tile_size - 1) // tile_size
elementwise[process_tiles, 1, target="gpu"](num_tiles, ctx)
The tiled processing pattern demonstrates advanced memory optimization techniques for GPU programming:
1. Tiling philosophy and memory hierarchy
Tiling represents a fundamental shift in how we think about parallel processing:
Elementwise approach:
- Wide parallelism: Many threads, each doing minimal work
- Small per-thread footprint: Each thread touches only
SIMD_WIDTHelements - Coalesced access: Consecutive threads cover one contiguous run of memory
Tiled approach:
- Deep parallelism: Fewer threads, each doing substantial work
- Localized memory access: Each thread works on contiguous data
- Per-thread locality: Spatial locality within a thread, traded against warp-level coalescing (see below)
2. Tile organization and indexing
var tile_id = Int(indices[0].value())
var output_tile = output.tile[tile_size](tile_id).to_layout_tensor()
var a_tile = a.tile[tile_size](tile_id).to_layout_tensor()
var b_tile = b.tile[tile_size](tile_id).to_layout_tensor()
Tile mapping visualization (TILE_SIZE=32):
Original array: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ..., 1023]
Tile 0 (thread 0): [0, 1, 2, ..., 31] â Elements 0-31
Tile 1 (thread 1): [32, 33, 34, ..., 63] â Elements 32-63
Tile 2 (thread 2): [64, 65, 66, ..., 95] â Elements 64-95
...
Tile 31 (thread 31): [992, 993, ..., 1023] â Elements 992-1023
Key insights:
- Each
tile[size](id)creates a view into the original tensor - Views are zero-copy - no data movement, just pointer arithmetic
- Tile boundaries are always aligned to
tile_sizeboundaries
3. Sequential processing deep dive
comptime for i in range(tile_size):
var a_vec = a_tile.aligned_load[width=simd_width](Index(i))
var b_vec = b_tile.aligned_load[width=simd_width](Index(i))
var ret = a_vec + b_vec
output_tile.store[simd_width](Index(i), ret)
Why sequential processing?
- Cache optimization: Consecutive memory accesses maximize cache hit rates
- Compiler optimization:
comptime forloops unroll completely at compile-time - Predictable addresses: Each threadâs addresses advance by one element per step, which the compiler folds into the unrolled loop
- Reduced coordination: One thread owns a whole tile, so there is nothing to synchronize
Execution pattern within one tile (TILE_SIZE=32):
Thread processes tile sequentially, one element per iteration:
Step 0: Load/store element [0]
Step 1: Load/store element [1]
Step 2: Load/store element [2]
...
Step 31: Load/store element [31]
Total: 32 scalar operations per thread (comptime for i in range(tile_size))
The width here is 1, not SIMD_WIDTH. The inner process_tiles declares its
own simd_width parameter, which shadows the enclosing functionâs, and
elementwise[process_tiles, 1, target="gpu"](num_tiles, ctx) instantiates it
with 1. So aligned_load[width=simd_width] loads a single element and the loop
walks the tile one element at a time. The two vectorized kernels later in this
puzzle avoid the shadowing by naming their inner parameter
num_threads_per_tile, which is why they really do load SIMD_WIDTH elements
at a time.
4. Memory access pattern analysis
Cache behavior comparison:
Elementwise pattern:
Thread 0: accesses positions [0:4] â One SIMD chunk
Thread 1: accesses positions [4:8] â Next SIMD chunk
...
Result: Each thread touches one small chunk, and the chunks spread across
the entire array
Tiled pattern:
Thread 0: accesses positions [0:32] sequentially â Contiguous 32-element block
Thread 1: accesses positions [32:64] sequentially â Next contiguous 32-element block
...
Result: Perfect spatial locality within each thread
Cache efficiency implications:
- L1 cache: Small tiles often fit better in L1 cache, reducing cache misses
- TLB efficiency: Fewer translation lookaside buffer misses
- Coalescing caveat: GPUs have no hardware prefetcher, and coalescing is a
per-warp property, not a per-thread one. Because each thread walks its own
tile, on any single iteration the lanes of a warp sit
tile_sizeelements apart - this pattern buys per-thread sequentiality at the cost of warp-level coalescing
5. Thread configuration strategy
elementwise[process_tiles, 1, target="gpu"](num_tiles, ctx)
Why 1 instead of SIMD_WIDTH?
- Thread count: Launch exactly
num_tilesthreads, notnum_tiles Ă SIMD_WIDTH - Work distribution: Each thread handles one complete tile
- Load balancing: More work per thread, fewer threads total
- Memory locality: Each threadâs work is spatially localized
Performance trade-offs:
- Fewer logical threads: May not fully utilize all GPU cores at low occupancy
- More work per thread: Better cache utilization and reduced coordination overhead
- Sequential access: Each threadâs own stream of addresses is contiguous, though the warpâs are not
- Reduced overhead: Less thread launch and coordination overhead
Important note: âFewer threadsâ refers to the logical programming model. The GPU scheduler can still achieve high hardware utilization by running multiple warps and efficiently switching between them during memory stalls.
6. Performance characteristics
When tiling helps:
- Memory-bound operations: When memory bandwidth is the bottleneck
- Cache-sensitive workloads: Operations that benefit from data reuse
- Complex operations: When compute per element is higher
- Limited parallelism: When you have fewer threads than GPU cores
When tiling hurts:
- Highly parallel workloads: When you need maximum thread utilization
- Simple operations: When memory access dominates over computation
- Irregular access patterns: When tiling doesnât improve locality
For our simple addition example (TILE_SIZE=32):
- Thread count: 32 threads instead of 256 (8Ă fewer)
- Work per thread: 32 elements instead of 4 (8Ă more)
- Memory pattern: Sequential vs strided access
- Cache utilization: Much better spatial locality
7. Advanced tiling considerations
Tile size selection:
- Too small: Poor cache utilization, more overhead
- Too large: May not fit in cache, reduced parallelism
- Sweet spot: Usually 16-64 elements for L1 cache optimization
- Our choice: 32 elements balances cache usage with parallelism
Hardware considerations:
- Cache size: Tiles should fit in L1 cache when possible
- Memory bandwidth: Consider memory controller width
- Core count: Ensure enough tiles to utilize all cores
- SIMD width: Tile size should be multiple of SIMD width
Comparison summary:
Elementwise: High parallelism, scattered memory access
Tiled: Moderate parallelism, localized memory access
The choice between elementwise and tiled patterns depends on your specific workload characteristics, data access patterns, and target hardware capabilities.
Next steps
Now that you understand both elementwise and tiled patterns:
- Vectorization: Fine-grained control over SIMD operations
- đ§ GPU Threading vs SIMD: Understanding the execution hierarchy
- đ Benchmarking: Performance analysis and optimization
đĄ Key takeaway: Tiling demonstrates how memory access patterns often matter more than raw computational throughput. The best GPU code balances parallelism with memory hierarchy optimization.