Bạn tra bảng chi phí lệnh và thấy "atomic đắt hơn lệnh thường khoảng X lần" — một con số duy nhất, như thể chi phí ấy cố định. Phần 9 đã hé lộ atomic không cố định theo memory_order; phần này đo một chiều còn quan trọng hơn: chi phí atomic biến thiên mạnh theo mức tranh chấp. Cùng một phép fetch_add, nó có thể rẻ như một phép tính bình thường, hoặc đắt gấp hàng chục lần — tùy có bao nhiêu lõi cùng giành một biến. Tôi đo trong container gcc:13 trên host ARM, và khoảng biến thiên lớn đến bất ngờ.
Atomic: giá của nguyên tử, và giá của giành nhau
Một phép atomic (như fetch_add, compare_exchange) bảo đảm thao tác đọc-sửa-ghi diễn ra không bị xen bởi lõi khác. Có hai nguồn chi phí rất khác nhau. Một là tính nguyên tử tự nó: ngay cả khi không ai tranh, phép atomic vẫn nặng hơn một lệnh thường vì phần cứng phải khóa/độc quyền dòng cache trong lúc thao tác. Hai là tranh chấp: khi nhiều lõi cùng ghi một biến atomic, dòng cache chứa nó bị giật qua giật lại giữa các lõi (coherence ping-pong), và chi phí này bùng nổ theo số lõi.
Tôi đo ba tình huống: (A) một luồng tăng biến thường so với atomic (không tranh chấp); (B) N luồng cùng đập một atomic (tranh chấp cao); (C) N luồng, mỗi luồng một atomic riêng, đệm ra dòng cache khác nhau (không tranh chấp).
Đo: từ 6,5 lần tới 74 lần
Host ARM, ns mỗi thao tác:
1 LUỒNG (không tranh chấp):
plain x++ : 0,244 ns
atomic fetch_add : 1,591 ns <- ~6,5x plain (giá của nguyên tử)
N LUỒNG cùng đập 1 atomic (tranh chấp):
T=1 : 1,60 T=2 : 2,94 T=4 : 6,84 T=8 : 18,08 ns/op
N LUỒNG, mỗi luồng atomic RIÊNG (đệm dòng riêng):
T=8 : 0,250 ns/op (plain riêng: 0,192 ns)
Đọc từng tầng. Một luồng: atomic 1,591 ns so với lệnh thường 0,244 ns — gấp 6,5 lần. Đây là giá của tính nguyên tử, và nó là sàn — không thể rẻ hơn. (Trên ARM, relaxed và seq_cst đều ~1,59 ns như nhau, phần 9.)
Nhiều luồng cùng một atomic: chi phí leo dốc theo số lõi — 1,60 ns (1 luồng) → 2,94 → 6,84 → 18,08 ns ở 8 luồng. Càng nhiều lõi giành một biến, dòng cache càng ping-pong. Ở 8 luồng, mỗi fetch_add ngốn 18 ns — gấp ~11 lần atomic không tranh chấp và gấp ~74 lần một lệnh thường. Đây là con số mà "atomic đắt hơn X lần" cố định không bao giờ nắm được.
Nhiều luồng, mỗi luồng atomic riêng (đệm ra dòng cache khác nhau): 0,250 ns/op ở 8 luồng — scale gần hoàn hảo, và gần bằng lệnh thường riêng (0,192 ns). Khi không có tranh chấp, atomic rẻ — thậm chí song song hóa tốt. So sánh này phơi bày sự thật: cái đắt không phải bản thân phép atomic, mà là nhiều lõi cùng giành một biến.
Một lần tôi đo hớ: "atomic có một chi phí cố định" và "atomic luôn đắt nên tránh"
Tôi vào đo với thói quen tra-bảng: "atomic đắt hơn lệnh thường một số lần cố định, cứ nhân lên là ra". Đo phá tan: chi phí atomic trải từ ~1,6 ns (một luồng) tới 18 ns (tám luồng chung) — chênh 11 lần chỉ do tranh chấp, chưa kể so với lệnh thường thì từ 6,5x tới 74x. Không có "một con số" cho atomic; phải hỏi bao nhiêu lõi cùng chạm biến này. Nếu tôi ước lượng bằng con số cố định, tôi sai một-chục-lần ở đúng chỗ nóng nhất (biến bị tranh chấp).
Nhưng đo cũng phá một niềm tin ngược: "atomic luôn đắt, phải tránh bằng mọi giá". Sai: atomic không tranh chấp rẻ (1,6 ns một luồng), và atomic riêng mỗi luồng chỉ 0,25 ns ở tám luồng — gần bằng lệnh thường, scale tốt. Cái cần tránh không phải phép atomic mà là tranh chấp: nhiều lõi giành một biến. Một bộ đếm phân mảnh (mỗi luồng một atomic riêng) rồi gộp cuối vừa đúng đắn vừa nhanh. Sợ atomic đến mức né hết là bỏ lỡ những chỗ nó gần như miễn phí.
Bài học đo lường: chi phí atomic BIẾN THIÊN theo TRANH CHẤP, không cố định. Đo (ns/op): 1 luồng atomic 1,59 vs plain 0,24 = ~6,5x (giá nguyên tử, là SÀN); 8 luồng CHUNG 1 atomic = 18 ns = ~11x atomic không tranh chấp, ~74x plain (dòng cache ping-pong); 8 luồng atomic RIÊNG/luồng (padded) = 0,25 ns, scale tốt gần bằng plain. 'Atomic một chi phí cố định X lần' và 'atomic luôn đắt nên tránh' đều SAI. Nếu tin "chi phí cố định" tôi ước sai chục lần ở biến nóng; nếu tin "luôn đắt, tránh hết" tôi bỏ lỡ atomic per-thread gần như miễn phí.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: giảm tranh chấp, đừng chỉ giảm atomic. Thay một bộ đếm/tổng chung cho mọi luồng bằng bộ đếm phân mảnh (mỗi luồng/mỗi shard một biến riêng, đệm chống false sharing) rồi gộp cuối. Bạn vẫn dùng atomic (đúng đắn), nhưng vì không lõi nào giành nhau, nó gần như miễn phí. Đây là gốc của mọi cấu trúc đồng thời hiệu năng cao: chia tải để không tranh.
Hệ quả thứ hai: giảm tần suất chạm biến chung. Nếu buộc phải có một biến dùng chung (ví dụ tổng toàn cục), hãy cập nhật nó thưa — tích lũy cục bộ rồi thỉnh thoảng mới đẩy lên biến chung — thay vì mỗi thao tác một fetch_add. Tranh chấp tỉ lệ với tần suất các lõi cùng ghi, nên giảm tần suất là giảm chi phí.
Hệ quả thứ ba là tinh thần đo lường: chi phí đồng bộ nằm ở chia sẻ, và nó là hàm của số lõi tranh — không phải một hằng số. Con số mang theo: atomic uncontended ~6,5x plain (sàn); shared contended tăng vọt theo T (8 luồng ~74x plain); per-thread atomic ~ bằng plain, scale tốt. Giảm tranh chấp (sharding, đệm, cập nhật thưa) chứ đừng sợ atomic. Cùng một fetch_add, đặt một luồng dùng thì rẻ, đặt tám luồng giành thì đắt bảy chục lần — đo mới thấy chi phí ấy sống động thế nào.
Thử ba mươi giây
Viết một bộ đếm và tăng nó vài chục triệu lần, đo ns mỗi lần, ba cách. Một: long x; x++ thường, một luồng — bạn được ~0,2 ns. Hai: std::atomic<long> a; a.fetch_add(1), một luồng — chậm hơn vài lần (giá của nguyên tử), nhưng vẫn là sàn. Ba: cho T luồng cùng fetch_add vào một atomic chung, chia thời gian cho tổng số phép — bạn sẽ thấy ns mỗi phép tăng vọt theo T (dòng cache giật giữa các lõi). Cuối cùng, cho mỗi luồng một atomic riêng căn alignas(128) và làm lại: lần này nó scale tốt, gần bằng lệnh thường. Chia con số tệ nhất (nhiều luồng chung) cho tốt nhất (per-thread): bạn sẽ thấy hàng chục lần, cùng một phép fetch_add. Ba mươi giây đó cho bạn thấy điều mà "atomic đắt hơn X lần" giấu đi: chi phí atomic không phải một con số — nó là hàm của bao nhiêu lõi cùng giành biến đó, và cách nhanh nhất để dùng atomic là đừng để chúng giành nhau.