Bạn ghi một giá trị vào một biến trong bộ nhớ, rồi ngay dòng sau đọc lại chính nó. Trực giác nói: lệnh ghi phải "xuống" tới cache đã, rồi lệnh đọc mới lấy được — mất thêm một vòng bộ nhớ. Nhưng CPU không ngây thơ vậy. Nó có một store buffer giữ các lệnh ghi chưa kịp xuống cache, và một cơ chế store-to-load forwarding: khi một lệnh đọc trúng địa chỉ mà store buffer đang giữ, phần cứng chuyển thẳng giá trị vừa ghi cho lệnh đọc, không cần đợi nó xuống L1. Câu hỏi đo lường: forwarding nhanh cỡ nào, và khi nào nó thất bại? Tôi đo trong container gcc:13 trên host ARM, và con số vừa xác nhận forwarding gần như miễn phí, vừa lật đổ một niềm tin phổ biến mượn từ x86.
Ghi rồi đọc: store buffer chuyển thẳng, không đợi cache
Khi CPU thực thi một lệnh ghi (store), giá trị không xuống cache ngay — nó vào store buffer, một hàng đợi nhỏ, rồi lặng lẽ ghi xuống L1 sau đó. Nếu một lệnh đọc (load) tiếp theo trúng đúng địa chỉ đang nằm trong store buffer, CPU không bắt nó chờ: nó forward giá trị thẳng từ buffer. Đây là lý do "ghi rồi đọc lại ngay" không tốn một vòng bộ nhớ đầy đủ.
Nhưng forwarding tạo ra một hệ quả tinh vi: khi lệnh đọc phụ thuộc lệnh ghi (đọc đúng chỗ vừa ghi), hai lệnh không thể chạy song song — lệnh đọc phải đợi giá trị của lệnh ghi. Nếu bạn lặp "ghi ô X rồi đọc ô X, kết quả lại dùng để ghi tiếp", bạn tạo một chuỗi phụ thuộc nối tiếp qua bộ nhớ, và tốc độ bị chặn bởi độ trễ forwarding mỗi vòng.
Ngược lại, nếu lệnh ghi và lệnh đọc không đè nhau (khác địa chỉ), lệnh đọc không phụ thuộc lệnh ghi — CPU chạy chúng song song, nhanh hơn nhiều. Và có một ca hiểm: khi lệnh đọc đè lên một phần vùng vừa ghi nhưng lệch (khác cỡ, lệch byte), forwarding khó thực hiện — trên x86 đây là store-forwarding stall trứ danh, chậm nhiều lần. Tôi đo cả ba.
Đo: khớp 1,55 ns, độc lập nhanh 6,8 lần, đè lệch chỉ +18%
Tôi dựng một chuỗi phụ thuộc: mỗi vòng làm đúng một lệnh ghi 8 byte rồi một lệnh đọc, giá trị đọc được chảy sang vòng sau (dùng volatile để lệnh ghi/đọc thật sự xảy ra, không bị tối ưu bỏ). Tôi đổi quan hệ địa chỉ giữa ghi và đọc:
1 store 8B + 1 load + alu mỗi vòng, ns/vòng, host ARM, g++ -O2 -fno-tree-vectorize:
tình huống (ghi 8B tại offset 0) | ns/vòng | diễn giải
---------------------------------|---------|------------------------------------
đọc 8B tại 0 (khớp, cùng ô) | 1,55 | forwarding OK, nhưng đọc phụ thuộc ghi
đọc 8B tại 128 (khác dòng cache) | 0,23 | đọc độc lập -> chạy song song
đọc 8B tại 1 (đè lệch 1 byte) | 1,82 | đè lệch
đọc 8B tại 2 (đè lệch 2 byte) | 1,82 |
đọc 8B tại 4 (đè lệch 4 byte) | 1,82 |
Ba điều đọc ra được. Một: ghi-rồi-đọc-lại đúng chỗ (khớp) tốn 1,55 ns mỗi vòng — khoảng 7 chu kỳ ở ~4,44 GHz. Đó không phải một vòng ra RAM (cỡ trăm ns); store buffer đã forward giá trị, và 1,55 ns là độ trễ của chuỗi phụ thuộc ghi→đọc→ghi. Forwarding hoạt động.
Hai: khi lệnh đọc lấy từ một dòng cache khác (offset 128, không đè lên chỗ ghi), thời gian tụt xuống 0,23 ns — nhanh ~6,8 lần. Không phải vì bộ nhớ nhanh hơn, mà vì lệnh đọc không phụ thuộc lệnh ghi: CPU out-of-order phóng cả hai gần như song song, không còn chuỗi nối tiếp. Đây là khác biệt lớn nhất trong cả phép đo — và nó đến từ quan hệ phụ thuộc, không phải từ forwarding có hay không.
Ba — cái bất ngờ: khi lệnh đọc đè lệch lên chỗ vừa ghi (đọc 8 byte bắt đầu ở offset 1, 2, hay 4, chồng một phần lên vùng ghi tại offset 0), thời gian là 1,82 ns — chỉ chậm 18% so với ca khớp (1,55 ns). Trên x86, đây đúng là kiểu truy cập gây store-forwarding stall chậm gấp mấy lần (load phải đợi store xuống cache rồi đọc lại). Nhưng trên host ARM này (Apple Silicon), hình phạt chỉ ~18% — phần cứng xử lý đè lệch nhẹ nhàng, không có cú sập nhiều lần như folklore x86 mô tả.
Một lần tôi đo hớ: "đọc lại tốn thêm một vòng bộ nhớ" và "đè lệch gây stall nhiều lần"
Tôi vào đo với một mô hình sai theo hướng bi quan: "ghi một biến rồi đọc lại ngay thì lệnh đọc phải đợi lệnh ghi xuống cache — tốn thêm một vòng truy cập bộ nhớ đầy đủ". Đo phá tan: chuỗi ghi-đọc khớp chỉ 1,55 ns (~7 chu kỳ), không phải chục-trăm ns của một vòng ra L1/RAM. Store buffer forward giá trị thẳng cho lệnh đọc, nên "ghi rồi đọc lại" gần như không tốn thêm gì ngoài độ trễ vài chu kỳ. Ai sợ "đọc lại biến vừa ghi thì chậm" đang tưởng tượng một cái giá không tồn tại.
Nhưng đo cũng lật một niềm tin ngược, mượn thẳng từ tài liệu tối ưu x86: "đọc đè lệch/khác cỡ lên chỗ vừa ghi gây store-forwarding stall chậm nhiều lần, phải tránh bằng mọi giá". Trên host ARM này, đo ra đè lệch chỉ chậm 18% (1,55 → 1,82 ns) — không phải cú sập gấp mấy lần. Đây là một điểm quan trọng: các quy tắc tối ưu "vi mô" thường đặc thù kiến trúc. Cái đúng và đáng sợ trên một dòng CPU x86 có thể gần như vô hại trên ARM/Apple Silicon. Chép quy tắc x86 sang ARM mà không đo là tối ưu cho một cái máy không tồn tại.
Bài học đo lường: store buffer FORWARD giá trị vừa ghi cho lệnh đọc — ghi-rồi-đọc-khớp chỉ ~1,55 ns (~7 chu kỳ, KHÔNG phải vòng RAM). Điều chi phối lớn nhất là lệnh đọc có PHỤ THUỘC lệnh ghi hay không: khớp (phụ thuộc, chuỗi nối tiếp) 1,55 ns vs khác dòng (độc lập, song song) 0,23 ns = ~6,8x. Đè lệch byte — 'store-forwarding stall' trứ danh của x86 — trên host ARM này chỉ chậm 18% (1,82 ns), KHÔNG nhiều lần. Quy tắc tối ưu vi mô đặc thù kiến trúc. Nếu tin "đọc lại tốn thêm vòng bộ nhớ" tôi sợ hão; nếu tin "đè lệch luôn stall nhiều lần" tôi chép nhầm quy tắc x86 sang ARM.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng sợ đọc lại biến vừa ghi — nhưng hãy để ý chuỗi phụ thuộc qua bộ nhớ. "Ghi rồi đọc lại ngay" tự nó rẻ (forwarding lo). Cái đắt là khi bạn xâu nhiều bước ghi-đọc-cùng-ô thành một chuỗi nối tiếp: mỗi bước phải đợi bước trước, và bạn mất khả năng chạy song song của CPU. Nếu một vòng lặp nóng cứ ghi rồi đọc lại cùng một ô (một biến đếm trong bộ nhớ, một trường struct dùng làm tích lũy), cân nhắc giữ nó trong biến cục bộ (thanh ghi) và chỉ ghi xuống bộ nhớ một lần ở cuối — cắt chuỗi qua bộ nhớ đi.
Hệ quả thứ hai: cẩn thận các mẫu ghi-cỡ-này-đọc-cỡ-khác chồng lên nhau, nhưng đo trước khi lo. Ghi một struct qua từng byte rồi đọc lại nguyên khối 8 byte, ghi một mảng char rồi đọc thành int, union đọc-ghi khác kiểu — những mẫu này có thể gây store-forwarding stall trên x86. Nhưng như phép đo cho thấy, mức phạt tùy kiến trúc: nặng trên vài dòng x86, nhẹ trên ARM này. Đừng bẻ cong code theo một quy tắc nghe được mà chưa đo trên máy đích.
Hệ quả thứ ba là tinh thần đo lường: quan hệ phụ thuộc giữa các lệnh chi phối hiệu năng nhiều hơn tiểu tiết forwarding — và quy tắc vi mô phải đo trên đúng máy. Con số mang theo: ghi-đọc-khớp ~1,55 ns (forwarding, chuỗi nối tiếp); ghi-đọc khác địa chỉ ~0,23 ns (độc lập, song song, 6,8x nhanh); đè lệch trên host ARM chỉ +18%, không phải stall nhiều lần như x86. Cùng một cặp "một ghi, một đọc", tốc độ chênh gần 7 lần — và biến quyết định là chúng có phụ thuộc nhau không, chứ không phải forwarding trục trặc hay trơn tru.
Thử ba mươi giây
Viết một vòng lặp làm đúng một lệnh ghi rồi một lệnh đọc mỗi vòng, dùng con trỏ volatile để chúng thật sự chạm bộ nhớ, và xâu giá trị đọc được vào vòng sau để tạo chuỗi. Chạy hai phiên bản: (a) ghi và đọc cùng một địa chỉ, (b) ghi ở địa chỉ này nhưng đọc ở một địa chỉ khác dòng cache. Bấm giờ mỗi vòng: bản (a) chậm hơn nhiều lần bản (b) — không phải vì forwarding hỏng, mà vì trong (a) lệnh đọc phụ thuộc lệnh ghi nên chúng nối tiếp, còn trong (b) chúng độc lập nên CPU chạy song song. Rồi thử ca thứ ba: ghi 8 byte tại offset 0, đọc 8 byte tại offset 1 (đè lệch một phần). Trên máy x86 bạn có thể thấy nó chậm hẳn (store-forwarding stall); trên một máy ARM/Apple Silicon nó có thể chỉ nhỉnh hơn chút. Ba mươi giây đó cho bạn thấy hai điều: "ghi rồi đọc lại" không tốn một vòng bộ nhớ như trực giác sợ, và cái thật sự quyết định tốc độ là các lệnh có phụ thuộc nhau không — cùng một quy tắc phải đo trên chính con CPU bạn chạy, vì tiểu tiết như store-forwarding stall khác nhau theo kiến trúc.