Hình dung một thủ thư đang đếm sách. Cách nhanh nhất là nhẩm con số trong đầu, đếm xong cả kệ mới ghi vào sổ một lần. Giờ sếp ra một quy định: "sau mỗi cuốn, phải ghi ngay vào sổ rồi đọc lại từ sổ mới được đếm tiếp". Câu quy định đó chẳng tốn gì để nói ra — nó là lời, không phải hành động. Nhưng nó cấm cô nhẩm trong đầu, buộc mỗi cuốn phải một vòng ghi-sổ-đọc-lại, và công việc chậm hẳn. Điều trớ trêu: cái đắt không phải một hành động nặng nề nào nhìn thấy được, mà chính là quyền nhẩm trong đầu bị lấy đi. Rào bộ nhớ của compiler đúng là cái quy định đó, và tôi đã đổ lỗi nhầm chỗ cho cái đắt.

Compiler không phát ra lệnh theo đúng thứ tự bạn viết. Nó được tự do sắp lại các lệnh độc lập để giấu độ trễ bộ nhớ và lấp đầy ống lệnh của CPU — miễn là hành vi quan sát được của một luồng đơn không đổi. Thường thì bạn không cần quan tâm. Nhưng khi code nói chuyện với phần cứng, với một trình ngắt, hay với luồng khác, thứ tự lại quan trọng — và bạn cần một rào (barrier) để ép nó. Ở bài về volatile tôi đã chạm tới chuyện này; bài này tôi đo thẳng vào cái rào trong container gcc:13 (ARM), và phát hiện cái đắt không nằm ở nơi tôi tưởng.

Hai loại rào, rất khác nhau

Có hai thứ tự bị đảo mà ta phải phân biệt. Thứ nhất là compiler sắp lại: lúc biên dịch, compiler dời/gộp/bỏ các lệnh. Thứ hai là CPU sắp lại: lúc chạy, con chip thực thi lệnh không theo thứ tự (out-of-order) và ghi bộ nhớ có thể hiện ra với lõi khác không theo thứ tự chương trình. Mỗi loại có một rào riêng.

Rào biên dịch trong GCC/Clang là asm volatile("" ::: "memory") — một đoạn assembly rỗng nhưng khai báo "làm bẩn" bộ nhớ. Nó cấm compiler dời đọc/ghi bộ nhớ qua điểm đó, và cấm giữ giá trị bộ nhớ trong thanh ghi xuyên qua nó. Điều quan trọng: nó không sinh lệnh máy nào — nó chỉ là một rào cho bộ tối ưu. Đúng câu quy định "ghi ngay vào sổ".

Rào phần cứng là atomic_thread_fence(memory_order_seq_cst) (C11) — nó vừa là rào biên dịch, vừa phát ra một lệnh CPU thật: trên AArch64 là dmb ish (data memory barrier). Lệnh này bắt chính con chip không được sắp lại các truy cập bộ nhớ qua nó. Trên một luồng đơn bạn chỉ cần rào biên dịch; chỉ khi có luồng khác hay MMIO quan sát thứ tự ghi thì mới cần rào phần cứng.

Đo: rào biên dịch là 0 lệnh nhưng chậm 5,8 lần

Tôi cộng dồn 200 triệu phần tử vào một biến toàn cục acc, ở ba biến thể của vòng lặp.

thường       : 0,2278 ns/phần tử
rào biên dịch: 1,3215 ns/phần tử   -> chậm 5,8 lần
rào phần cứng: 1,1855 ns/phần tử   -> chậm 5,2 lần

objdump cho thấy cơ chế. Vòng thường giữ acc trong một thanh ghi: một ldr nạp acc trước vòng, thân vòng chỉ có ldrsw (nạp a[i]) và add, rồi một str ghi acc ra sau vòng. Suốt 200 triệu vòng, acc không rời thanh ghi — đúng kiểu tối ưu giữ-giá-trị-nóng mà khử mã chết và giữ thanh ghi dựa vào. Cô thủ thư đang nhẩm trong đầu.

Chèn rào biên dịch asm volatile("" ::: "memory") sau mỗi phép cộng, thân vòng đổi hẳn: ldr acc + ldrsw a[i] + add + str acc — nạp và ghi acc ra bộ nhớ mỗi vòng. Vì rào khai báo bộ nhớ "có thể đã đổi", compiler không dám giữ acc trong thanh ghi qua rào, buộc phải load lại và store lại mỗi lần. Đó là 5,8 lần chậm hơn. Và đây là điểm bất ngờ đầu tiên: bản thân cái rào không có một lệnh máy nào — grep dmb trong hàm này trả về 0. Nó là một rào vô hình trong assembly, nhưng cái giá của nó (chặn tối ưu) thì rất thật.

Tôi đổ lỗi nhầm cho cái đắt

Tôi vào đo với một niềm tin phổ biến: "rào bộ nhớ là một lệnh CPU đắt tiền — cứ có rào là có một lệnh nặng chạy". Nên tôi đoán chắc: rào phần cứng (có hẳn lệnh dmb ish) phải chậm hơn rào biên dịch (0 lệnh). Đo ra ngược lại.

Rào phần cứng đo được 1,1855 ns/phần tử, còn nhanh hơn rào biên dịch (1,3215 ns) khoảng 11% — dù nó có thêm một lệnh dmb ish thật mỗi vòng mà rào biên dịch không có. Con số này ổn định qua nhiều lần chạy, không phải nhiễu.

