Bạn viết một chương trình đa luồng "sạch": mỗi luồng có bộ đếm riêng, không luồng nào đụng biến của luồng khác, không khóa, không atomic. Về logic, chúng hoàn toàn độc lập — phải chạy song song hoàn hảo. Nhưng đo lại thấy chậm gấp mấy lần khi thêm luồng. Thủ phạm là một cạm bẫy vô hình ở tầng phần cứng: false sharing (chia sẻ giả). Vì bộ nhớ được quản lý theo dòng cache, hai biến riêng mà tình cờ nằm chung một dòng sẽ khiến dòng đó bị giật qua giật lại giữa các lõi. Tôi đo trong container gcc:13 trên host ARM, và con số cho thấy các biến "độc lập" có thể đâm nhau thế nào.
Coherence theo dòng, không theo biến
Mỗi lõi CPU có cache riêng, và phần cứng phải giữ chúng nhất quán (cache coherence): nếu lõi A ghi vào một dòng cache mà lõi B cũng đang giữ bản sao, dòng đó phải chuyển quyền sở hữu sang A và vô hiệu bản của B. Lần sau B ghi, nó lại giật về — một cuộc ping-pong tốn hàng chục tới trăm chu kỳ mỗi lần chuyển.
Điểm mấu chốt: đơn vị của coherence là cả dòng cache (128 byte trên máy này, phần 6), không phải từng biến. Nên nếu hai biến riêng của hai luồng tình cờ nằm chung một dòng, mỗi lần một luồng ghi biến của mình, phần cứng vẫn giật cả dòng khỏi lõi kia — dù hai biến chẳng liên quan gì nhau về mặt logic. Hai luồng tưởng độc lập, nhưng phần cứng thấy chúng chia sẻ một dòng. Đó là false sharing.
Để đo, tôi cho T luồng, mỗi luồng tăng một bộ đếm riêng 50 triệu lần, theo hai bố cục: gói sát — 8 bộ đếm long liền nhau (64 byte, chung một dòng 128B); và padded — mỗi bộ đếm căn alignas(128) để nằm ở dòng cache riêng.
Đo: cùng code, chỉ khác bố cục bộ nhớ
Mỗi luồng tăng bộ đếm RIÊNG 50 triệu lần; ns/tăng (wall time / iter), host ARM:
T | gói sát (chung dòng) | padded (dòng riêng) | chậm hơn
--|----------------------|---------------------|----------
1 | 0,653 ns | 0,716 ns | 0,9x
2 | 1,121 ns | 0,855 ns | 1,3x
4 | 1,762 ns | 0,909 ns | 1,9x
8 | 3,997 ns | 1,561 ns | 2,6x
Đọc bảng: với 1 luồng, hai bố cục ngang nhau (không có ai để tranh dòng). Nhưng khi thêm luồng, bản gói sát chậm dần so với padded: 1,3x ở 2 luồng, 1,9x ở 4, và 2,6x ở 8 luồng. Cùng một đoạn code, cùng số phép tăng, mỗi luồng ghi biến riêng của nó — khác biệt duy nhất là các bộ đếm có nằm chung dòng cache hay không. Phần chậm thêm ấy là thuần chi phí coherence: dòng cache chứa cả 8 bộ đếm bị ping-pong giữa các lõi mỗi lần bất kỳ luồng nào ghi.
Một điều quan trọng: đây là ghi thường, không atomic, không khóa. Vậy mà vẫn trả giá đồng bộ — vì sự "đồng bộ" ở đây không phải do bạn viết, mà do phần cứng bắt buộc để giữ cache nhất quán. Bản padded (mỗi biến một dòng riêng) không có ai tranh dòng nên scale tốt hơn hẳn.
Nói cho công bằng: trên lõi Apple Silicon này, coherence khá hiệu quả nên false sharing chỉ ~2,6x — nhẹ hơn con số 10–100x hay gặp trên x86 đời cũ. (Bản padded cũng chậm nhẹ ở 8 luồng, có thể do lõi hiệu năng và lõi tiết kiệm điện không đều nhau; vì thế nên nhìn tỉ lệ gói-sát/padded để tách riêng chi phí false sharing.) Nhưng dù nhẹ hơn, hiệu ứng có thật và tăng theo số luồng — càng nhiều lõi càng đau.
Một lần tôi đo hớ: "biến riêng thì không đụng nhau" và "phải atomic mới tốn"
Tôi vào đo với một niềm tin rất hợp lý: "mỗi luồng có biến riêng, không dùng chung, thì chúng độc lập — chạy song song thoải mái, không tốn gì". Đo phá tan: các biến độc lập về logic vẫn đâm nhau về vật lý nếu chung dòng cache — gói sát chậm 2,6x padded ở 8 luồng. Phần cứng không thấy "biến"; nó thấy "dòng cache". Nếu tôi tin "biến riêng là an toàn" và xếp mảng bộ đếm-mỗi-luồng sát nhau, tôi tự tạo một nút thắt coherence mà không dòng code nào cho thấy.
Nhưng đo cũng phá một niềm tin ngược: "chi phí đồng bộ chỉ đến từ atomic hoặc khóa; ghi thường thì miễn phí". Sai — đây là phép tăng counter++ thường, không atomic, không lock, mà vẫn trả giá coherence khi các biến chung dòng — khác với chi phí atomic/barrier bạn chủ ý dùng. Sự đồng bộ ẩn ở tầng phần cứng: mọi ghi vào một dòng đang được nhiều lõi giữ đều kích hoạt ping-pong, bất kể bạn có dùng atomic hay không. Chi phí song song không chỉ nằm ở những chỗ bạn chủ ý đồng bộ, mà cả ở bố cục bộ nhớ bạn vô tình tạo ra.
Bài học đo lường: coherence quản lý theo DÒNG CACHE (128B), không theo biến — nên hai biến RIÊNG chung dòng gây FALSE SHARING: dòng ping-pong giữa lõi. Đo: 8 luồng tăng bộ đếm riêng, gói sát (chung dòng) chậm 2,6x padded (alignas 128) — thuần coherence, dù logic độc lập, dù ghi THƯỜNG không atomic. Padding sửa được. (ARM Apple ~2,6x, nhẹ hơn x86 10-100x nhưng tăng theo T.) 'Biến riêng thì không đụng nhau' và 'phải atomic mới tốn đồng bộ' đều SAI. Nếu tin "biến riêng an toàn" tôi gói bộ đếm sát nhau và chết vì false sharing; nếu tin "chỉ atomic mới tốn" tôi bỏ qua chi phí coherence của ghi thường.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đệm (padding) các biến bị nhiều luồng ghi ra dòng cache riêng. Mảng bộ đếm-mỗi-luồng, các trường "hot" mà nhiều luồng cập nhật, kết quả cục bộ trước khi gộp — hãy căn lề alignas(64)/alignas(128) hoặc chèn padding để mỗi cái một dòng. Đây là lý do các cấu trúc đồng thời hiệu năng cao (bộ đếm phân mảnh, hàng đợi, thống kê per-thread) luôn đệm dữ liệu per-thread.
Hệ quả thứ hai: cảnh giác với các struct chia sẻ có trường ghi bởi nhiều luồng. Một struct mà luồng A ghi trường x và luồng B ghi trường y liền kề sẽ false-share dù A, B không bao giờ đụng biến của nhau. Tách các trường "nóng theo luồng" ra, hoặc padding giữa chúng. Công cụ như perf c2c giúp tìm false sharing thật, nhưng biết nguyên lý là bước đầu.
Hệ quả thứ ba là tinh thần đo lường: song song không tự do — phần cứng đồng bộ theo dòng cache sau lưng bạn. Con số mang theo: false sharing (biến riêng chung dòng) làm ghi thường chậm tới 2,6x ở 8 luồng (dòng ping-pong coherence), dù không atomic; padding/alignas(128) tách dòng là sửa. Không chỉ atomic/lock mới tốn đồng bộ. Cùng một chương trình "mỗi luồng việc riêng", đặt các biến gần hay xa nhau trong bộ nhớ quyết định nó scale hay nghẹt — đo mới thấy cái bẫy vô hình ấy.
Thử ba mươi giây
Viết một chương trình T luồng, mỗi luồng tăng một bộ đếm riêng của nó vài chục triệu lần (dùng volatile để không bị tối ưu mất), và bấm giờ tổng thời gian. Làm hai phiên bản: một, các bộ đếm là một mảng long counters[T] (liền nhau, chung dòng cache); hai, mỗi bộ đếm bọc trong struct alignas(128) { long v; } (mỗi cái một dòng riêng). Chạy với T = 1, 2, 4, 8 và so. Bạn sẽ thấy phiên bản mảng liền chậm dần so với phiên bản đệm khi thêm luồng — dù không luồng nào đụng biến của luồng khác, không có atomic, không có khóa. Đó là false sharing: dòng cache chứa mảng bị giật qua giật lại giữa các lõi. Ba mươi giây đó cho bạn thấy điều mà "mỗi luồng biến riêng thì song song thoải mái" giấu đi: phần cứng giữ cache nhất quán theo dòng, không theo biến — nên chỉ cần hai biến riêng vô tình chung một dòng, chúng đã ngầm tranh nhau, và cách chữa đơn giản là đẩy chúng ra hai dòng khác nhau.