Tài liệu chia sẻ kỹ thuật nội bộ

Cache Hit — Sự giao thoa giữa Code & Phần cứng

Tốc độ phản hồi của RAM hiện đại chậm hơn hàng trăm lần so với tốc độ xử lý của CPU. Cache và cách sắp xếp dữ liệu trong bộ nhớ chính là lý do phần cứng ngày nay không phải chờ RAM ở mọi lệnh đọc/ghi. Tài liệu này đi qua kiến trúc phân cấp bộ nhớ, cách cache ánh xạ dữ liệu, và tại sao alignment không phải chi tiết vặt vãnh.

~1 nsĐộ trễ L1 cache
~80–100 nsĐộ trễ RAM
64 BKích thước 1 cache line phổ biến
REG L1 L2 L3 RAM Đĩa (SSD/HDD)
Nhỏ, đắt, nhanh Lớn, rẻ, chậm
Bối cảnh

Vì sao CPU cần cache?

Tốc độ xử lý của CPU tăng nhanh hơn rất nhiều so với tốc độ phản hồi của RAM trong vài chục năm qua — khoảng cách này thường được gọi là "Memory Wall".

Một CPU 3 GHz thực hiện xong một chu kỳ trong khoảng 0.33 ns. Nhưng một lần truy cập RAM mất khoảng 60–100 ns — tương đương 200–300 chu kỳ CPU. Nếu mọi lệnh đọc/ghi bộ nhớ đều phải đợi RAM, CPU sẽ đứng im phần lớn thời gian chỉ để chờ dữ liệu.

Cache giải quyết vấn đề này bằng cách chèn các lớp bộ nhớ nhỏ hơn nhưng nhanh hơn nhiều giữa CPU và RAM, dựa trên một quan sát thực nghiệm rất ổn định: chương trình có tính cục bộ (locality) — dữ liệu vừa dùng thường được dùng lại sớm, và dữ liệu nằm gần nhau trong bộ nhớ thường được dùng gần nhau về thời gian. Phần "Locality of reference" ở cuối tài liệu sẽ nói kỹ hơn về điều này.

Kiến trúc

Phân cấp bộ nhớ: gần CPU thì nhanh và nhỏ, xa CPU thì chậm và lớn

Mỗi cấp trong sơ đồ ở đầu trang là một sự đánh đổi giữa tốc độ, dung lượng và chi phí trên mỗi byte.

CấpDung lượng điển hìnhĐộ trễ điển hìnhVị trí vật lý
Thanh ghi CPUVài trăm byte~0 chu kỳNgay trong ALU của lõi CPU
L1 Cache32–64 KB / lõi~4 chu kỳ (~1 ns)Bên trong mỗi lõi, tách riêng I-cache/D-cache
L2 Cache256 KB – 1 MB / lõi~12 chu kỳ (~3–4 ns)Riêng cho mỗi lõi hoặc từng cặp lõi
L3 CacheVài MB – vài chục MB~40 chu kỳ (~10–20 ns)Dùng chung cho toàn bộ chip
RAMVài GB – vài trăm GB~200 chu kỳ (~60–100 ns)Gắn ngoài CPU, qua bus bộ nhớ
Ổ đĩa (SSD/HDD)Hàng trăm GB – vài chục TBSSD ~10–100 µs
HDD ~1–10 ms
Thiết bị lưu trữ ngoài, qua PCIe/SATA

Số liệu mang tính minh hoạ cho các CPU x86 phổ biến gần đây; con số thực tế thay đổi theo từng dòng chip.

Cơ chế hoạt động

Cache hit & cache miss

Khi CPU cần một địa chỉ bộ nhớ, nó tìm từ cấp gần nhất (L1) rồi mới đi xa dần. Hit là tìm thấy ngay; miss là phải hỏi cấp tiếp theo — mỗi lần miss cộng dồn thêm độ trễ ("miss penalty").

