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 trước về volatile tôi đã chạm tới chuyện này; bài này đo thẳng vào cái rào, và phát hiện cái đắt không nằm ở nơi tôi tưởng.

Sắp lại lệnh và rào bộ nhớ

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.

Rào phần cứngatomic_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 đo trong container gcc:13 (ARM AArch64): 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.

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àogrep 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.

Một lần tôi đo hớ: cái đắt không phải lệnh rào

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.

Bài học đo lường: đừ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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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".

Hệ quả 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.

Hệ quả thứ ba là bài học đo lường bao trùm: chi phí thật thường vô hình trong assembly. Con số mang theo: compiler được tự do sắp lại lệnh độc lập; rào biên dịch asm volatile("":::"memory") cấm nó nhưng sinh 0 lệnh máy (không có dmb) — đo được vẫn chậm 5,8 lần vì buộc acc đi bộ nhớ mỗi vòng thay vì ở thanh ghi; rào phần cứng atomic_thread_fence thêm một dmb ish thật nhưng chỉ chậm 5,2 lần (nhanh hơn ~11%), vì cái đắt là tối ưu bị chặn chứ không phải bản thân lệnh rào; và volatile giữ thứ tự giữa các volatile nhưng KHÔNG rào phần cứng, không đủ cho đồng bộ đa luồng. Khi tối ưu, đừng đổ lỗi cho lệnh dễ thấy — đo để biết cái nặng nằm ở đâu.

Thử ba mươi giây

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 -dgrep 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.