Một cửa hàng treo biển "GIẢM 70%!". Giảm 70% từ giá nào? Nếu là từ một cái giá niêm yết bị thổi lên mà chẳng ai từng mua, thì "70%" là con số dối — cái quyết định không phải phần trăm, mà là mẫu số bạn chia cho. Speedup của code song song là đúng cái phân số đó: "nhanh gấp N lần" chỉ có nghĩa khi bạn biết N lần so với cái gì. Và cái "giá gốc" ấy — baseline — là nơi một báo cáo scaling trung thực và một con số đẹp rỗng tách nhau ra.

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).

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 — đúng cái giá niêm yết bị thổi lên.

(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ớ.

Hai niềm tin đối nghịch, cùng sai

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 coarse-grained, chi phí pthread_create/join một lần 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ả:

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. Giờ hai cách tính speedup phân kỳ:

        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, đúng cái giá niêm yết bị kênh lên. Cột self khiến bức tranh "trông ổn" (1 luồng = 1,00x, như điểm khởi đầu bình thường); cột true nói sự thật: 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 = chậm hơn 6 lần). Báo cáo self-relative, tôi sẽ nói "song song hóa đạt 1x, hòa vốn" — trong khi thực tế là một khoản lỗ 6 lần. (Workload này còn scale âm vì atomic chung càng đông càng tranh chấp — nhưng điểm ở đây là baseline.)

Đây là chỗ niềm tin thứ hai — "scaling tuyến tính tới số lõi" — cũng đã sụp ở bảng strong (4,65x, không phải 10x). Hai lầm tưởng gặp nhau ở một sự thật về phân số: 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 overhead của bản song song 1 luồng. Nếu overhead nhỏ (code coarse), hệ số ~1 và bạn may. Nếu overhead lớn, hệ số có thể là 6x — đủ giấu hoàn toàn việc song song hóa là một thất bại. 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, và đừng ngoại suy từ 2 lõi (gần lý tưởng) ra 10 lõi (cong xuống).

Strong so với weak scaling

Strong scaling bi quan (trần 4,65x), 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. Đ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. 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. Chú ý so sánh: ở 10 lõi, weak giữ 46% trong khi strong đạ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ề đến code hằng ngày

Điều đầ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 đú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.

Điều 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 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.

Chỗ này cái đường-cong-cong-xuống có một mô hình đẹp mô tả nó, và một loạt cạm bẫy báo cáo ở tầng thật. Universal Scalability Law (USL của Neil Gunther) mở rộng Amdahl bằng cách thêm hai hạng: chi phí tranh chấp (contention) và đồng bộ hóa nhất quán (coherency) — nó dự đoán đúng cái mà ta đo: đường speedup không chỉ bão hòa mà có thể quay đầu đi xuống khi thêm lõi, chính là scale âm của workload atomic-chung. Trong benchmark Java, JMH tồn tại một phần để buộc bạn đo tử số cho tử tế; còn "strong scaling" và "weak scaling" là thuật ngữ chuẩn của HPC/MPI đúng vì cộng đồng đó học được rằng báo cáo speedup mà không nói strong hay weak là vô nghĩa. Và cái bẫy "70% off giá thổi" chính là lý do các bài báo hiệu năng nghiêm túc luôn ghi rõ baseline: so với triển khai tuần tự tốt nhất đã biết, không so với chính nó hạ xuống một luồng. Mẫu số chung: speedup là một phân số, và một phân số chỉ trung thực khi cả tử lẫn mẫu đều trung thực.

Muốn tự thấy thì 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: strong scaling (bài cố định) hay weak (bài lớn theo lõi)? Vài phút với hai câu hỏi đó — baseline nào, strong hay weak — tách 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ố.