CPU
yêu cầu dữ liệu
→
L1
4 chu kỳ
→
L2
12 chu kỳ
→
L3
40 chu kỳ
→
RAM
200 chu kỳ
Xác suất hit giả lập: L1 80% · L2 70% · L3 60% · RAM 100%
Bấm nút để mô phỏng một lượt CPU tìm dữ liệu qua các cấp cache…

Ba nguyên nhân gây miss ("3 C's"): compulsory miss (lần đầu truy cập, chưa từng được nạp vào cache), capacity miss (cache quá nhỏ để giữ hết dữ liệu đang dùng), và conflict miss (nhiều vùng nhớ tranh nhau cùng một vị trí trong cache — liên quan trực tiếp tới cách ánh xạ ở phần tiếp theo).

Bản đồ cache

Một khối nhớ được đặt vào cache như thế nào?

Cache được chia thành các "dòng" (cache line, thường 64 byte). Ba chiến lược ánh xạ khối nhớ vào dòng cache dưới đây đánh đổi giữa tốc độ tra cứu và tỷ lệ miss.

Direct-mapped

Mỗi khối nhớ chỉ có đúng 1 dòng cache để đi vào: dòng = địa_chỉ mod 4. Đơn giản, tra cứu cực nhanh, nhưng 2 khối cùng màu tranh nhau 1 dòng sẽ liên tục đá nhau ra (conflict miss).
↓ ánh xạ theo màu ↓

Set-associative (2-way)

Khối nhớ map vào đúng 1 "tập" (set), nhưng trong tập có 2 dòng để chọn. Đây là lựa chọn phổ biến nhất trên CPU thật — L1 thường 8-way, L2/L3 có thể 8–20-way.
↓ ánh xạ theo màu tập ↓

Fully associative

Một khối nhớ có thể nằm ở bất kỳ dòng nào. Tỷ lệ miss thấp nhất, nhưng phải so sánh tag với toàn bộ các dòng cùng lúc → phần cứng đắt, thường chỉ dùng cho cache rất nhỏ như TLB.
bất kỳ khối nào ⇄ bất kỳ dòng nào

Địa chỉ được "cắt" thành 3 phần

Ví dụ minh hoạ (không theo đúng tỉ lệ pixel) cho một L1 cache 32 KB, 8-way, dòng 64 byte, địa chỉ 64-bit → 64 tập → cần 6 bit index, 6 bit offset, còn lại 52 bit tag.

TAG · 52 bit
INDEX
OFFSET
Tag — xác định đúng khối nhớ nào đang nằm trong dòng đó Index — chọn tập/dòng cache để so sánh Offset — vị trí byte cần lấy bên trong dòng 64 byte

CẤU TRÚC VẬT LÝ CỦA MỘT DÒNG CACHE (CACHE LINE BLOCK ON SRAM):

Minh hoạ (không theo đúng tỉ lệ pixel) cho một L1 cache 32 KB, 8-way, 52 bit tag, 1 bit Valid Bit, 1 bit Dirty Bit, vài MESI/LRU Bits, KHỐI DỮ LIỆU THÔ 64 BYTES.

TAG *
52 bit
VALID
1 bit
DIRTY
1 bit
MESI/LRU
vài bit
DATA
64 bytes
TAG — xác định đúng khối nhớ nào đang nằm trong dòng đó. * Số lượng bit của TAG sẽ thay đổi phụ thuộc vào tầng cache L1/L2/L3 VALID — xác định dữ liệu trong dòng cache hiện tại là hợp lệ để CPU đọc hay đang trống/bị hủy DIRTY — đánh dấu dữ liệu trong cache đã bị CPU sửa đổi hay chưa, nhằm quyết định có cần ghi đè ngược xuống RAM khi bị xóa. MESI/LRU - Đồng bộ trạng thái dữ liệu giữa các nhân CPU (MESI) và lưu lịch sử truy cập để biết đường "kick" dòng cache cũ ra ngoài khi đầy (LRU). DATA - Dữ liệu được cache.
Memory alignment

Vì sao thứ tự khai báo field cũng ảnh hưởng tới hiệu năng

