Tiling 技術與 Tiled 矩陣乘法 Kernel (Tiling & Tiled Matrix Multiplication)

重點總覽 (Overview)

項目 內容 關鍵數字
核心問題 Global memory 大但慢;naive matmul 的 compute-to-memory ratio 僅 0.25 OP/B,是 memory-bound A100 僅達 389 GFLOPS (峰值 2%)
策略 (Tiling) 將資料切成可放進 shared memory 的 tiles,threads 協同載入並重複使用 減少 global memory 流量
協同載入 (Collaborative Loading) 每個 thread 載入 1 個 M、1 個 N 元素到 shared memory,全 block 共用 每元素只從 global 讀 1 次
執行分階段 (Phases) dot product 拆成 Width / TILE_WIDTH 個 phase,每 phase 處理一對 tile strip-mining
同步 (Barriers) 載入後 / 使用後各一個 __syncthreads(),防止 RAW / WAR hazard 2 個 barrier
效益 global memory 存取量降為 1/TILE_WIDTH,ratio 提升至 TILE_WIDTH × 0.25 16×16 tile → 4 OP/B → 6220 GFLOPS
限制 Shared memory 容量小;Fig 5.9 假設方陣且 Width 是 TILE_WIDTH 倍數 邊界處理見 05-Memory-Architecture-And-Data-Locality/03-Boundary-Checks-and-Memory-Occupancy
Important

本篇接續 05-Memory-Architecture-And-Data-Locality/01-Memory-Access-Efficiency-and-CUDA-Memory-Types 的動機(compute-to-global-memory-access ratio、memory-bound)與記憶體類型,把 shared memory 實際用在演算法上。


Tiling 的核心思想 (Tiling for Reduced Memory Traffic)

記憶體的內在權衡 (The Tradeoff)

Memory 大小 速度 角色
Global memory 大 (GB) 慢 (off-chip DRAM) 存整個矩陣
Shared memory 小 (KB/block) 快 (on-chip scratchpad) 存一塊 tile

為何 matmul 適合 tiling — 存取重疊分析

block(0,0) 中,每個 M、N 元素都被該 block 的 threads 存取剛好兩次(2×2 block)。若讓 threads 協同,只從 global memory 載入一次即可。

block(0,0) 對 global memory 的存取 (Fig 5.6 概念)
時間 →
thread(0,0):  M0,0  M0,1  M0,2  M0,3   N0,0  N1,0  N2,0  N3,0
thread(0,1):  M0,0  M0,1  M0,2  M0,3   N0,1  N1,1  N2,1  N3,1   ← M row0 與 t(0,0) 完全重疊
thread(1,0):  M1,0  M1,1  M1,2  M1,3   N0,0  N1,0  N2,0  N3,0   ← N col0 與 t(0,0) 完全重疊
thread(1,1):  M1,0  M1,1  M1,2  M1,3   N0,1  N1,1  N2,1  N3,1
              └── 同 row 的 thread 共用 M ──┘ └── 同 col 的 thread 共用 N ──┘
潛在減量 ∝ block 維度

用 Width×Width 的 block,global memory 流量最多可降為原本的 1/Width。例如 16×16 block → 降為 1/16。

執行分階段 (Execution Phases) 與協同載入

將 dot product 拆成多個 phase,每 phase 全 block 協同把一對 tile 載入 shared 陣列 MdsNds,再用之計算部分內積。

Tiled matmul 的兩個 phase (Fig 5.8, block(0,0), TILE_WIDTH=2)

 Phase 1                              Phase 2
┌──────────────┐                    ┌──────────────┐
│ load M tile0 │  M0,0 M0,1         │ load M tile1 │  M0,2 M0,3
│ load N tile0 │  M1,0 M1,1         │ load N tile1 │  M1,2 M1,3
│  __sync      │                    │  __sync      │
│ Pvalue +=    │  Mds·Nds (k=0,1)   │ Pvalue +=    │  Mds·Nds (k=0,1)
│  __sync      │                    │  __sync      │
└──────────────┘                    └──────────────┘
        Mds / Nds 在各 phase 間「重複使用」(reuse) → 只需很小的 shared memory

Tiled 矩陣乘法 Kernel (A Tiled Matrix Multiplication Kernel)

完整 Kernel (Fig 5.9)

#define TILE_WIDTH 16

__global__ void matrixMulKernel(float* M, float* N, float* P, int Width) {
    // 04-05: shared memory tiles,scope = block(每 block 一份)
    __shared__ float Mds[TILE_WIDTH][TILE_WIDTH];
    __shared__ float Nds[TILE_WIDTH][TILE_WIDTH];

    // 07-08: 存進 register(縮短名稱)
    int bx = blockIdx.x;  int by = blockIdx.y;
    int tx = threadIdx.x; int ty = threadIdx.y;

    // 11-12: 此 thread 負責的 P 元素位置
    int Row = by * TILE_WIDTH + ty;
    int Col = bx * TILE_WIDTH + tx;

    // 16: strip-mining 的外層迴圈,逐 phase 計算
    float Pvalue = 0;
    for (int ph = 0; ph < Width/TILE_WIDTH; ++ph) {
        // 19-20: 協同載入一對 tile 到 shared memory
        Mds[ty][tx] = M[Row*Width + ph*TILE_WIDTH + tx];
        Nds[ty][tx] = N[(ph*TILE_WIDTH + ty)*Width + Col];
        __syncthreads();                 // 21: RAW barrier(載入完才能讀)

        // 23: 內層迴圈,對 tile 做部分內積
        for (int k = 0; k < TILE_WIDTH; ++k) {
            Pvalue += Mds[ty][k] * Nds[k][tx];
        }
        __syncthreads();                 // 26: WAR barrier(讀完才能覆寫)
    }
    P[Row*Width + Col] = Pvalue;         // 29: 寫回結果
}

