Bộ nhớ/Viết code cache-friendly — locality thành kỹ năng
10/13
Bài 10 / 13~17 phútCache & memory hierarchyMiễn phí lượt xem

Viết code cache-friendly — locality thành kỹ năng

Bốn kỹ thuật tăng locality: duyệt theo thứ tự bộ nhớ, loop blocking, gói dữ liệu nóng, tránh pointer chasing — cùng big-O nhưng nhanh hơn nhiều nhờ ít cache miss.

TL;DR: Hai bài trước cho bạn cơ chế (cache line, locality); bài này biến nó thành kỹ năng có hệ thống. Bốn kỹ thuật cốt lõi: (1) duyệt theo thứ tự bộ nhớ (vòng lặp trong cùng đi theo chiều dữ liệu liền nhau), (2) loop blocking/tiling (chia bài toán thành khối vừa cache để tái dùng dữ liệu trước khi nó bị đẩy ra), (3) gói dữ liệu nóng lại gần nhau (để mỗi cache line toàn byte cần), và (4) tránh pointer chasing (ưu tiên cấu trúc liền mạch hơn con trỏ rải rác). Tất cả đều giữ nguyên thuật toán và big-O — chỉ đổi cách dữ liệu được chạm tới — nhưng có thể tăng tốc nhiều lần trên dữ liệu lớn. Đây là kỹ năng "mechanical sympathy" cốt lõi của course.

Bạn đã biết vì sao cache thưởng locality. Câu hỏi giờ là làm thế nào để viết code có locality một cách chủ động — không phải dò dẫm, mà theo vài kỹ thuật lặp lại được. Điểm chung của cả bốn: chúng không đổi bạn tính gì, chỉ đổi bạn chạm bộ nhớ theo thứ tự nào.

Bài này trình bày bốn kỹ thuật kèm code trước/sau, và quy tắc khi nào áp dụng.

1. Kỹ thuật 1 — duyệt theo thứ tự bộ nhớ

Quy tắc nền tảng: vòng lặp trong cùng nên chạy theo chiều dữ liệu nằm liền nhau. Đây là tối ưu rẻ nhất, đáng làm gần như luôn.

Với ma trận row-major, đặt chỉ số hàng ở vòng ngoài, cột ở vòng trong (đã thấy ở bài 02). Nhưng nguyên lý rộng hơn: bất cứ khi nào lồng vòng lặp trên dữ liệu nhiều chiều, sắp thứ tự để bước nhỏ nhất trong bộ nhớ nằm ở vòng trong cùng.

// Cong hai ma tran -- thu tu vong lap dung chieu row-major
for (int i = 0; i < N; i++)
    for (int j = 0; j < N; j++)        // j chay theo chieu lien nhau
        c[i][j] = a[i][j] + b[i][j];

Phép nhân ma trận ngây thơ là ví dụ kinh điển: thứ tự vòng lặp i-j-k truy cập b[k][j] theo cột (xấu); đổi sang i-k-j khiến cả ba ma trận được duyệt thân thiện hơn:

// Ngay tho i-j-k: b[k][j] duyet theo cot -> nhieu miss
for (int i = 0; i < N; i++)
  for (int j = 0; j < N; j++)
    for (int k = 0; k < N; k++)
      c[i][j] += a[i][k] * b[k][j];

// Doi sang i-k-j: a[i][k] hang so trong vong j (temporal),
// b[k][j] va c[i][j] duyet tuan tu theo j (spatial) -> it miss
for (int i = 0; i < N; i++)
  for (int k = 0; k < N; k++)
    for (int j = 0; j < N; j++)
      c[i][j] += a[i][k] * b[k][j];

Chỉ đổi thứ tự hai vòng lặp — cùng kết quả, cùng số phép nhân — nhưng bản i-k-j thường nhanh hơn nhiều trên ma trận lớn.

2. Kỹ thuật 2 — loop blocking (tiling)

Khi một thuật toán duyệt qua dữ liệu lớn hơn cache nhiều lần (như nhân ma trận), dữ liệu nạp vào cache bị đẩy ra trước khi được dùng lại. Blocking chia bài toán thành các khối (tile) đủ nhỏ để vừa cache, xử lý trọn một khối trước khi sang khối khác — nhờ đó dữ liệu trong khối được tái dùng tối đa khi còn nóng.

