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

OpenMP: pragma tiện nhưng không phép màu

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. Đâ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ồngrào chúng ở cuối — một chi phí cố định. 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. Đâ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ột lần tôi đo hớ: pragma tiện, nhưng không phải phép màu

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

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.

Bài học đo lường: 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".

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

Hệ quả đầ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.

Hệ quả 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.

Hệ quả thứ ba là tinh thần đo lường, đúng như đo lường vi mô đúng cách dạy: tiện không đồng nghĩa với nhanh vô điều kiện — đo trước khi rắc pragma khắp nơi. Con số mang theo: OpenMP #pragma omp parallel for đạt speedup ngang pthread thủ công (4,83× so 4,86× trên 10 lõi) mà chỉ một dòng — RẤT tiện; NHƯNG không phải phép màu: vòng quá nhỏ (64 phần tử) thì phí lập/rào team làm nó CHẬM hơn tuần tự 105 lần; schedule sai (static cho tải lệch) chậm 1,38× so dynamic; và vẫn dính trần Amdahl (10 lõi chỉ ~4,8×). Pragma là công cụ phải đo và chọn schedule, không phải nút bấm là nhanh.

Thử ba mươi giây

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. Ba mươi giây 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.