Vì sao? Vì cái đắt trong cả hai trường hợp là việc bị chặn tối ưu — cả hai đều buộc acc phải đi một vòng bộ nhớ (load + store) mỗi phần tử thay vì nằm yên trong thanh ghi. Chi phí đó (phụ thuộc chuỗi load-store trên acc) lấn át hoàn toàn. Lệnh dmb ish cộng thêm vào rào phần cứng thì trên lõi này gần như miễn phí — hàng đợi bộ nhớ đã bị chuỗi phụ thuộc acc giữ chân, cái rào phần cứng gần như không thêm độ trễ, và sự khác biệt nhỏ trong cách compiler xếp lịch quanh hai loại rào còn nghiêng phần thắng về phía bản có dmb. Nói theo chuyện thủ thư: cái làm chậm là buộc ghi sổ mỗi cuốn, chứ không phải con dấu đóng thêm lên trang sổ.

Bài học đọng lại: đừng gán cái đắt cho thứ dễ thấy nhất. Lệnh dmb nổi bật trong assembly nên dễ bị đổ lỗi là "cái nặng"; nhưng đo ra, cái nặng là điều vô hình — tối ưu bị tước đi. Nếu tôi chỉ nhìn assembly và đoán, tôi đã kết luận sai hẳn về đâu là chi phí thật.

volatile không phải là rào đủ

Còn một cạm bẫy nối tiếp bài volatile: nhiều người tưởng volatile đủ để "sắp thứ tự" cho code đa luồng. Không. volatile giữ thứ tự giữa các truy cập volatile với nhau và cấm compiler gộp/bỏ chúng — nhưng nó không phát ra rào phần cứng. Nó không cấm CPU sắp lại các truy cập thường quanh nó, và không đảm bảo một lõi khác thấy các ghi của bạn theo đúng thứ tự. Dùng volatile để đồng bộ đa luồng là một lỗi kinh điển: code "trông như" có thứ tự mà thực ra vẫn đua. Đồng bộ đa luồng cần atomic với thứ tự bộ nhớ phù hợp (chính là thứ phát ra dmb khi cần), không phải volatile.

Về đến code hằng ngày

Điều đầu tiên: rào là công cụ đúng khi bạn cần thứ tự, nhưng nó không miễn phí — cái giá là tối ưu bị chặn. Một asm volatile("" ::: "memory") đặt trong vòng nóng sẽ giết register-keeping và vector hóa quanh nó, y như đo được. Đặt rào ở đúng một điểm cần thiết (một lần, không phải mỗi vòng), và hiểu rằng cái bạn trả không phải "một lệnh" mà là "vùng tối ưu bị đóng băng".

Điều thứ hai: chọn đúng loại rào cho đúng việc. Trong một luồng — ví dụ chặn compiler dời một phép đo thời gian ra khỏi đoạn cần đo, hay ép thứ tự ghi log gỡ lỗi — rào biên dịch (0 lệnh) là đủ và rẻ nhất về mặt lệnh. Chỉ khi thứ tự phải hiện ra với lõi khác (đồng bộ đa luồng) hay với thiết bị (MMIO) thì mới cần rào phần cứng với dmb/mfence. Dùng rào phần cứng khi chỉ cần rào biên dịch là thừa; dùng rào biên dịch khi cần rào phần cứng là bug tiềm ẩn khó lần.

Và cái ý "một chỉ thị thứ tự chẳng tốn lệnh nào mà lại chặn cả một vùng tối ưu" lặp lại y hệt ở tầng cao. Java: một trường volatile không sinh "lệnh nặng" ở chỗ khai báo, nhưng mỗi lần đọc/ghi nó dựng một happens-before và một rào (trên x86 gần như miễn phí, trên ARM là dmb thật), khiến JIT không được sắp lại quanh nó — chi phí thật là tối ưu bị cấm, đúng như C. Go phơi bày qua sync/atomic và mô hình bộ nhớ của nó, cũng vậy. Rõ nhất là CSDL: một BEGIN ... COMMIT hay một mức cô lập cao hơn không phải là "một lệnh đắt", mà là một rào về thứ tự và hiển thị buộc engine bớt sắp lại/gối chồng các thao tác — bạn trả bằng throughput mất đi, không bằng một chỉ thị nặng. Mẫu số chung ở mọi tầng: rào không tính tiền bằng chu kỳ của bản thân nó, mà bằng những tối ưu nó cấm ở xung quanh — và cái giá đó gần như luôn vô hình nếu bạn chỉ nhìn vào lệnh.

Muốn tự thấy thì viết long acc=0; for(int i=0;i<100000000;i++){ acc+=a[i]; } và đo. Rồi thêm asm volatile("":::"memory"); vào cuối thân vòng và đo lại — bạn sẽ thấy nó chậm hẳn, dù bạn "không thêm lệnh nào". Xem vì sao bằng gcc -O2 -S -o - t.c: bản có rào giờ có một ldr/str của acc bên trong vòng, còn bản không rào giữ acc trong thanh ghi (chỉ một str sau vòng). Rồi objdump -d và grep dmb — bạn sẽ không thấy dmb nào cho rào biên dịch. Ba mươi giây đó cho bạn thấy một rào có thể là 0 lệnh mà vẫn đắt: cái nó lấy đi là quyền tối ưu, không phải chu kỳ của một lệnh.