Hãy hình dung hai luồng, mỗi luồng tăng một biến đếm riêng của mình, không bao giờ đọc hay ghi biến của luồng kia. Về mặt logic, chúng độc lập hoàn toàn — không có gì để tranh chấp, nên đáng lẽ phải chạy song song hết tốc lực. Nhưng có một hiện tượng phần cứng khiến chúng chậm đi vài lần dù code không hề chia sẻ dữ liệu: false sharing (chia sẻ giả). Bài này đo nó trực tiếp — và cái làm tôi giật mình không phải con số, mà là chỗ nó ẩn nấp.
Chia sẻ "giả" qua cache line
CPU không quản bộ nhớ cache theo từng biến, mà theo dòng cache (cache line) — những khối 64 byte. Khi một nhân đọc một biến, nó nạp cả dòng 64 byte chứa biến đó vào cache của mình. Và khi một nhân ghi vào bất kỳ byte nào trong dòng, giao thức đồng bộ cache (cache coherency) phải vô hiệu hóa bản sao của dòng đó ở mọi nhân khác — buộc chúng nạp lại từ đầu.
Đây là gốc của false sharing: nếu hai biến của hai luồng tình cờ nằm chung một dòng cache, thì dù chúng khác nhau hoàn toàn về logic, mỗi lần luồng A ghi biến của nó, dòng cache chứa cả hai biến bị vô hiệu ở nhân của luồng B, và ngược lại. Dòng cache "nảy" qua lại giữa hai nhân liên tục, mỗi lần nảy tốn hàng chục nano giây. Hai luồng "chia sẻ" một dòng cache mà không hề chia sẻ dữ liệu — chia sẻ giả. Tôi muốn đo cái giá của nó.
Đo: chung dòng chậm 3,7 lần, và thủ phạm là nảy giữa nhân
Tôi cho hai luồng, mỗi luồng tăng một biến long riêng 500 triệu lần, đặt hai biến cách nhau một khoảng gap byte điều khiển được, rồi đo thông lượng. (Cỡ dòng cache tôi đọc ra là 64 byte, không đoán.)
| Khoảng cách hai biến | Thông lượng (2 luồng) |
|---|---|
| 8 byte (chung một dòng) | 2.038 triệu ++/s |
| 64 byte (khác dòng) | 7.607 triệu ++/s |
| 128 / 256 byte | ~7.600 (như 64) |
Con số rõ ràng: khi hai biến cách nhau 8 byte — nằm chung một dòng cache 64 byte — thông lượng chỉ 2.038 triệu phép tăng mỗi giây. Khi tách chúng ra 64 byte (rơi vào hai dòng khác nhau), thông lượng vọt lên 7.607 — nhanh gấp 3,7 lần. Đẩy khoảng cách lên 128 hay 256 byte không nhanh thêm, vì 64 đã đủ tách dòng. Chỉ một khoảng đệm bằng đúng một dòng cache biến hai luồng "vướng nhau" thành hai luồng thật sự song song.
Để chắc chắn thủ phạm là nảy giữa các nhân, tôi ghim cả hai luồng vào cùng một nhân (dù vẫn chung dòng cache): thông lượng lên 4.073 — không còn chậm như khi chúng ở hai nhân, vì trên một nhân không có dòng cache nào phải nảy sang nhân khác. Ép chúng về hai nhân khác nhau thì lại chậm về 2.640. Điều này khẳng định: false sharing là chi phí của việc đồng bộ một dòng cache qua lại giữa các nhân, không phải chuyện gì khác.
Một lần tôi đo hớ: tranh chấp vô hình trong mã nguồn
Điều làm tôi giật mình không phải con số 3,7 lần, mà là chỗ tranh chấp này ẩn. Hai luồng trong phép đo không chia sẻ một mẩu dữ liệu nào trong code — mỗi luồng có biến riêng, không bao giờ chạm biến của luồng kia. Nếu chỉ đọc mã nguồn, tôi sẽ khẳng định chắc nịch "hai luồng này độc lập hoàn toàn, không thể có tranh chấp". Và tôi sẽ sai, vì tranh chấp không nằm ở mã nguồn — nó nằm ở bố trí bộ nhớ.
Đây là bài học đo lường tinh vi nhất của cả sê-ri về loại này: có những nút thắt hiệu năng hoàn toàn vô hình ở tầng mã nguồn, chỉ lộ ra khi biết dữ liệu được xếp thế nào trong bộ nhớ và phần cứng nhóm chúng ra sao. Đồng bộ cache làm việc theo dòng 64 byte, không theo biến — nên hai biến cạnh nhau bị buộc chung số phận dù logic chương trình coi chúng tách biệt. Đọc code cả ngày không thấy; chỉ có đo, cộng với hiểu tầng cache, mới lộ. Và tôi khẳng định được nguyên nhân bằng cách tách biến: ghim hai luồng vào một nhân thì hết chậm — đúng phương pháp cô lập để tìm thủ phạm thật.
Có một cái bẫy đi kèm mà tôi tránh được nhờ kiểm thay vì đoán: cỡ dòng cache. Ở đây máy báo 64 byte và đệm 64 byte là đủ. Nhưng lời khuyên "đệm 64 byte" không phải chân lý phổ quát — chip Apple Silicon thật có dòng cache 128 byte, nên trên đó đệm 64 vẫn còn false sharing, phải đệm 128. Đừng bê trực giác phần cứng cũ sang phần cứng mới; đo cỡ dòng của đúng máy đích.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là cảnh giác với các struct/mảng mà nhiều luồng ghi vào các phần khác nhau. Kiểu kinh điển: một mảng bộ đếm mà mỗi luồng tăng một phần tử (counters[thread_id]++) — nghe rất hợp lý, nhưng các phần tử long chỉ cách nhau 8 byte, tám phần tử nhét chung một dòng cache 64 byte, và tám luồng làm dòng đó nảy điên cuồng. Đây là lý do một đoạn "song song" lại chậm hơn cả tuần tự. Cách sửa: đệm mỗi phần tử ra một dòng cache riêng (struct { long v; char pad[56]; } căn 64 byte), hay dùng biến cục bộ luồng rồi cộng dồn một lần ở cuối.
Hệ quả thứ hai là để các trường mà nhiều luồng cùng ghi vào những dòng cache riêng. Trong một struct dùng chung, nếu luồng A hay ghi trường x và luồng B hay ghi trường y, hãy đặt chúng vào hai dòng cache khác nhau (đệm giữa, hoặc alignas(64)). Nhiều thư viện hiệu năng cao (hàng đợi lock-free, bộ đếm nguyên tử theo nhân) đầy những padding trông thừa thãi — chúng chính là để tránh false sharing, và bỏ đi là mất vài lần tốc độ.
Hệ quả thứ ba, về đo lường: khi một chương trình song song chậm bất thường mà không thấy tranh chấp nào trong code, hãy nghi false sharing và nhìn xuống bố trí bộ nhớ. Con số mang theo: hai luồng ghi hai biến riêng, không chia sẻ gì về logic, vẫn chậm ~3,7 lần nếu hai biến chung một cache line (2.038 so với 7.607 triệu ++/s) — vì đồng bộ cache làm việc theo dòng 64 byte chứ không theo biến, và dòng nảy giữa các nhân; sửa bằng đệm cho mỗi biến một dòng riêng, và nhớ kiểm cỡ dòng (64 hay 128) của máy đích. Có những cái chậm không đọc code mà thấy — phải biết phần cứng nhóm dữ liệu thế nào.
Thử ba mươi giây
Xem cỡ dòng cache máy bạn: getconf LEVEL1_DCACHE_LINESIZE (thường 64; Apple Silicon 128). Trong code đa luồng, tìm những chỗ nhiều luồng ghi vào các phần tử liền kề của một mảng, hay các trường liền kề của một struct dùng chung — đó là các ổ false sharing tiềm tàng. Muốn thấy tận mắt, viết hai luồng cùng tăng hai biến: một lần để hai biến cạnh nhau trong một struct, một lần chèn char pad[64] giữa chúng, rồi so thời gian — bạn sẽ thấy bản có đệm nhanh gấp mấy lần dù logic y hệt, đúng cái bài này đo. Với công cụ chuyên sâu, perf c2c (cache-to-cache) chỉ thẳng ra dòng cache nào đang bị nhiều nhân tranh — vũ khí săn false sharing.