Ở NUMA và cục bộ bộ nhớ ta thấy nơi dữ liệu nằm quyết định tốc độ. Nhưng khi nhiều lõi cùng chạm một dữ liệu, có một tầng phần cứng khác vào cuộc: giao thức nhất quán cache (cache coherence). Nó là thứ khiến x = 5 trên lõi A được lõi B nhìn thấy đúng — một sự kỳ diệu trong suốt mà lập trình viên gần như không bao giờ nghĩ tới. Nhưng "trong suốt về ngữ nghĩa" không có nghĩa là "miễn phí về hiệu năng". Tôi đo cái giá của coherence, và phát hiện một bất đối xứng khổng lồ giữa đọc và ghi mà nhiều người không ngờ tới.
Coherence: giữ các bản sao nhất quán
Mỗi lõi CPU có cache riêng (L1, L2). Khi hai lõi cùng dùng một biến, mỗi lõi có thể giữ một bản sao của dòng cache chứa nó. Vấn đề: nếu lõi A sửa biến, bản sao trong cache lõi B thành cũ (stale). Giao thức coherence (kiểu MESI — Modified, Exclusive, Shared, Invalid) đảm bảo điều đó không bao giờ để lộ ra phần mềm: trước khi A ghi, nó phải vô hiệu hóa (invalidate) mọi bản sao ở lõi khác, giành quyền sở hữu độc quyền dòng đó.
Đây là điểm mấu chốt tôi muốn đo: đọc và ghi tốn khác nhau ghê gớm. Nhiều lõi cùng đọc một biến thì ổn — mỗi lõi giữ một bản "Shared", tất cả song song, không ai phiền ai. Nhưng ngay khi một lõi ghi, nó phải vô hiệu hóa tất cả các bản kia, và dòng cache "nảy" (bounce) từ lõi này sang lõi khác. Tôi đo cả hai trong container gcc:13 (ARM AArch64, dòng cache 64 byte), một biến atomic_long nằm riêng một dòng cache (để không lẫn false sharing).
Đo (a): ghi-chung đắt hơn đọc-chung 721 lần
Cho 8 luồng cùng thao tác lên một biến chung, hai chế độ: tất cả cùng đọc (atomic load) hay tất cả cùng ghi (atomic fetch_add):
ĐỌC-chung (8 luồng, atomic_load) : 0,05 ns / op
GHI-chung (8 luồng, fetch_add) : 33,0 ns / op
GHI / ĐỌC : 721× đắt hơn
Chênh lệch 721 lần trên cùng một biến, cùng số luồng — chỉ khác đọc hay ghi. Đọc-chung gần như miễn phí (0,05 ns/op): cả 8 lõi giữ một bản "Shared" của dòng cache, mỗi lõi đọc từ L1 của mình mà không cần hỏi ai. Không có tranh chấp vì đọc không làm dữ liệu cũ đi — mọi bản sao vẫn đúng. Ghi-chung thì thảm: mỗi lần một lõi ghi, nó phải vô hiệu hóa 7 bản kia và kéo dòng cache về cache của nó (trạng thái Modified); lõi tiếp theo muốn ghi lại phải kéo dòng đó từ lõi vừa ghi. Dòng cache nảy vòng quanh 8 lõi, mỗi lần nảy là một lần chuyển dữ liệu cache-to-cache tốn hàng chục ns.
Đây là một trong những bất đối xứng quan trọng nhất mà ít người để ý: dữ liệu chỉ-đọc chia sẻ tùy ý không sao, nhưng dữ liệu ghi chia sẻ là độc dược cho hiệu năng.
Đo (b): mỗi lần chuyển dòng tốn ~35 ns
Để đo trực tiếp cái giá của một lần dòng cache nảy giữa hai lõi, tôi làm một ping-pong: hai luồng thay phiên ghi vào cùng một biến cờ — luồng A đặt cờ = 1, luồng B thấy 1 thì đặt lại = 0, lặp lại. Mỗi lần đổi tay (handoff) buộc dòng cache chuyển từ lõi này sang lõi kia:
ping-pong 2 luồng: 35,4 ns / handoff (một lần chuyển dòng lõi-sang-lõi)
35,4 ns cho mỗi lần chuyển quyền sở hữu dòng cache giữa hai lõi. Con số này khớp với 33 ns/op ở phần (a) — vì cả hai đo cùng một thứ: chi phí một coherence miss, tức lần phần cứng phải chuyển một dòng cache đang bị lõi khác sở hữu. Để so, một lần đọc L1 hit là dưới 1 ns; coherence miss đắt hơn hàng chục lần. Đây là cùng con số nền ~35 ns mà cache-line bouncing ở bài atomic và CAS đã hé lộ — giờ đo tách bạch, nó chính là chi phí cơ bản của giao thức coherence.
Đo (c): càng nhiều lõi ghi, càng chậm
Nếu một lần nảy tốn ~35 ns, thì càng nhiều lõi cùng ghi một biến, dòng càng phải nảy giữa nhiều lõi hơn, và mỗi thao tác càng đắt. Đo fetch_add lên một biến chung với số luồng tăng dần:
NT | ns/op | throughput tổng
1 | 1,6 | 615 Mops/s
2 | 3,3 | 601 Mops/s (op chậm 2,0×)
4 | 8,1 | 494 Mops/s (op chậm 5,0×)
8 | 32,4 | 247 Mops/s (op chậm 19,9×)
Một luồng ghi biến của chính nó: 1,6 ns/op (không ai tranh, dòng nằm yên trong cache nó). Thêm luồng, mỗi thao tác chậm dần: 8 luồng thì mỗi op tốn 32 ns — chậm gần 20 lần so với một luồng. Và nhìn cột throughput tổng: nó không tăng mà sụt — từ 615 xuống 247 Mops/s. Thêm lõi làm chậm đi, vì tất cả tranh nhau cùng một dòng cache, và cái dòng đó chỉ có thể ở một chỗ tại một thời điểm. Đây đúng là scale âm ta đã gặp, giờ nhìn thấy nguyên nhân gốc rễ: giao thức coherence tuần tự hóa mọi lần ghi vào một dòng.
Một lần tôi đo hớ: coherence trong suốt nhưng không miễn phí
Tôi vào đo với hai niềm tin. Thứ nhất: "đọc hay ghi một biến dùng chung thì cũng như nhau thôi". Sai — đọc-chung 0,05 ns, ghi-chung 33 ns, khác 721 lần. Thứ hai, sâu hơn: "cache coherence là chuyện của phần cứng, trong suốt, tôi không cần bận tâm". Đúng một nửa — nó trong suốt về ngữ nghĩa (bạn không bao giờ đọc phải giá trị cũ), nhưng hoàn toàn không trong suốt về hiệu năng.
Cái làm tôi giật mình khi đo là mức độ bất đối xứng. Trực giác nói "chia sẻ dữ liệu giữa các luồng thì tốn" — nhưng nó không tốn đều. Chia sẻ để đọc gần như miễn phí (các bản Shared song song); chỉ ghi mới đắt (phải giành sở hữu độc quyền). Bài học đo lường: không phải "chia sẻ" tốn, mà "ghi vào cái được chia sẻ" tốn — và cái giá là một lần chuyển dòng cache lõi-sang-lõi (~35 ns), tuần tự hóa mọi lần ghi. Nếu tôi tin "chia sẻ nào cũng như nhau", tôi đã tránh cả những chia sẻ chỉ-đọc vô hại (bỏ lỡ tối ưu) hoặc xem nhẹ những chia sẻ ghi chết người (tạo điểm nóng). Coherence trong suốt đúng như thiết kế — nhưng đúng như tinh thần đo lường, chỉ đo mới cho thấy cái giá ẩn của nó, và cái giá đó bất đối xứng đến bất ngờ.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dữ liệu ghi-chia-sẻ là điểm nóng — giảm nó, không chỉ giảm khóa. Một biến đếm chung, một con trỏ đầu hàng đợi, một cờ trạng thái mà nhiều luồng ghi — mỗi cái là một dòng cache nảy giữa các lõi ở ~35 ns mỗi lần. Đây là lý do sâu xa của reduction (mỗi luồng gộp cục bộ rồi hợp), của thread-local storage, của sharding: tất cả đều loại bỏ điểm ghi chung, không chỉ bảo vệ nó bằng khóa. Khóa cũng chỉ là một dòng cache ghi-chung nữa.
Hệ quả thứ hai: chia sẻ chỉ-đọc thì thoải mái. Nếu dữ liệu chỉ được đọc bởi nhiều luồng (cấu hình, bảng tra bất biến), cứ chia sẻ tự do — coherence cho phép mọi lõi giữ bản Shared song song gần như miễn phí. Ranh giới nguy hiểm là chỗ có một người ghi vào cái nhiều người đọc: lần ghi đó vô hiệu hóa mọi bản, và nếu nó xảy ra thường xuyên, bạn mất lợi ích chia sẻ chỉ-đọc. Tách dữ liệu hay-đổi khỏi dữ liệu chỉ-đọc.
Hệ quả thứ ba là tinh thần đo lường: hiểu cái giá ẩn của thứ "trong suốt". Con số mang theo: cache coherence trong suốt về ngữ nghĩa nhưng KHÔNG miễn phí — nhiều lõi cùng ĐỌC một biến gần như miễn phí (0,05 ns/op, mọi lõi giữ bản Shared song song) nhưng cùng GHI thì đắt 721 lần (33 ns/op) vì mỗi ghi invalidate mọi bản và dòng cache nảy lõi-sang-lõi (~35 ns/lần chuyển); càng nhiều lõi ghi 1 biến, mỗi op càng chậm (8 lõi 32 ns, chậm 19,9×) và throughput tổng SỤT (615->247 Mops/s). Không phải chia sẻ tốn, mà GHI vào cái chia sẻ tốn.
Thử ba mươi giây
Nhìn một cấu trúc dữ liệu nhiều luồng của bạn và phân loại mỗi trường: nó được đọc bởi nhiều luồng, hay được ghi bởi nhiều luồng? Những trường ghi-chung — biến đếm, con trỏ, cờ mà nhiều luồng cập nhật — là các dòng cache nảy quanh các lõi ở ~35 ns mỗi lần, và chúng tuần tự hóa chương trình song song của bạn dù bạn có bao nhiêu lõi. Thử một thí nghiệm: cho 8 luồng cùng tăng một biến đếm chung (atomic_fetch_add) hàng triệu lần và bấm giờ; rồi cho mỗi luồng tăng một biến đếm riêng của nó và cộng lại ở cuối — bấm giờ lại. Bản riêng sẽ nhanh hơn nhiều lần, dù kết quả y hệt, vì nó không có dòng cache nào nảy. Ba mươi giây đó dạy bạn phản xạ quan trọng nhất về coherence: đừng để nhiều lõi cùng ghi một chỗ — cho mỗi lõi chỗ riêng rồi gộp, vì cái giá thật của chia sẻ không nằm ở đọc, mà ở ghi.