Hình dung một tài liệu quan trọng trong văn phòng. Nếu mọi người chỉ cần đọc nó, dễ thôi: photo ra mười bản, ai cầm bản của người nấy về bàn đọc, không phiền đến ai. Mười người đọc cùng lúc chẳng tốn gì. Nhưng nếu ai đó cần sửa tài liệu, rắc rối bắt đầu: chỉ được có một bản gốc có quyền sửa, và để sửa bạn phải cầm được đúng bản gốc đó — nghĩa là mọi bản photo kia lập tức thành lỗi thời phải hủy, và bản gốc phải được chuyền tay từ người vừa sửa sang bạn. Càng nhiều người muốn sửa, bản gốc càng phải chạy vòng quanh phòng, và mỗi lần chuyền tay tốn thời gian. Cache coherence của CPU vận hành y hệt: đọc chung thì photo thoải mái, ghi chung thì phải giành bản gốc.
Ở 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ó (bản photo). 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 đó (cầm lấy bản gốc, hủy mọi bản photo).
Đâ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 (ai cũng có bản photo). 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 — bản gốc chuyền tay 8 ngườ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 (một lần chuyền bản gốc). 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 (chỉ một bản gốc). Đâ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ôi trường đo: container gcc:13 trên host ARM AArch64, dòng cache 64B, biến atomic_long nằm riêng một dòng, số lấy sau warmup. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: đọc-chung gần miễn phí, ghi-chung đắt hàng trăm lần, và càng nhiều lõi ghi một dòng thì mỗi op càng chậm còn throughput tổng càng sụt.)
Hai niềm tin đối nghịch, cùng sai
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). Hai niềm tin sai gặp nhau ở một sự thật: 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ề đến code hằng ngày
Điều đầ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.
Điều 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. Tách dữ liệu hay-đổi khỏi dữ liệu chỉ-đọc.
Chỗ này cái bất đối xứng "đọc scale, ghi không" hiện lên ở mọi tầng, và nhận ra nó giải thích rất nhiều lựa chọn thiết kế quen thuộc. Java thu gọn đúng bài học "đừng để nhiều lõi ghi một dòng" thành công cụ: LongAdder (mỗi luồng một ô riêng thay AtomicLong nóng) và chú thích @Contended (đệm một trường ra riêng dòng cache để nó không bị nảy chung) — LMAX Disruptor nhồi padding vì lý do y hệt. Đồng thời, JVM cho phép chia sẻ tự do mọi object bất biến (record, String, trường final publish an toàn) chính vì đọc-chung rẻ — "đóng băng để chia sẻ" là một mẫu thiết kế, không phải sự cẩn thận thừa. Ở tầng database, đây đúng là câu chuyện read replica: bạn scale đọc gần như tùy ý bằng cách thêm bản sao chỉ-đọc (mỗi replica là một "bản Shared"), nhưng ghi thì phải về primary và lan truyền/vô hiệu hóa — nên ghi không scale ngang được, y hệt dòng cache chỉ có một bản gốc. Và mọi cache tầng ứng dụng (Redis, CDN) đều dựa vào "đọc nhiều hơn ghi rất nhiều" để đáng giá — cùng một vật lý. Mẫu số chung: kiến trúc nào cũng vậy, nhân bản để đọc thì rẻ, đồng bộ để ghi thì đắt, nên thiết kế xoay quanh việc giảm điểm ghi chung.
Muốn tự soi thì 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ù 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ì không có dòng cache nào nảy. Vài phút đó 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.