// Nhan ma tran co blocking -- xu ly tung khoi B x B vua cache
int B = 64; // tile size, chinh theo cache
for (int ii = 0; ii < N; ii += B)
  for (int kk = 0; kk < N; kk += B)
    for (int jj = 0; jj < N; jj += B)
      // xu ly mot tile B x B -- du lieu trong tile o lai cache
      for (int i = ii; i < min(ii + B, N); i++)
        for (int k = kk; k < min(kk + B, N); k++)
          for (int j = jj; j < min(jj + B, N); j++)
            c[i][j] += a[i][k] * b[k][j];

Ý tưởng: thay vì quét cả một hàng dài rồi quay lại (dữ liệu đã bị đẩy khỏi cache), ta gói công việc thành ô vuông nhỏ. Mỗi ô vừa trong cache nên các phần tử của nó được tái dùng nhiều lần mà không phải nạp lại. Blocking là kỹ thuật chuẩn trong thư viện đại số tuyến tính hiệu năng cao (BLAS).

flowchart LR
  subgraph noblock["Khong blocking"]
    direction TB
    N1["Quet ca hang dai"] --> N2["Du lieu bi day khoi cache"] --> N3["Phai nap lai"]
  end
  subgraph block["Co blocking"]
    direction TB
    B1["Xu ly tile vua cache"] --> B2["Tai dung tron tile"] --> B3["Sang tile ke"]
  end
Blocking không đổi big-O, đổi hệ số cache miss

Nhân ma trận vẫn là O(n³) dù có blocking hay không — số phép nhân không đổi. Cái đổi là số lần đi RAM: bản ngây thơ nạp lại dữ liệu nhiều lần (mỗi phần tử của B bị đọc lại N lần từ RAM), bản blocking giữ tile trong cache nên đọc RAM ít hơn nhiều bậc. Đây là minh hoạ rõ nhất rằng hiệu năng thực tế = số phép tính × chi phí trung bình mỗi phép, và blocking tấn công thừa số thứ hai.

3. Kỹ thuật 3 — gói dữ liệu nóng lại gần nhau

Mỗi cache line là 64 byte. Nếu dữ liệu bạn thực sự dùng trong vòng lặp nóng nằm rải trong các object lớn, mỗi line kéo về nhiều byte vô ích. Hai cách gói:

Tách field nóng khỏi field nguội. Nếu một vòng lặp chỉ cần idscore của mỗi Player (object 200 byte), tách hai field đó ra một mảng riêng — mỗi line giờ chứa toàn id/score dùng được.

Chọn kiểu nhỏ hơn khi đủ. int (4 byte) thay vì long (8 byte) khi giá trị nhỏ → gấp đôi số phần tử mỗi cache line, gấp đôi mật độ dữ liệu hữu ích. short, byte, hoặc bảng index thay vì con trỏ 8 byte cũng theo nguyên lý này.

// SAI: vong lap chi dung 2 field nhung moi line keo ca object lon
class Player { long id; int score; String name; byte[] avatar; /* ... */ }
Player[] players;
for (Player p : players) leaderboard.add(p.id, p.score); // phi cache

// TOT: tach 2 field nong ra mang rieng -> moi line toan du lieu can
long[] ids;     // lien nhau
int[]  scores;  // lien nhau
for (int i = 0; i < n; i++) leaderboard.add(ids[i], scores[i]);

Đây chính là tiền đề cho AoS vs SoA ở bài 04 — tách field thành mảng riêng là dạng cơ bản của SoA.

4. Kỹ thuật 4 — tránh pointer chasing

"Pointer chasing" là duyệt dữ liệu bằng cách đi theo chuỗi con trỏ tới các địa chỉ rải rác: linked list, cây con trỏ, hash map chaining, đồ thị lưu bằng danh sách kề con trỏ. Mỗi bước là một cache miss khó tránh và vô hiệu hoá prefetcher (bài 02).

Linked list (rai rac tren heap): moi buoc nhay toi dia chi bat ky -> moi node 1 miss
  [node@0x100]--next-->[node@0x9C0]--next-->[node@0x2F0]--next-->[node@0x740]
       miss               miss                miss                miss

Mang (lien mach): phan tu lien nhau -> ~1 miss / 16 phan tu, prefetcher giup
  [a0 a1 a2 a3 ... a15][a16 ...]
   ^miss nap ca line    ^miss

