Hình dung bạn gọi một công ty nhân lực: một cuộc điện thoại là họ cử tới mười người làm. Với một việc lớn — sơn lại cả nhà kho — mười người xong nhanh hơn hẳn bạn làm một mình, và gọn hơn nhiều so với tự đi tuyển từng người (viết pthread bằng tay). Nhưng cùng cái quy trình "gọi mười người" đó, nếu áp lên một việc tí xíu — treo một bức tranh — thì thời gian tập hợp đội, dặn dò, rồi giải tán còn lâu hơn cả việc treo tranh; bạn tự làm xong từ đời nào trước khi đội tới. Và nếu việc lệch (người này ôm phần nặng, người kia phần nhẹ), chia mỗi người một khu cố định sẽ có kẻ ngồi chơi chờ kẻ đang è cổ. OpenMP là đúng cái công ty nhân lực đó: một dòng gọi cả đội, tiện thật — nhưng không phải phép màu.
Suốt sê-ri ta song song hóa bằng tay: pthread_create, chia dải index, join, tự lo cân bằng tải và thread pool. Có một cách gọn hơn nhiều: OpenMP — thêm một dòng pragma trước vòng lặp, trình biên dịch tự sinh phần tạo luồng, chia việc, đồng bộ. Nghe như bùa. Nhưng đúng tinh thần sê-ri, tôi không tin quảng cáo — tôi đo. Kết quả: OpenMP tiện thật, nhưng nó không phải phép màu, và có ít nhất ba chỗ nó phản bội kỳ vọng "thêm pragma là tự động nhanh".
Một dòng thay cho cả một mớ pthread
OpenMP là một chuẩn (bật bằng cờ -fopenmp của gcc) cho phép song song hóa bằng chỉ thị (directive) thay vì tự quản luồng. Muốn chạy một vòng lặp trên nhiều lõi, bạn viết:
#pragma omp parallel for
for (int i = 0; i < N; i++)
out[i] = viec(i);
Trình biên dịch biến dòng đó thành: tạo một team luồng (mặc định bằng số lõi, hoặc theo OMP_NUM_THREADS), chia dải [0, N) cho các luồng, mỗi luồng chạy phần của mình, rồi rào (barrier) chờ nhau ở cuối. Toàn bộ phần dựng luồng và chia việc mà bài đầu sê-ri ta phải viết tay — ở đây gói trong một dòng. Câu hỏi tôi mang đi đo: cái tiện đó có phải trả giá bằng tốc độ không, và có cái bẫy nào không?
Tôi đo ba thứ trong container gcc:13 (10 lõi, biên dịch -O2 -fopenmp -pthread), mỗi con số lấy trung vị ba lần, có warmup, có checksum tổng khớp để chắc mọi phần tử chạy đủ.
Đo (a): OpenMP ngang pthread, ít code hơn hẳn
Đầu tiên, câu hỏi cốt lõi: OpenMP có chậm hơn pthread thủ công không? Tôi lấy một vòng lặp nặng (2 triệu phần tử, mỗi phần tử một chuỗi tính sin để tốn CPU thật), song song hóa nó hai cách — một bằng pthread_create chia dải index bằng tay, một bằng #pragma omp parallel for — rồi đo cùng số luồng:
NT | pthread | OpenMP | speedup OpenMP (so 1 luồng)
1 | 193 ms | 196 ms | 1,00×
2 | 99 ms | 101 ms | 1,95×
4 | 64 ms | 51 ms | 3,82×
8 | 39 ms | 42 ms | 4,70×
10 | 40 ms | 41 ms | 4,83×
Hai cột gần như trùng nhau ở mọi mức luồng — chênh lệch vài phần trăm, trong khoảng nhiễu đo. OpenMP đạt speedup 4,83× trên 10 lõi, pthread đạt 4,86× (193/40). Nói cách khác: OpenMP không mất gì về tốc độ so với tự viết pthread cho vòng lặp song song đơn giản này — trình biên dịch sinh ra code chia việc chẳng kém tay người. Nhưng nó thắng đậm về công sức: bản OpenMP là một dòng, bản pthread của tôi là một struct dải index, một hàm worker, một vòng tạo luồng, một vòng join. Với vòng lặp song song hóa được dễ dàng, OpenMP là lựa chọn hiển nhiên — cùng tốc độ, một phần mười số dòng.
Nhưng chú ý cả cái hình dạng của cột speedup: 10 lõi chỉ cho 4,83×, không phải 10×. Đây chính xác là định luật Amdahl và các chi phí ẩn (băng thông bộ nhớ, đồng bộ) mà phần trước đã đo — và OpenMP không hề miễn nhiễm. Pragma sinh code song song, nhưng nó không thay đổi vật lý: vẫn cùng trần scaling như mọi cách song song khác.
Đo (b): schedule sai làm chậm 1,38 lần
Đây là bẫy thứ nhất mà "thêm pragma là xong" bỏ qua. Mặc định, #pragma omp parallel for dùng schedule(static): chia dải index thành các khối liền nhau bằng nhau — luồng 0 lấy nửa đầu, luồng 1 lấy nửa sau, đại loại vậy. Điều đó tuyệt khi mỗi vòng lặp tốn như nhau. Nhưng nếu tải lệch — vòng lặp i làm nhiều việc hơn khi i lớn — thì khối chứa các index lớn nặng hơn hẳn, và luồng ôm khối đó thành kẻ tụt lại; những luồng khác xong sớm rồi ngồi không chờ nó ở rào cuối (kẻ được chia khu nặng còn è cổ, người xong khu nhẹ đứng chơi). Đây đúng là vấn đề cân bằng tải mà work stealing đã mổ xẻ.
OpenMP cho một cái van: schedule(dynamic) — chia việc thành lô nhỏ và phát động, luồng nào xong lô thì lấy lô tiếp theo, nên tải tự san. Tôi đo một vòng M = 20000 mà việc của vòng i tăng dần theo i (tải cố tình lệch), 10 luồng:
schedule(static) : 84,5 ms (worker ôm index lớn làm nặng)
schedule(dynamic,16) : 61,4 ms -> nhanh 1,38×
static chậm hơn dynamic 1,38 lần trên đúng cùng vòng lặp, cùng số luồng — khác biệt chỉ nằm ở mệnh đề schedule. Với tải đều, static lại thường nhỉnh hơn (không tốn phí phát lô động). Bài học: pragma mặc định không tự chọn đúng cách chia việc cho bạn — bạn phải biết tải của mình đều hay lệch, rồi chọn schedule cho khớp. "Thêm parallel for" mà để mặc định trên tải lệch là bỏ mất 38% tốc độ mà không hề có lỗi báo.
Đo (c): vòng quá nhỏ, pragma chậm hơn tuần tự 105 lần
Bẫy thứ hai, và là cú sốc lớn nhất. Mỗi lần chạm vào một parallel region, OpenMP phải lập một team luồng và rào chúng ở cuối — một chi phí cố định (tập hợp và giải tán đội). Nếu vòng lặp bên trong đủ lớn, chi phí đó tan biến. Nhưng nếu vòng tí xíu, chi phí lập/rào team lớn hơn cả công việc thật nhiều lần. Tôi đo một vòng chỉ 64 phần tử, mỗi phần tử một phép cộng, lặp lại 200.000 lần — một cách chạy tuần tự, một cách bọc #pragma omp parallel for quanh vòng trong:
serial (200.000 lần) : 10,6 ms
omp parallel for : 1117,0 ms -> CHẬM hơn 105 lần!
Không phải chậm một chút — chậm 105 lần. Song song hóa một vòng 64 phần tử khiến chương trình tệ đi hai bậc độ lớn. Vì mỗi trong 200.000 vòng ngoài, OpenMP dựng và đồng bộ cả một team 10 luồng để chia nhau vỏn vẹn 64 phép cộng — chi phí điều phối nuốt trọn, phần tính toán thật gần như bằng không (gọi mười người tới treo một bức tranh). Đây là cùng một bài học chi phí per-item nhân lên: một phí "nhỏ" (lập team) nhân với số lần lớn thành thảm họa. Pragma không biết vòng của bạn to hay nhỏ; nó cứ song song hóa như được bảo, kể cả khi việc đó lỗ nặng.
(Môi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi, -O2 -fopenmp -pthread, trung vị ba lần có warmup và checksum. Con số tuyệt đối đổi theo máy; điều bền vững: OpenMP ngang pthread cho vòng lớn, chậm thảm cho vòng tí xíu, và schedule phải khớp hình dạng tải.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin phổ biến của người mới gặp OpenMP: "chỉ cần rắc #pragma omp parallel for lên các vòng lặp là chương trình tự động nhanh lên". Nửa đầu của niềm tin đó đúng đến bất ngờ: đo (a) cho thấy OpenMP đạt speedup ngang pthread thủ công (4,83× so 4,86× trên 10 lõi) mà chỉ tốn một dòng — cái tiện là thật, không phải lời quảng cáo rỗng. Nhưng nửa "tự động nhanh" thì vỡ theo ba hướng khi tôi đo kỹ. Và niềm tin đối nghịch — "pragma chỉ là lớp trừu tượng, chắc chậm hơn tự viết pthread" — cũng sai: nó không mất tốc độ nào so với pthread cho vòng lớn.
Ba hướng vỡ: thứ nhất, vòng quá nhỏ thì pragma làm chậm đi 105 lần — chi phí lập/rào team lấn át việc thật. Thứ hai, schedule sai làm chậm 1,38 lần — mặc định static bỏ rơi tải lệch, phải đổi sang dynamic. Thứ ba, vẫn dính trần Amdahl — 10 lõi chỉ được ~4,8×, pragma không phá được giới hạn vật lý của scaling.
Hai niềm tin gặp nhau ở một điều: một công cụ có thể vừa tiện thật vừa không phải phép màu cùng lúc — và chỉ đo mới tách được hai vế đó. Nếu tôi chỉ đo (a), tôi đã kết luận "OpenMP tuyệt đối, rắc pragma khắp nơi". Nếu tôi chỉ đo (c), tôi đã kết luận "OpenMP tệ, chậm hơn tuần tự". Cả hai kết luận đều sai vì đều khái quát từ một phép đo. Sự thật nằm ở cả ba: OpenMP là cách tuyệt vời để song song hóa các vòng lặp lớn, tải cân nhắc được, với điều kiện bạn chọn đúng schedule và đừng bọc nó quanh việc tí xíu. Pragma là công cụ, không phải nút "nhanh hơn". Đúng tinh thần đo lường vi mô đúng cách.
Về đến code hằng ngày
Điều đầu tiên: với vòng lặp nặng, song song hóa được, hãy dùng OpenMP thay vì tự viết pthread. Nó cho cùng tốc độ với một phần nhỏ số dòng, ít chỗ sai (không tự quản join, không rò luồng). Đây là lý do OpenMP thống trị tính toán khoa học và xử lý dữ liệu nặng — cái vòng for bạn đã có, thêm một dòng là chạy trên mọi lõi.
Điều thứ hai: chọn schedule theo tải, và đừng song song hóa vòng nhỏ. Tải đều thì static (mặc định) tốt; tải lệch thì dynamic cứu bạn 38% như đo được — nhưng bạn phải biết tải của mình để chọn. Và trước khi bọc pragma quanh một vòng, ước lượng: vòng này có đủ việc để bù chi phí lập team không? Nếu vòng chỉ vài chục phần tử nhẹ, để tuần tự — hoặc song song hóa ở vòng ngoài lớn hơn, không phải vòng trong tí xíu.
Chỗ này đáng biết là "pragma một dòng" có bản sao ở gần như mọi ngôn ngữ cao cấp, và chúng mang đúng ba cái bẫy này. Java có IntStream.range(0,n).parallel() (và collection.parallelStream()) — cũng một dòng biến vòng thành song song, cũng chạy trên một ForkJoinPool chung, và tài liệu Java khuyến cáo y hệt những gì tôi đo: chỉ đáng dùng khi N lớn và việc mỗi phần tử đủ nặng và nguồn chia đều được (một ArrayList chia tốt, một LinkedList thì không), còn parallel một stream nhỏ thì chậm hơn tuần tự vì phí dựng task — chính là cú 105× ở trên. Rust có Rayon với par_iter() (đổi iter() thành par_iter() là xong), .NET có Parallel.For, và cả ba đều cần bạn đo chứ không phải rắc bừa. Mẫu số chung: mọi API "song song hóa một dòng" đều tuyệt cho vòng lớn tải đều và đều phản tác dụng cho việc tí xíu — sự tiện lợi của cú pháp không xóa được vật lý của song song, nên câu hỏi luôn là "việc có đủ lớn, tải có đều, và trần Amdahl ở đâu".
Muốn tự soi thì lấy một chương trình có #pragma omp parallel for (hoặc thêm một cái vào một vòng lặp bạn có) và hỏi ba câu trước khi tin nó nhanh. Một: vòng này có bao nhiêu việc mỗi lần lặp? Nếu chỉ vài phép tính và vòng ngắn, chi phí lập team có thể ăn hết — thử bỏ pragma và bấm giờ, bạn có thể sốc khi bản tuần tự nhanh hơn. Hai: tải có đều không? Nếu vòng i làm nhiều việc hơn khi i lớn (duyệt cây lệch, xử lý phần tử kích thước khác nhau), thêm schedule(dynamic) và đo lại — bạn có thể lấy lại vài chục phần trăm. Ba: bao nhiêu lõi, speedup bao nhiêu? Nếu 10 lõi chỉ cho 4× thay vì 10×, đó không phải lỗi OpenMP — đó là Amdahl và chi phí ẩn, và câu trả lời là giảm phần tuần tự, không phải thêm pragma. Vài phút hỏi ba câu đó tách được "tiện" khỏi "nhanh" — và nhắc rằng pragma dễ viết nhất cũng vẫn phải đo, vì trình biên dịch song song hóa đúng cái bạn bảo, kể cả khi đó là một ý tồi.