Khi dùng biến atomic, bạn phải chọn một mức thứ tự bộ nhớ (memory order): relaxed, acquire/release, hay seq_cst. Chúng khác nhau ở việc đảm bảo thứ tự các thao tác bộ nhớ khác quanh atomic đó — và người ta thường dạy rằng seq_cst (mặc định, an toàn nhất) đắt hơn relaxed (yếu nhất), nên "chọn relaxed để đi nhanh". Tôi vào đo trên ARM để xem cái giá thật, và phát hiện lời khuyên đó dựa trên một giả định sai về phần cứng hiện đại.
Ba mức thứ tự, và ARM là kiến trúc yếu
relaxed chỉ đảm bảo thao tác nguyên tử, không đảm bảo gì về thứ tự với các đọc/ghi khác — nhanh nhất về ngữ nghĩa. acquire/release đồng bộ một chiều: một store release "công bố" mọi ghi trước nó, một load acquire "nhìn thấy" chúng — nền tảng của mẫu producer-consumer. seq_cst mạnh nhất: mọi luồng thấy các thao tác seq_cst theo cùng một thứ tự toàn cục.
ARM là kiến trúc yếu thứ tự (weakly-ordered) — mặc định CPU được sắp lại các truy cập bộ nhớ khá tự do — nên trên ARM các mức này thật sự sinh ra lệnh máy khác nhau (khác với x86 vốn mạnh thứ tự sẵn, phần lớn ordering là miễn phí). Đây là lý do tôi đo trên ARM: để thấy sự khác biệt phần cứng thật. Tôi đọc assembly từng mức, rồi bấm giờ.
Đo: lệnh khác nhau, thời gian gần như giống
objdump cho thấy các mức sinh lệnh khác nhau:
store relaxed : str load relaxed : ldr fetch_add relaxed : ldadd (relax)
store release : stlr load acquire : ldar fetch_add seq_cst : ldadd (acq_rel)
store seq_cst : stlr load seq_cst : ldar
Điều quan trọng: relaxed dùng lệnh thường (str/ldr/ldadd), còn acquire/release/seq_cst dùng các lệnh có thứ tự sẵn của ARMv8 — stlr (store-release), ldar (load-acquire), ldaddal (rmw acquire-release). Đáng chú ý: seq_cst cho một load/store dùng chính ldar/stlr — không phải một rào dmb ish nặng nề như nhiều người tưởng. Bây giờ đo thời gian (một luồng, không tranh chấp):
store relaxed/release/seq_cst : 0,676 / 0,677 / 0,676 ns
load relaxed/acquire/seq_cst : 0,681 / 0,687 / 0,687 ns
fetch_add relaxed / seq_cst : 1,593 / 1,593 ns
Chênh lệch ~1% — gần như bằng nhau. Dù lệnh khác nhau, stlr/ldar/ldaddal có độ trễ gần y hệt str/ldr/ldadd trên lõi này, vì khi không có gì thật sự cần sắp thứ tự (một luồng), lệnh có-thứ-tự chẳng phải chờ gì. Và khi tôi đo có tranh chấp (nhiều luồng cùng fetch_add):
T=2: relaxed 3,8 | seq_cst 2,9 T=8: relaxed 19,2 | seq_cst 19,1
seq_cst không chậm hơn relaxed — cả hai bị cache-line bouncing chi phối, và mức thứ tự thêm vào gần như không đáng kể so với cái giá cache đó.
Một lần tôi đo hớ: ordering không phải nút tăng tốc
Tôi vào đo với niềm tin phổ biến: "seq_cst đắt hơn relaxed nhiều, nên hạ xuống relaxed (hoặc acquire/release) ở đường nóng để đi nhanh". Đo trên ARMv8 cho thấy khác biệt tốc độ rất nhỏ — một luồng chênh 1%, có tranh chấp thì seq_cst thậm chí không chậm hơn. Vì sao? Vì ARMv8 có sẵn các lệnh ldar/stlr/ldaddal thực thi acquire/release/seq_cst ngay trong lệnh load/store/rmw, không cần một rào dmb ish riêng. Cái dmb ish độc lập — thứ mà bài rào bộ nhớ đo là đắt — chỉ xuất hiện với atomic_thread_fence độc lập, không phải với ordering trên một thao tác atomic.
Đây là chỗ đo hớ được sửa theo hướng bất ngờ: hạ mức thứ tự để "đi nhanh" gần như không mang lại gì trên ARM, mà lại mở ra rủi ro sai. relaxed không đảm bảo thứ tự; nếu bạn dùng nó ở chỗ cần acquire/release (ví dụ công bố một con trỏ dữ liệu qua một cờ), chương trình sẽ chạy đúng phần lớn thời gian rồi thỉnh thoảng thấy dữ liệu chưa kịp ghi — một bug đua tranh cực khó lần. Bạn đánh đổi một correctness quý giá để lấy 1% tốc độ không có thật.
Bài học đo lường: đo cả cái giá lẫn cái được trước khi tối ưu. Ở đây cái được (tốc độ) gần như bằng không, còn cái mất (đúng đắn) là rất lớn. Nên trên ARM, hãy chọn mức thứ tự cho ĐÚNG NGỮ NGHĨA thuật toán, không phải cho tốc độ — và seq_cst mặc định gần như miễn phí, nên "cứ seq_cst cho chắc" thật ra là một mặc định hợp lý cho từng thao tác atomic.
Một lưu ý về tính khả chuyển
Con số "gần như miễn phí" ở trên là của ARMv8, và không tự động đúng ở mọi kiến trúc — đúng tinh thần đo ở đúng phần cứng. Trên x86, phần lớn ordering cũng miễn phí (x86 mạnh thứ tự sẵn: mọi load là acquire, mọi store là release một cách ngầm định), nhưng có một ngoại lệ nổi tiếng: một store seq_cst trên x86 cần một lệnh mfence (hoặc xchg) đắt, trong khi trên ARM nó chỉ là stlr. Nghĩa là bức tranh chi phí đảo ngược một phần giữa hai kiến trúc: chỗ ARM rẻ có thể x86 đắt và ngược lại. Bài học kép: (1) đừng khái quát một con số ordering từ máy này sang máy khác, và (2) dù chi phí khác nhau, kết luận thực dụng vẫn giống nhau — chọn ordering cho đúng, vì cả hai kiến trúc đều làm cái đúng đủ rẻ để không phải hy sinh nó. Nếu thật sự cần vắt kiệt, hãy đo trên chính phần cứng đích, đừng đoán.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chọn mức thứ tự theo ý nghĩa cần, không theo "yếu hơn thì nhanh hơn". Cần công bố dữ liệu qua một cờ → release khi đặt cờ, acquire khi đọc cờ. Cần một tổng nguyên tử không phụ thuộc thứ tự với gì khác (như bộ đếm thống kê) → relaxed là đúng (không phải để nhanh, mà vì nó đủ). Đừng dùng relaxed ở chỗ cần đồng bộ chỉ vì nghe nói nó nhanh — trên ARM nó không nhanh hơn đáng kể, và sai thì rất khó gỡ.
Hệ quả thứ hai: cái đắt thật là rào độc lập, không phải ordering trên atomic. Nếu bạn thấy dmb ish/mfence trong đường nóng (từ atomic_thread_fence hay volatile sai chỗ), đó mới là nơi đáng tối ưu. Ordering gắn liền trên một load/store/fetch_add thì gần như đi kèm miễn phí trên phần cứng có ldar/stlr.
Hệ quả thứ ba là tinh thần đo lường: một "tối ưu" phải chứng minh được cả lợi ích lẫn an toàn bằng số. Con số mang theo: trên ARMv8, ba mức memory ordering sinh lệnh khác nhau (relaxed str/ldr/ldadd; acquire/release stlr/ldar; seq_cst cũng stlr/ldar/ldaddal, KHÔNG phải dmb) nhưng thời gian gần như bằng nhau (~1% một luồng; seq_cst không chậm hơn relaxed khi tranh chấp vì cache nảy lấn át); nên hạ ordering để "đi nhanh" gần như vô ích mà mở ra bug — chọn mức cho đúng ngữ nghĩa, seq_cst mặc định gần như miễn phí, cái đắt là rào dmb độc lập. Đừng đổi đúng đắn lấy một tốc độ không tồn tại.
Thử ba mươi giây
Viết ba hàm: atomic_store_explicit(&x, v, memory_order_relaxed), ..._release, và ..._seq_cst; biên dịch gcc -O2 -S và so assembly. Trên ARM bạn sẽ thấy relaxed cho str, còn release và seq_cst cho stlr — lệnh khác nhau thật. Rồi bấm giờ mỗi hàm trong một vòng lớn: bạn sẽ thấy chúng gần như bằng nhau. Ba mươi giây đó phá tan một huyền thoại phổ biến — rằng "seq_cst chậm, relaxed nhanh" — ít nhất trên ARM hiện đại, và nhắc bạn rằng lựa chọn memory ordering là một quyết định về tính đúng đắn, nơi đồng hồ nói cho bạn biết bạn không phải trả gì để làm đúng.