Khi duyệt là thao tác nóng, ưu tiên cấu trúc liền mạch:

  • Mảng / ArrayList thay vì LinkedList.
  • Cây lưu trong mảng (như binary heap lưu mảng) thay vì cây con trỏ, khi cấu trúc cho phép.
  • Hash map open-addressing (lưu entry liền trong mảng) thay vì chaining khi locality quan trọng.
  • Đồ thị dạng CSR (compressed sparse row — mảng phẳng) thay vì danh sách kề con trỏ cho thuật toán duyệt nặng.
// CHAM: pointer chasing, moi node mot cache miss
for (Node n = head; n != null; n = n.next) sum += n.value;

// NHANH: mang lien mach, prefetcher giup, ~1 miss / 16 phan tu
for (int i = 0; i < arr.length; i++) sum += arr[i];
Locality vs các tiêu chí khác — luôn cân nhắc

Cấu trúc liền mạch không phải lúc nào cũng thắng: LinkedList chèn/xoá giữa danh sách là O(1) nếu đã có con trỏ tới vị trí, còn ArrayList phải dịch phần tử. Hash chaining đơn giản và chịu được tải cao hơn open-addressing. Quy tắc: chọn cấu trúc theo thao tác chính. Nếu thao tác chính là duyệt trên dữ liệu lớn ở hot path → ưu tiên liền mạch. Nếu là chèn/xoá ngẫu nhiên nhiều → cân nhắc khác. Và luôn đo: với n nhỏ vừa cache, khác biệt locality thường không đáng kể.

5. Áp dụng vào code của bạn

Gộp bốn kỹ thuật thành một quy trình ra quyết định:

  1. Đo trước. Dùng profiler hoặc perf stat -e cache-misses,cache-references để xác nhận cache miss thực sự là bottleneck. Nếu hot path bị chặn bởi I/O hay tính toán thuần, tối ưu cache là sai chỗ.
  2. Rẻ trước, đắt sau. Đổi thứ tự vòng lặp (kỹ thuật 1) gần như miễn phí về công sức — làm trước. Blocking và viết lại cấu trúc dữ liệu (2, 4) tốn hơn — chỉ khi cần.
  3. Dữ liệu lớn hơn cache mới đáng. Mọi kỹ thuật này chỉ tạo khác biệt khi dữ liệu vượt cache (vượt vài MB). Với dữ liệu nhỏ, mọi thứ đã trong cache — đừng phức tạp hoá code.
  4. Đo lại sau. Speedup phải chứng minh bằng số trước/sau, kèm số cache-miss giảm. Đây đúng quy trình measure → optimize → re-measure mà capstone sẽ thực hành.

6. Liên hệ các bài khác

  • Bài 02 — Cache line và locality: cơ chế nền tảng — bốn kỹ thuật ở đây đều là cách khai thác có hệ thống spatial và temporal locality.
  • Bài 04 — AoS vs SoA: kỹ thuật 3 (gói field nóng) được hệ thống hoá thành một quyết định bố cục dữ liệu lớn.
  • Course 1 — Branch prediction: locality và branch prediction là hai trụ cột mechanical sympathy; capstone kết hợp cả hai.

7. Tóm tắt

  • Bốn kỹ thuật cache-friendly, đều giữ nguyên thuật toán và big-O: duyệt theo thứ tự bộ nhớ, loop blocking, gói dữ liệu nóng, tránh pointer chasing.
  • Duyệt theo thứ tự bộ nhớ: vòng lặp trong cùng đi theo chiều dữ liệu liền nhau — tối ưu rẻ nhất, đáng làm gần như luôn.
  • Loop blocking: chia bài toán thành tile vừa cache để tái dùng dữ liệu khi còn nóng; chuẩn trong thư viện đại số tuyến tính.
  • Gói dữ liệu nóng: tách field nóng ra mảng riêng, chọn kiểu nhỏ hơn khi đủ — tăng tỉ lệ dữ liệu hữu ích mỗi cache line.
  • Tránh pointer chasing: ưu tiên cấu trúc liền mạch (mảng) hơn con trỏ rải rác (linked list) khi duyệt là thao tác nóng.
  • Quy trình: đo trước → rẻ trước đắt sau → chỉ đáng khi dữ liệu lớn hơn cache → đo lại để chứng minh.

8. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao chỉ đổi thứ tự hai vòng lặp lồng nhau (ví dụ i-j-k thành i-k-j trong nhân ma trận) có thể tăng tốc đáng kể mà không đổi kết quả?
Đổi thứ tự vòng lặp không đổi tập phép tính (cùng số phép nhân, cùng kết quả) nhưng đổi thứ tự chạm bộ nhớ. Thứ tự i-j-k khiến vòng trong cùng (k) duyệt b[k][j] theo cột — nhảy cách một hàng mỗi bước, phá spatial locality, nhiều cache miss. Đổi sang i-k-j, vòng trong cùng (j) duyệt b[k][j]c[i][j] tuần tự theo row-major — mỗi cache line dùng cho ~16 phần tử. Cùng O(n³), nhưng chi phí trung bình mỗi phép giảm vì ít đi RAM hơn, nên tổng thời gian giảm nhiều lần trên ma trận lớn.
Q2
Loop blocking (tiling) hoạt động thế nào và vì sao nó giảm số lần đọc RAM dù không giảm số phép tính?
Blocking chia bài toán thành các tile (khối) đủ nhỏ để vừa cache, và xử lý trọn một tile trước khi sang tile khác. Trong nhân ma trận ngây thơ, để tính xong, mỗi phần tử của ma trận B bị đọc lại từ RAM khoảng N lần — vì sau khi nạp vào cache nó bị đẩy ra trước khi được dùng lại. Blocking giữ một tile của B trong cache và tái dùng nó cho mọi phép tính trong tile khi nó còn nóng, nên mỗi phần tử chỉ đọc từ RAM vài lần thay vì N lần. Số phép nhân không đổi (vẫn O(n³)), nhưng số lần đi RAM giảm nhiều bậc — và vì RAM chậm gấp ~100 lần cache, tổng thời gian giảm mạnh. Đây là tối ưu thừa số "chi phí trung bình mỗi phép", không phải số phép.
Q3
Một mảng object Player 200 byte, vòng lặp nóng chỉ dùng id và score. Vì sao tách hai field này ra mảng riêng giúp, và nó liên quan gì tới cache line?
Cache nạp theo cache line 64 byte. Khi mỗi Player chiếm 200 byte và bạn chỉ dùng id + score (~12 byte), mỗi line kéo về phần lớn là field không dùng (name, avatar...) → phí ~94% băng thông và chỗ cache. Tách idscore ra hai mảng riêng liền nhau khiến mỗi cache line toàn id (hoặc toàn score) — dữ liệu hữu ích trên mỗi line tăng vọt, cache chứa được nhiều phần tử hữu ích hơn, tỉ lệ miss giảm. Đây chính là ý tưởng cốt lõi của SoA (bài 04): tổ chức dữ liệu theo field thay vì theo bản ghi, để vòng lặp chỉ kéo về thứ nó cần.
Q4
Vì sao 'pointer chasing' (duyệt linked list, cây con trỏ) vừa chậm vì cache miss vừa chậm vì mất prefetcher?
Hai vấn đề cộng dồn. Cache miss: các node được cấp phát rời rạc trên heap nên nằm rải rác ở những địa chỉ không liền nhau — mỗi node thường rơi vào một cache line khác, nên mỗi bước duyệt là một miss xuống RAM (~100 ns). Mất prefetcher: prefetcher nạp trước cache line khi đoán được mẫu truy cập (stride đều); nhưng với pointer chasing, địa chỉ node kế tiếp chỉ biết được sau khi đã đọc xong node hiện tại (phải dereference node.next), và không theo mẫu nào — prefetcher không có gì để đoán, nên CPU phải chờ đầy đủ mỗi miss. Cấu trúc liền mạch (mảng) tránh cả hai: phần tử liền nhau (ít miss) và stride đều (prefetcher hoạt động).
Q5
Bạn nghe nói LinkedList chậm vì cache, nên định thay mọi LinkedList trong code bằng ArrayList. Vì sao đây là quyết định cần cân nhắc, không phải luật cứng?
Vì locality chỉ là một tiêu chí, và cấu trúc dữ liệu nên chọn theo thao tác chính. ArrayList thắng khi thao tác chính là duyệt dữ liệu lớn (spatial locality, prefetcher). Nhưng LinkedList có thể tốt hơn khi cần chèn/xoá ở giữa nhiều và đã có con trỏ tới vị trí (O(1) thay vì phải dịch phần tử như ArrayList). Ngoài ra, với n nhỏ (toàn bộ dữ liệu vừa trong cache), khác biệt locality không đáng kể — thay đổi chỉ thêm rủi ro mà không lợi đo được. Quy tắc đúng: xác định thao tác nóng, đo trên dữ liệu thật, rồi mới đổi; đừng áp một "luật" locality mù quáng lên mọi nơi.

Bài tiếp theo: AoS vs SoA — bố cục dữ liệu theo cache

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên