Bạn có hai luồng, mỗi luồng tăng một bộ đếm riêng của nó hàng trăm triệu lần. Về mặt logic chúng hoàn toàn độc lập — không luồng nào đọc hay ghi dữ liệu của luồng kia. Vậy chúng phải chạy song song đầy đủ, mỗi luồng một lõi, không cản nhau. Nhưng nếu hai bộ đếm đó tình cờ nằm gần nhau trong bộ nhớ, đo ra chúng chậm hơn nhiều lần — vì một hiện tượng tên là false sharing (chia sẻ giả). Tôi đo nó với std::thread trong container gcc:13, và con số cho thấy hai biến "độc lập" vẫn có thể đua nhau tới mức chậm 5 lần, mà không hề chia sẻ một byte dữ liệu nào.

Cache line và false sharing

Cache đồng bộ theo cả dòng, không theo byte

Chìa khóa nằm ở chỗ phần 1 đã nói: CPU không quản lý bộ nhớ theo từng byte, mà theo cache line 64 byte. Và khi nhiều lõi cùng làm việc, chúng phải giữ cache nhất quán với nhau qua một giao thức (MESI/cache coherence) — đơn vị của sự nhất quán đó cũng là cả cache line, không phải byte.

Giờ xét hai bộ đếm nằm cùng một cache line (ví dụ hai long liền nhau — chỉ 16 byte, thừa sức chung một dòng 64 byte):

  • Lõi A tăng bộ đếm của nó. Vì nó ghi vào cache line đó, giao thức phải vô hiệu hóa (invalidate) bản sao của dòng đó ở cache lõi B — dù lõi B chẳng đụng gì tới bộ đếm của A.
  • Lõi B tăng bộ đếm của nó (cùng dòng). Cache của nó vừa bị vô hiệu, nên nó phải nạp lại cả dòng, rồi ghi — và việc ghi này lại vô hiệu bản của lõi A.

Kết quả: cache line bị ping-pong qua lại giữa hai lõi liên tục, mỗi lần một luồng ghi. Đây là false sharing: hai luồng không chia sẻ dữ liệu (mỗi cái một bộ đếm riêng), nhưng chia sẻ cache line, nên bị phạt y như thể chúng tranh nhau cùng một biến. Cái tên "giả" là vì sự chia sẻ chỉ tồn tại ở tầng cache line, không ở tầng logic.

Đo: 3,8–4,9 lần chậm, càng nhiều luồng càng tệ

Tôi cho mỗi luồng tăng bộ đếm riêng 200 triệu lần, so hai bố cục: các bộ đếm liền nhau (chung cache line — false sharing) và các bộ đếm padding ra mỗi cái một cache line riêng (alignas(64)):

Mỗi luồng tăng bộ đếm riêng 200 triệu lần (g++ -O2 -pthread, nproc=10):

luồng | false sharing | padded (64B) | chậm hơn
------|---------------|--------------|----------
  2   |    0,183 s    |    0,048 s   | 3,8x
  4   |    0,224 s    |    0,052 s   | 4,3x
  8   |    0,701 s    |    0,142 s   | 4,9x

Với 2 luồng, bản false sharing mất 0,183 giây còn bản padded chỉ 0,048 giây — chậm hơn 3,8 lần, dù cùng khối lượng công việc và các bộ đếm hoàn toàn độc lập về logic. Toàn bộ khác biệt là chi phí ping-pong cache line. Và nhìn khi tăng luồng: 4 luồng chậm 4,3×, 8 luồng chậm 4,9× — càng nhiều lõi tranh cùng một cache line, ping-pong càng dữ dội. Bản padded thì mở rộng đẹp (mỗi bộ đếm một cache line riêng, không lõi nào đụng dòng của lõi khác), nên nó tận dụng được nhiều lõi. Bản false sharing thì gần như không nhanh lên khi thêm luồng — cache coherence nuốt hết lợi ích song song.

Đây là loại lỗi hiệu năng ác nhất: code trông hoàn hảo, không có khóa, không có biến chia sẻ, "rõ ràng song song" — mà lại chậm như chạy tuần tự, và không một dòng nào cho thấy lý do.

Một lần tôi đo hớ: "biến độc lập thì luồng độc lập"

Tôi vào đo với niềm tin hiển nhiên: "mỗi luồng ghi biến riêng của nó, không chia sẻ gì, nên chúng chạy độc lập, thêm luồng thì nhanh lên". Đo phá tan: hai bộ đếm riêng nằm cùng cache line làm chương trình chậm 3,8–4,9 lần, và thêm luồng gần như không giúp. Sự "độc lập" ở tầng logic không đảm bảo độc lập ở tầng phần cứng — cache coherence làm việc theo cache line, nên hai biến khác nhau chung một dòng vẫn kéo nhau xuống. Còn niềm tin sửa lỗi ngây thơ: "chỉ cần tách hai biến ra là xong". Cũng chưa đủ — hai biến tách nhau trong khai báo vẫn có thể rơi vào cùng một cache line (chúng chỉ cách nhau vài byte). Phải padding để đẩy chúng sang các cache line khác nhau (alignas(64) hoặc chèn đệm 64 byte).

Bài học đo lường: false sharing — hai luồng ghi hai biến RIÊNG nhưng chung một cache line 64B — làm chậm 3,8–4,9x (càng nhiều luồng càng tệ) vì dòng cache bị ping-pong giữa các lõi (ghi vô hiệu bản sao ở lõi khác), dù không chia sẻ dữ liệu logic nào; sửa bằng cách PAD mỗi biến sang cache line riêng (alignas 64), tách biến thường vẫn chung dòng. Nếu tôi tin "biến độc lập là đủ" và đặt các bộ đếm/biến-mỗi-luồng liền nhau trong một mảng, tôi tạo ra một chương trình đa luồng không mở rộng — thêm lõi mà không nhanh hơn — và profiler chỉ vào "vòng lặp tăng biến" trông vô tội.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: dữ liệu ghi riêng cho từng luồng phải nằm trên cache line riêng. Mẫu điển hình gây lỗi: một mảng kết quả results[num_threads] mà mỗi luồng ghi vào ô của mình — các ô liền nhau, chung cache line, false sharing. Sửa bằng cách padding mỗi ô ra 64 byte (alignas(64) cho phần tử, hoặc cho mỗi luồng một biến cục bộ rồi gộp lại một lần ở cuối). Đây là một trong những nguyên nhân phổ biến nhất khiến code đa luồng "đúng mà chậm".

Hệ quả thứ hai: gộp cục bộ rồi mới ghi chung. Thay vì mỗi luồng ghi liên tục vào bộ nhớ chia sẻ (dễ false sharing), cho mỗi luồng tích lũy vào một biến cục bộ trên stack của nó (nằm ở cache line riêng tự nhiên), và chỉ ghi kết quả cuối vào bộ nhớ chung một lần. Đây là mẫu "reduce" — vừa tránh false sharing vừa giảm tranh chấp, và là lý do các thư viện song song (OpenMP, TBB) có toán tử reduction.

Hệ quả thứ ba là tinh thần đo lường: "độc lập về logic" không đảm bảo "độc lập về phần cứng" — cache line là ranh giới thật. Con số mang theo: false sharing = hai luồng ghi hai biến RIÊNG cùng một cache line 64B, làm chậm 3,8–4,9x (đo 2/4/8 luồng, càng nhiều càng tệ) vì ghi làm vô hiệu bản sao dòng ở lõi khác -> ping-pong dòng cache (MESI), dù không chia sẻ dữ liệu; sửa bằng PAD mỗi biến sang cache line riêng (alignas 64) — bản padded nhanh 3,8–4,9x và mở rộng theo lõi. Tách biến chưa đủ, phải tách sang cache line KHÁC. Hai byte cạnh nhau trong bộ nhớ có thể biến hai luồng độc lập thành hai luồng tranh nhau.

Thử ba mươi giây

Viết một chương trình hai luồng, mỗi luồng tăng một phần tử của mảng long counter[2] vài trăm triệu lần (counter[0] cho luồng 0, counter[1] cho luồng 1). Bấm giờ. Rồi đổi sang một cấu trúc padding — mỗi bộ đếm bọc trong struct alignas(64) { long v; char pad[56]; } để chúng nằm ở hai cache line khác nhau — và đo lại. Bạn sẽ thấy bản padding nhanh hơn nhiều lần, dù logic y hệt: cùng số phép tăng, cùng số luồng, chỉ khác khoảng cách giữa hai biến trong bộ nhớ. Rồi tăng lên 4, 8 luồng và xem bản không padding càng lúc càng tệ. Ba mươi giây đó cho bạn thấy điều mà "code song song, không chia sẻ biến" giấu đi: phần cứng đồng bộ cache theo cả dòng 64 byte, nên hai biến độc lập ở cạnh nhau vẫn có thể ping-pong một cache line qua lại giữa các lõi — và khoảng cách vài chục byte là ranh giới giữa "mở rộng tốt" và "thêm lõi mà không nhanh hơn".