Index 計算 (Fig 5.10)

公式 說明
Col (P 的 x) bx*TILE_WIDTH + tx 前面 bx 個 block 各蓋 TILE_WIDTH 欄,再加 tx
Row (P 的 y) by*TILE_WIDTH + ty 同理(垂直方向)
載入的 M M[Row*Width + ph*TILE_WIDTH + tx] 列固定為 Row,欄起點 ph*TILE_WIDTH 偏移 tx
載入的 N N[(ph*TILE_WIDTH + ty)*Width + Col] 欄固定為 Col,列起點 ph*TILE_WIDTH 偏移 ty
Thread(ty,tx) 在每 phase 的角色:
   M  ────────────────►  (沿 row Row 由左往右,每 phase 取一段 tile)
   ┌───┬───┐
   │tile│tile│ ...           P 的 (Row,Col) = Σ_phase Σ_k Mds[ty][k]*Nds[k][tx]
   └───┴───┘
   N  │  每 phase 取一段 tile(沿 col Col 由上往下)
      ▼
TILE_WIDTH² 個 thread 協同 → 載入 TILE_WIDTH² 個 M + TILE_WIDTH² 個 N
(每 thread 恰好載入 1 M + 1 N,且 index 各不重複)

Strip-mining 與兩個 Barrier

Strip-mining:把一條長迴圈 (line 16-28) 切成「外層 phase 迴圈 + 內層幾次連續迭代」。在內層前後加 barrier,強迫同 block 的 threads 每 phase 聚焦在同一段輸入資料 → 這正是 tiling 所需的 phase 機制。

Barrier 位置 防止的 hazard 別名 為何要等
__syncthreads() #1 載入 tile 之後 (line 21) Read-after-write (RAW) true dependence(真相依) reader 真的需要別的 thread 寫入的資料,非等不可
__syncthreads() #2 用完 tile 之後 (line 26) Write-after-read (WAR) false dependence(假相依) writer 不需 reader 的資料;只因重用同一記憶體位置才相依(換位置即可消除)
漏掉任一個 __syncthreads() 都會造成 race condition

效益量化

指標 Naive (Ch.3) Tiled (16×16)
Compute-to-global-memory ratio 0.25 OP/B 4 OP/B (= 16 × 0.25)
每元素被 global 讀取次數 N N / TILE_WIDTH
A100 可達吞吐量 389 GFLOPS 6220 GFLOPS (1555 GB/s × 4)

核心公式

Tiled 版 6220 GFLOPS 仍只有 A100 峰值 (19,500 GFLOPS) 的 32%。要更快需進一步優化(thread coarsening、register tiling 等),或直接用 cuBLAS / CUTLASS 等高度優化函式庫。

CPU vs GPU tiling

CPU (blocking) GPU (tiling)
重用資料留在晶片上的方式 依賴 cache(隱式) shared memory(顯式)
原因 一核心通常只跑 1-2 threads,cache 較可靠 SM 同時跑大量 threads 競爭 cache slot,故需顯式 shared memory

簡化假設 (本 Kernel 的限制)

Fig 5.9 做了兩個簡化假設:

  1. Width 是 block 寬度的整數倍(否則無法正確處理任意寬度)。
  2. 矩陣為方陣 (square)
    一般 (rectangular) 矩陣與邊界檢查見 05-Memory-Architecture-And-Data-Locality/03-Boundary-Checks-and-Memory-Occupancy


考試/面試重點 (Exam / Test Patterns)

情境 / 關鍵字 答案 / 技巧
Tiling 把 global memory 存取減少幾倍? TILE_WIDTH 倍(T×T tile → 每元素讀取次數從 N 降為 N/T)
16×16 tile 的 compute-to-memory ratio? 0.25 × 16 = 4 OP/B;32×32 → 減量 32 倍
phase 數量怎麼算? Width / TILE_WIDTH
兩個 __syncthreads() 各防什麼? #1 = RAW (true dependence);#2 = WAR (false dependence)
忘記 __syncthreads() 會怎樣? race condition:讀到未載入資料 (#1) 或 tile 被提早覆寫 (#2)
為何 false dependence 是「假」相依? writer 不需 reader 的資料,僅因重用同一 shared 位置;換位置即無相依
strip-mining 是什麼? 把長迴圈拆成 outer phase 迴圈 + inner 連續迭代,加 barrier 形成 tiling 的 phase
每個 thread 載入幾個元素? 1 個 M + 1 個 N;TILE_WIDTH² threads 共載 TILE_WIDTH² 對
Mds/Nds scope 與 lifetime? scope = block,lifetime = kernel;每 block 一份,跨 phase 重用
為何 GPU 用 shared memory 而非靠 cache? SM 同時跑大量 threads 競爭 cache,cache 不可靠 → 需顯式 shared memory
Tiled 仍非峰值的原因? 仍 memory-bound 殘留;需 coarsening/register tiling 或 cuBLAS/CUTLASS
Col / Row 公式? Col = bx*TILE_WIDTH+txRow = by*TILE_WIDTH+ty