Suốt sê-ri ta đo từng mảnh của đồng thời, và định luật Amdahl đã đóng khung câu hỏi lớn: song song hóa nhanh lên được bao nhiêu. Giờ đến một câu thực tế hơn: khi bạn đo speedup để báo cáo — "chương trình của tôi scale bao nhiêu lần trên 10 lõi" — làm sao đo cho đúng? Nghe tưởng đơn giản: chạy 1 luồng, chạy N luồng, chia hai số. Nhưng chính phép chia đó chứa hai cái bẫy có thể biến một con số thất bại thành một con số đẹp giả tạo. Tôi đo cả ba khía cạnh — đường cong speedup, cái bẫy baseline, và strong so với weak scaling — trong container gcc:13 (10 lõi, ARM).

Đo scaling theo số lõi

Ba câu hỏi của scaling

"Scaling" không phải một con số duy nhất. Đo cho đúng cần trả lời ba câu tách bạch.

(a) Speedup dưới tuyến tính. Với một workload thuần CPU, kích thước cố định, thêm lõi thì nhanh lên bao nhiêu? Trực giác ngây thơ nói "10 lõi → 10 lần". Thực tế gần như luôn dưới tuyến tính, vì những thứ ta đã đo suốt sê-ri: phần tuần tự (Amdahl), băng thông bộ nhớ chung, và tranh chấp.

(b) Baseline nào? Speedup là một phân số — và mẫu số quyết định tất cả. Speedup thật phải chia cho bản tuần tự tốt nhất bạn có, không phải bản song song chạy với một luồng. Bản song song mang theo chi phí đồng bộ và chia việc mà bản tuần tự không có; lấy nó làm mốc là tự cho điểm.

(c) Strong hay weak? Strong scaling giữ bài toán cố định và thêm lõi để làm nhanh hơn — bị trần Amdahl. Weak scaling cho bài lớn hơn theo số lõi (mỗi lõi một phần việc cố định) — thường scale tốt hơn, đúng tinh thần Gustafson. Hai câu hỏi khác nhau cho hai kết luận khác nhau.

Đo: strong scaling dưới tuyến tính

Trước hết, đường cong strong scaling: một workload thuần CPU tổng công việc cố định (W đơn vị), chia đều cho N luồng, đo min qua nhiều lần chạy (đọc volatile mỗi vòng để -O2 không gấp vòng lặp thành hằng số):

N   | thời gian | speedup thật (tuần tự / song-N)
 1  | 19,16 ms  | 1,01x
 2  |  9,68 ms  | 1,99x   (gần lý tưởng)
 4  |  5,30 ms  | 3,63x
 8  |  4,28 ms  | 4,50x
10  |  4,14 ms  | 4,65x   <- trần thực tế

2 luồng speedup gần như hoàn hảo (1,99x) — hai lõi, hai đường tới bộ nhớ, ít tranh chấp. Nhưng đường cong cong xuống nhanh: 4 lõi chỉ được 3,63x (không phải 4x), và 10 lõi chỉ đạt 4,65x — chưa tới một nửa của 10x lý tưởng. Đây chính xác là hình dạng Amdahl cộng với trần băng thông: khi tất cả các lõi cùng đọc/ghi bộ nhớ chung, chúng tranh nhau đường tới RAM, và thêm lõi sau điểm nào đó gần như không thêm throughput. Con số 4,65x không phải lỗi đo — nó là sự thật vật lý của một máy 10 lõi chia sẻ một hệ thống nhớ.

Một lần tôi đo hớ: baseline sai thổi phồng con số

Tôi vào đo với niềm tin phổ biến nhất về scaling: "speedup = thời gian 1 luồng chia thời gian N luồng, thế là đủ". Ở phép đo trên nó có vẻ đúng, vì bản song song chạy 1 luồng nhanh gần bằng bản tuần tự (chênh ~1%) — code của tôi coarse-grained, chi phí pthread_create/join một lần là không đáng kể so với 19 ms tính toán. Nên self-relative và true speedup trùng nhau, và tôi suýt kết luận "baseline nào cũng như nhau".

Rồi tôi đo một workload khác: bản song song mang chi phí phối hợp thật — mỗi vòng cộng kết quả vào một bộ đếm chung bằng atomic (mô phỏng bookkeeping của một framework song song), thứ mà bản tuần tự tích lũy cục bộ nên không phải trả. Con số lộ ngay:

tuần tự tốt nhất (W)      :  82,6 ms
bản song song, 1 luồng (W): 510,2 ms   -> chậm hơn tuần tự 6,2 lần!

Bản song song chạy một luồng đã chậm hơn bản tuần tự 6,2 lần — vì cái atomic chung mỗi vòng. Giờ xem hai cách tính speedup phân kỳ ra sao:

        true speedup      self-relative speedup
N       (tuần tự / N)     (song-1 / N)
 1      0,16x             1,00x
 4      0,03x             0,16x
 8      0,01x             0,09x

Self-relative speedup bị thổi phồng đều 6,2 lần ở mọi mức N — vì nó chia cho một mẫu số (song-1) đã phồng sẵn 6,2 lần. Cột self khiến bức tranh "trông ổn": ở 1 luồng là 1,00x, nhìn như điểm khởi đầu bình thường. Cột true nói sự thật tàn nhẫn: bản song song này ở mọi cấu hình đều chậm hơn một vòng lặp tuần tự đơn giản (0,16x nghĩa là chậm hơn 6 lần). Nếu tôi báo cáo self-relative, tôi sẽ nói "song song hóa của tôi đạt 1x, hòa vốn" — trong khi thực tế nó là một khoản lỗ 6 lần. (Lưu ý: workload này còn scale âm vì atomic chung càng đông luồng càng tranh chấp — nhưng điểm ở đây là baseline, không phải hình dạng.)

