Năm phần đầu của sê-ri đo từng chi phí riêng lẻ: tạo luồng, chuyển ngữ cảnh, khóa, atomic, false sharing. Giờ ta gộp lại thành câu hỏi thực tế nhất: khi tôi thêm luồng, chương trình có nhanh hơn không, và nhanh hơn bao nhiêu? Trực giác nói "thêm luồng thì nhanh hơn, càng nhiều càng nhanh". Tôi đo hai loại workload để xem đường cong tăng tốc thật — và chúng trái ngược nhau đến mức làm tan vỡ trực giác đó.
Hai đường cong, một câu hỏi
Tôi đo thông lượng (throughput — số thao tác mỗi giây) khi tăng số luồng từ 1 lên 16, cho hai workload trong container gcc:13 (10 lõi). Độc lập: mỗi luồng làm việc riêng của nó (cộng dồn vào một biến cục bộ, không chia sẻ ghi với ai). Tranh chấp: mọi luồng cùng atomic_fetch_add vào một biến chung. Với mỗi mức luồng, tôi tính speedup = thông lượng(T) / thông lượng(1 luồng).
ĐỘC LẬP (không chia sẻ):
T=1 : 3684 Mop/s (1,00×) T=2 : 7177 (1,95×)
T=8 : 14296 (3,88×) T=16: 17911 (4,86×)
TRANH CHẤP (một atomic chung):
T=1 : 625 Mop/s (1,00×) T=2 : 333 (0,53×!)
T=8 : 59 (0,09×) T=16: 38 (0,06×)
Hai đường cong đi hai hướng hoàn toàn khác nhau.
Độc lập scale lên, tranh chấp scale xuống
Workload độc lập tăng tốc khi thêm luồng — nhưng dưới tuyến tính: 1,95× ở 2 luồng (gần lý tưởng), rồi 3,88× ở 8, và 4,86× ở 16 luồng. Nó không đạt 10× hay 16× vì máy chỉ có 10 lõi (thêm luồng quá số lõi không thêm sức tính), và vì băng thông bộ nhớ cùng tần số CPU (giảm khi nhiều lõi bận) cũng giới hạn. Đường cong: leo dốc rồi chững quanh số lõi. Đây là scale "tốt" — thêm luồng có ích, tới một trần.
Workload tranh chấp thì ngược hẳn: thêm luồng làm nó chậm đi. Ở 2 luồng, thông lượng đã tụt xuống 0,53× — một nửa so với một luồng. Ở 16 luồng, chỉ còn 0,06× — tức 16 luồng làm việc chậm hơn 16 lần so với một luồng làm một mình. Đây là scale âm (negative scaling): mỗi luồng thêm vào không mang thêm việc, mà chỉ mang thêm tranh chấp — dòng cache của biến chung nảy giữa nhiều lõi, các luồng dẫm chân nhau. Thêm người vào một cánh cửa hẹp không làm dòng người qua nhanh hơn; nó làm tắc nghẽn.
Một lần tôi đo hớ: song song không tự động nhanh
Tôi vào đo với niềm tin gần như phổ quát: "thêm luồng thì nhanh hơn, và song song scale tuyến tính — 8 luồng nhanh gấp 8". Đo phá tan cả hai. Ngay cả workload tốt nhất (hoàn toàn độc lập) cũng chỉ đạt 4,86× trên ~10 lõi, không phải 16× — song song hoàn hảo là hiếm. Và workload tranh chấp không những không nhanh hơn, mà chậm đi 16 lần khi thêm luồng.
Sự thật, và là bài học đo lường của bài: song song không tự động nhanh — nó chỉ nhanh khi công việc thật sự ĐỘC LẬP. Cùng một số luồng, cùng một máy, hai workload cho hai kết quả trái ngược, và khác biệt duy nhất là có chia sẻ ghi hay không. Đây là lý do bạn không thể giả định một chương trình sẽ tăng tốc khi thêm luồng; bạn phải đo đường cong scale thật của nó. Nếu tôi tin "thêm luồng = nhanh" mà không đo, tôi đã có thể ném 16 luồng vào một điểm nóng tranh chấp và làm chương trình chậm đi 16 lần — một "tối ưu" phản tác dụng hoàn toàn.
Vượt số lõi: chững, rồi có thể xấu đi
Một chi tiết trong bảng đáng dừng lại: workload độc lập đạt 3,88× ở 8 luồng, 4,86× ở 16 luồng — nhưng máy chỉ có 10 lõi. Vì sao 16 luồng vẫn hơn 8 một chút dù chỉ 10 lõi? Vì một phần thời gian mỗi luồng chờ bộ nhớ, nên 16 luồng lấp được vài khoảng trống đó — nhưng lợi ích cận biên nhỏ dần và sẽ chững hẳn rồi có thể xấu đi khi số luồng vượt xa số lõi (oversubscription), vì hệ điều hành phải liên tục chuyển ngữ cảnh để chia thời gian — mỗi lần ~8,5 µs bị đốt cho việc quản lý thay vì tính. Với workload nặng CPU thuần, số luồng tối ưu thường quanh số lõi; nhiều hơn chỉ thêm chi phí lập lịch.
Và như mọi phần của sê-ri, đường cong scale này chỉ đáng tin khi đo đúng cách: lặp nhiều lần lấy giá trị tốt nhất (nhiễu lịch của máy ảo rất lớn ở đa luồng), làm nóng trước, và đo đủ lâu để lấn nhiễu đồng hồ. Một lần đo scale đơn lẻ dễ cho một con số lệch khiến bạn kết luận sai về khả năng song song của chương trình.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đo đường cong scale trước khi tăng số luồng. Đừng đoán "thêm luồng sẽ nhanh hơn"; chạy với 1, 2, 4, 8 luồng và vẽ speedup. Nếu đường cong chững sớm hoặc đi xuống, bạn có một điểm nóng tranh chấp — thêm luồng chỉ đốt tài nguyên. Con số scale thật cho bạn biết chương trình thực sự dùng được bao nhiêu lõi, thường ít hơn nhiều so với số lõi có.
Hệ quả thứ hai: thiết kế để độc lập. Cách duy nhất để đạt đường cong scale tốt là làm công việc thật sự song song — mỗi luồng chạm dữ liệu riêng, không chia sẻ ghi. Khi buộc phải tổng hợp, dùng mẫu "mỗi luồng một bản cục bộ, gộp một lần ở cuối" (như atomic tranh chấp và false sharing đã chỉ). Biến một điểm nóng chung thành N phép tính riêng là cách duy nhất để đường cong đi lên thay vì đi xuống.
Hệ quả thứ ba là tinh thần đo lường: hiệu năng song song là chuyện đo, không phải chuyện đếm luồng. Con số mang theo: workload ĐỘC LẬP (không chia sẻ ghi) scale gần tuyến tính rồi chững ở ~số lõi (4,86× với ~10 lõi — dưới tuyến tính vì băng thông/tần số); workload TRANH CHẤP (cùng ghi một biến) scale ÂM — 16 luồng chỉ bằng 6% một luồng, thêm luồng còn HẠI; vì thêm luồng vào điểm nóng chia sẻ chỉ thêm tranh chấp, không thêm việc. Trước khi tin nhiều luồng là nhanh, đo đường cong scale — và thiết kế để nó đi lên.
Thử ba mươi giây
Lấy một tác vụ song song bạn có và chạy nó với 1, 2, 4, rồi 8 luồng, ghi thời gian mỗi lần. Chia thời gian một luồng cho thời gian T luồng để ra speedup, rồi nhìn đường cong: nó tiến gần T (tốt), chững lại (giới hạn tài nguyên), hay đi xuống (tranh chấp)? Nếu đi xuống hoặc chững sớm, tìm điểm nóng chia sẻ — một biến, một khóa, một dòng cache mà mọi luồng cùng đụng. Ba mươi giây đó thay câu hỏi sai "tôi nên dùng bao nhiêu luồng" bằng câu hỏi đúng "chương trình của tôi scale được tới đâu, và cái gì đang chặn nó" — và chỉ đồng hồ mới trả lời được.