CPU đọc bộ nhớ theo từng "word" (4/8 byte) hoặc theo cả cache line, không đọc từng byte lẻ. Một giá trị được coi là aligned khi địa chỉ của nó chia hết cho kích thước của chính nó (một int 4 byte nên nằm ở địa chỉ chia hết cho 4).

Truy cập misaligned — dữ liệu nằm vắt qua ranh giới word/cache line — buộc CPU phải đọc 2 lần rồi ghép lại, chậm hơn hẳn; một số kiến trúc (ARM cũ, chế độ strict) còn từ chối thẳng và bắn lỗi bus. Vì vậy trình biên dịch tự thêm các byte đệm (padding) vào struct để mọi field đều rơi đúng vào địa chỉ hợp lệ — và thứ tự khai báo field quyết định việc đệm nhiều hay ít.

struct Example { char a; // 1 byte int b; // 4 byte char c; // 1 byte double d; // 8 byte };

Tổng kích thước: 24 byte — trong đó 10 byte là padding (~42% lãng phí).

struct ExampleOpt { double d; // 8 byte int b; // 4 byte char a; // 1 byte char c; // 1 byte };

Tổng kích thước: 16 byte — chỉ còn 2 byte padding. Nhỏ hơn 33% so với bản gốc, chỉ nhờ đổi thứ tự field.

Quy tắc chung: khai báo field theo thứ tự kích thước giảm dần (lớn → nhỏ) để trình biên dịch cần chèn ít padding nhất.

Vì sao cache hoạt động hiệu quả

Locality of reference

Temporal locality: dữ liệu vừa dùng có khả năng cao được dùng lại sớm (ví dụ biến đếm vòng lặp). Spatial locality: dữ liệu nằm gần nhau trong bộ nhớ thường được dùng gần nhau về thời gian — đây là lý do cache luôn nạp nguyên một cache line 64 byte thay vì chỉ 1 byte cần dùng.

Duyệt mảng 2 chiều theo hàng — khớp với cách C lưu mảng trong bộ nhớ (row-major) → tận dụng tối đa spatial locality.
Duyệt theo cột — mỗi bước nhảy 4 phần tử trong bộ nhớ thật, dễ gây cache miss liên tục dù dữ liệu giống hệt bên trái.
Tác dụng thực tế

Cache mang lại hiệu quả bao nhiêu?

Hai cách nhìn: so sánh trực tiếp độ trễ giữa các cấp, và công thức AMAT (Average Memory Access Time) cho thấy chỉ vài phần trăm hit rate cũng đổi khác biệt rất lớn.

So sánh độ trễ (thang log)

Máy tính AMAT — độ trễ trung bình mỗi lần truy cập bộ nhớ

Giả định độ trễ cố định: L1 = 1 ns, L2 = 4 ns, L3 = 15 ns, RAM = 80 ns. Kéo thanh trượt để đổi hit rate từng cấp.

Hit rate L192%
Hit rate L285%
Hit rate L375%
AMAT ước tính
—
nano giây / lần truy cập
Tổng kết

Những điều đáng nhớ nhất

  • 1
    Cache tồn tại vì khoảng cách tốc độ giữa CPU và RAM quá lớn (Memory Wall) — mỗi cấp cache là một điểm cân bằng giữa tốc độ, dung lượng và chi phí.
  • 2
    Hit rate ảnh hưởng tới hiệu năng nhiều hơn trực giác — vài phần trăm hit rate thấp hơn ở L1/L2 có thể làm AMAT tăng gấp nhiều lần.
  • 3
    Set-associative là lựa chọn thực tế phổ biến nhất, cân bằng giữa tốc độ tra cứu (direct-mapped) và tỷ lệ miss thấp (fully associative).
  • 4
    Alignment không phải chi tiết vặt: sắp field theo thứ tự kích thước giảm dần có thể giảm 1/3 kích thước struct, giảm số cache line cần nạp.
  • 5
    Viết code tận dụng spatial/temporal locality (duyệt tuần tự, gom dữ liệu hay dùng cùng nhau) gần như luôn nhanh hơn code "đúng thuật toán nhưng truy cập bộ nhớ ngẫu nhiên".