Bài học đo lường: baseline sai không làm cong đường speedup, nó dịch cả đường lên một hệ số cố định — đúng bằng chi phí overhead của bản song song 1 luồng. Nếu overhead nhỏ (code coarse), hệ số ~1 và bạn may mắn. Nếu overhead lớn, hệ số có thể là 6x — đủ để giấu hoàn toàn việc song song hóa của bạn là một thất bại. Và trớ trêu: baseline chỉ quan trọng đúng lúc overhead lớn, tức đúng lúc bạn cần con số trung thực nhất. Luôn chia cho bản tuần tự tốt nhất, không phải bản song song hạ xuống một luồng.

Strong so với weak scaling

Câu hỏi cuối: strong scaling bi quan (trần 4,65x ở trên), nhưng đó chỉ là một cách nhìn. Weak scaling hỏi khác: cho mỗi luồng một lượng việc cố định và tăng cả bài toán theo số lõi — 1 lõi làm w, 10 lõi làm 10w. Nếu scale hoàn hảo, thời gian không đổi (mỗi lõi vẫn chỉ làm phần w của nó). Đo:

N   | tổng việc | thời gian | hiệu suất weak (thời gian không đổi = 100%)
 1  |     4000  | 2,02 ms   | 100%
 2  |     8000  | 2,00 ms   | 101%
 4  |    16000  | 2,35 ms   |  86%
 8  |    32000  | 3,73 ms   |  54%
10  |    40000  | 4,37 ms   |  46%

Ở 2 lõi weak scaling gần hoàn hảo (101%) — gấp đôi bài, gấp đôi lõi, cùng thời gian. Nhưng nó cũng xuống khi đông lõi: ở 10 lõi hiệu suất chỉ còn 46%, thời gian hơn gấp đôi thay vì giữ nguyên. Vì sao? Cùng thủ phạm: 10 lõi cùng đập vào một hệ thống nhớ chung, băng thông bão hòa — như NUMA và cục bộ bộ nhớ đã đo. Nhưng chú ý so sánh: ở 10 lõi, weak vẫn giữ 46% hiệu suất trong khi strong chỉ đạt 4,65/10 = 46,5% — trùng nhau ở đây vì cùng bị băng thông chặn, nhưng với workload ít chia sẻ bộ nhớ hơn, weak thường vượt strong rõ rệt. Bài học: báo cáo bạn đang đo strong hay weak — chúng trả lời hai câu khác nhau, và một hệ thống "chỉ scale 4,65x" (strong) có thể vẫn rất đáng dùng để giải bài lớn hơn trong cùng thời gian (weak).

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

Hệ quả đầu tiên: luôn lấy baseline là bản tuần tự tốt nhất, không phải bản song song 1 luồng. Đây là lỗi báo cáo phổ biến nhất về scaling. Bản song song chạy 1 luồng gánh khóa, atomic, hàng đợi việc mà một vòng lặp tuần tự sạch không có; chia cho nó là tự thổi phồng speedup lên đúng bằng tỉ lệ overhead đó. Viết bản tuần tự riêng, đo nó, dùng nó làm mốc — nếu bản song song N luồng còn thua nó, bạn chưa có gì để khoe.

Hệ quả thứ hai: kỳ vọng dưới tuyến tính, và đo tới đúng số lõi bạn sẽ chạy. Đừng ngoại suy từ 2 lõi (nơi speedup gần lý tưởng) ra 10 lõi. Đường cong cong xuống — 4,65x trên 10 lõi là điển hình cho workload nặng bộ nhớ, và biết điểm bão hòa giúp bạn chọn số lõi tối ưu thay vì trả tiền cho lõi không sinh lợi.

Hệ quả thứ ba là tinh thần đo lường: một baseline sai cộng một điểm số lõi cho ra một bức tranh scaling dối. Con số mang theo: strong scaling gần như luôn dưới tuyến tính (đo được 4,65x trên 10 lõi, không phải 10x, do Amdahl + băng thông); speedup phải chia cho BẢN TUẦN TỰ TỐT NHẤT chứ không phải bản song song 1 luồng — ở đây bản song song 1 luồng chậm hơn tuần tự 6,2 lần vì phí đồng bộ, nên self-relative speedup thổi phồng con số ĐỀU 6,2 lần so true speedup; và strong (bài cố định) bị trần trong khi weak (bài lớn theo lõi) scale tốt hơn nhưng vẫn xuống khi băng thông bão hòa (46% ở 10 lõi). Đo scaling là đo một phân số — và cả tử lẫn mẫu đều phải đúng, đúng tinh thần đo lường vi mô đúng cách.

Thử ba mươi giây

Lần tới khi bạn thấy ai đó khoe "code của tôi scale 8 lần trên 8 lõi", hỏi một câu: 8 lần so với cái gì? Nếu đáp án là "so với chính nó chạy 1 luồng", con số đó gần như vô nghĩa — vì bản song song 1 luồng có thể đã chậm hơn một vòng lặp tuần tự đơn giản. Speedup thật phải chia cho bản tuần tự tốt nhất. Và hỏi câu thứ hai: đó là strong scaling (bài cố định) hay weak (bài lớn theo lõi)? Ba mươi giây với hai câu hỏi đó — baseline nào, strong hay weak — tách được một báo cáo scaling trung thực khỏi một con số đẹp mà rỗng. Vì scaling không phải một số; nó là một phân số, và phân số nào cũng nói dối được nếu bạn chọn sai mẫu số.