Hình dung bạn đang tính toán trên một tờ giấy nháp cầm trên tay. Bạn viết một con số xuống, rồi ngay câu sau cần đọc lại chính nó. Bạn có đợi tờ giấy đó được cất vào tủ hồ sơ, rồi lục tủ ra đọc không? Tất nhiên là không — nó đang nằm trên tay bạn. CPU cũng làm y hệt, và cái "tờ giấy trên tay" ấy có tên: store buffer.

Chuyện là thế này. Trực giác của phần lớn chúng ta bảo: ghi một biến vào bộ nhớ thì 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ớ. Nghe hợp lý. Nhưng CPU không ngây thơ vậy. Nó giữ các lệnh ghi chưa kịp xuống cache trong store buffer, và có một cơ chế gọi là 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ỏi đợi nó xuống L1. Câu hỏi tôi muốn trả lời bằng con số: forwarding nhanh cỡ nào, và khi nào thì nó trục trặc? Tôi đo trong container gcc:13 trên host ARM (Apple Silicon, ~4,44 GHz), và kết quả vừa xác nhận điều mình đoán, vừa lật đổ một niềm tin tôi mượn từ x86 mà không kiểm lại.

Ghi rồi đọc: store buffer chuyền thẳng, không đợi cache

Khi CPU chạy 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) ngay sau trúng đúng địa chỉ đang nằm trong buffer, CPU không bắt nó chờ: nó forward giá trị thẳng từ buffer. Đó là lý do "ghi rồi đọc lại ngay" không tốn một vòng bộ nhớ đầy đủ.

Nhưng forwarding kéo theo một hệ quả tinh vi mà lúc đầu tôi không để ý. 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 buộ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 đã dựng một chuỗi phụ thuộc nối tiếp chạy 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 đè lên 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 hẳn. Và có một ca hiểm giữa hai thái cực: 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 mấy lần. Tôi đo cả ba ca.

Đo: khớp 1,55 ns, độc lập nhanh gần 7 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 chứ không bị tối ưu bỏ đi). Rồi 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 từ bảng này.

Thứ nhấ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ỳ. Đây không phải một vòng ra RAM (cỡ trăm ns); store buffer đã forward giá trị, và 1,55 ns chỉ là độ trễ của chuỗi phụ thuộc ghi→đọc→ghi. Forwarding hoạt động đúng như quảng cáo.

Thứ 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 gần 7 lần. Không phải vì bộ nhớ bỗng nhanh hơn, mà vì lệnh đọc giờ không phụ thuộc lệnh ghi: CPU out-of-order phóng cả hai gần như song song, chuỗi nối tiếp biến mất. Đâ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ừ chuyện forwarding có xảy ra hay không. Chỗ này làm tôi phải ngồi lại nghĩ, vì tôi vào đo với đầu óc chăm chăm vào forwarding, trong khi nhân vật chính hóa ra là dependency.

Thứ ba, cái bất ngờ nhất: 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, đúng kiểu truy cập này gây store-forwarding stall chậm gấp mấy lần, vì load phải đợi store xuống cache rồi mới đọc lại được. Nhưng trên host ARM này, hình phạt chỉ ~18%. Phần cứng xử lý đè lệch nhẹ nhàng, không có cú sập nào như folklore x86 vẫn kể.

Hai niềm tin bị đo cho lung lay

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 đủ". Con số nói ngược: chuỗi ghi-đọc khớp chỉ 1,55 ns (~7 chu kỳ), chẳng dính dáng gì tới chục-trăm ns của một vòng ra L1 hay 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 gì thêm ngoài độ trễ vài chu kỳ. Ai sợ "đọc lại biến vừa ghi thì chậm" đang sợ một cái giá không tồn tại.

Còn niềm tin thứ hai thì ngược lại, và tôi mượn thẳng từ tài liệu tối ưu x86 mà chưa từng kiểm trên máy khác: "đọc đè lệch/khác cỡ lên chỗ vừa ghi gây store-forwarding stall chậm mấy 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 nào cả. Đây mới là điểm tôi thấy đáng nhớ nhất: 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ại là đang tối ưu cho một cái máy không tồn tại.

Nếu bạn chỉ mang theo được một câu: store buffer forward giá trị vừa ghi cho lệnh đọc, nên ghi-rồi-đọc-khớp chỉ ~1,55 ns chứ không phải một vòng RAM. Nhưng thứ chi phối tốc độ lớn nhất là lệnh đọc có phụ thuộc lệnh ghi hay không: khớp (phụ thuộc, nối tiếp) 1,55 ns so với khác dòng (độc lập, song song) 0,23 ns — chênh gần 7 lần. Còn "store-forwarding stall" trứ danh của x86, trên máy ARM này chỉ là +18%.

Về đến code hằng ngày thì sao

Điều đầu tiên, và cũng là điều dễ áp dụng nhất: đừng sợ đọc lại biến vừa ghi — nhưng để mắt tới chuỗi phụ thuộc qua bộ nhớ. Bản thân "ghi rồi đọc lại ngay" rẻ, forwarding lo hết. 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 đánh mất khả năng chạy song song của CPU. Một vòng lặp nóng cứ ghi rồi đọc lại cùng một ô — một biến đếm nằm trong bộ nhớ, một trường struct dùng làm tích lũy — thì nên giữ nó trong biến cục bộ (để nó ở lại thanh ghi) và chỉ ghi xuống bộ nhớ một lần ở cuối. Cắt cái chuỗi qua bộ nhớ đi là xong. Trình biên dịch C/C++ với -O2 thường tự làm việc này khi biến không volatile và không bị con trỏ khác nhòm vào; nhưng thêm một volatile, hay một alias mà nó không chứng minh được là vô hại, là nó buộc phải ghi-đọc thật mỗi vòng — và bạn quay lại đúng chuỗi 1,55 ns kia.

Điều thứ hai: cẩn thận với 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, một 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 hơi được mà chưa đo trên đúng máy đích. Đây cũng là bài học chung cho ai đọc mấy bài "micro-optimization tips" trên mạng: gần như bài nào cũng ngầm giả định x86.

Nhìn lên tầng cao hơn thì hiện tượng này gần như vô hình. JVM, Go runtime, hay một câu truy vấn DB đều nằm cách store buffer nhiều lớp trừu tượng; bạn không điều khiển được offset của một load. Nhưng cái tinh thần thì mang lên được: quan hệ phụ thuộc giữa các bước chi phối hiệu năng nhiều hơn tiểu tiết của một thao tác đơn lẻ. Một vòng lặp Go cập nhật cùng một biến qua từng vòng, một chuỗi phép tính mà bước sau cần kết quả bước trước — đó là chuỗi nối tiếp, và không CPU out-of-order nào cứu được. Tách các bước cho độc lập nhau, chúng mới chạy song song. Cùng một cặp "một ghi, một đọc" mà chênh nhau gần 7 lần chỉ vì một cái phụ thuộc còn cái kia không — con số đó đáng nhớ hơn mọi mẹo về offset.

Muốn tự thấy tận mắt thì nhanh thôi. 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 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êm ca thứ ba: ghi 8 byte tại offset 0, đọc 8 byte tại offset 1. Trên máy x86 bạn có thể thấy nó chậm hẳn; trên một máy ARM/Apple Silicon nó có khi chỉ nhỉnh hơn chút. Hai điều rơi ra từ ba mươi giây đó: "ghi rồi đọc lại" không tốn một vòng bộ nhớ như trực giác sợ, và thứ 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 mà bạn phải đo trên chính con CPU mình chạy, vì những tiểu tiết như store-forwarding stall khác nhau theo kiến trúc.