Ở bài __builtin_expect ta thấy unlikely() báo compiler nhánh nào hiếm chạy. Compiler dùng thông tin đó để làm một việc bố cục quan trọng: tách đường nóng và lạnh (hot/cold splitting) — chuyển phần mã hiếm chạy (xử lý lỗi, đường ngoại lệ, nhánh unlikely) ra xa đường nóng, để đường nóng nằm liền mạch trong bộ đệm lệnh, không bị mã lạnh chen giữa. Tôi vào đo cái lợi đó, tin rằng nó tăng tốc rõ — và đo được một câu chuyện khiêm tốn hơn nhiều, đúng bài học của sê-ri.
Vì sao tách nóng-lạnh có thể giúp
CPU nạp lệnh theo dòng cache, và bộ đệm lệnh (i-cache) có hạn. Nếu trong một hàm nóng có một khối mã lạnh lớn (ví dụ một trình xử lý lỗi dài) xen giữa đường chạy thường, thì mỗi lần CPU nạp qua vùng đó, nó kéo về cả những dòng lệnh lạnh không bao giờ chạy — lãng phí i-cache và băng thông nạp lệnh. Tách khối lạnh ra xa để đường nóng gói gọn trong ít dòng cache liền nhau giúp CPU nạp đúng cái nó cần.
Có hai mức tách. Nhẹ: đẩy khối lạnh xuống cuối hàm (sau lệnh ret), để thân vòng nóng liền mạch. Nặng: gom mọi khối lạnh vào một section riêng .text.unlikely, đặt xa hẳn khỏi các hàm nóng — thường cần hồ sơ PGO để compiler biết chắc cái gì lạnh. Tôi kiểm cả hai trong container gcc:13 (ARM AArch64).
Đo: compiler đã tách, nhưng đồng hồ gần như không đổi
Tôi viết một hàm process có vòng nóng (cộng v*3+1) và một nhánh unlikely(v<0) chứa một khối tính toán lạnh, lớn (một chuỗi 40 phép nhân). objdump cho thấy compiler làm đúng như lý thuyết: khối lạnh đó bị đẩy xuống cuối hàm — thân vòng nóng của process không có một lệnh nào của khối lạnh (đếm mul trong thân chính: 0). Đường nóng liền mạch; khối lạnh nằm sau, chỉ nhảy tới khi v<0.
Nhưng khi đo thời gian, so bản mặc định (có tách) với bản ép -fno-reorder-blocks-and-partition (giảm tách):
tách (mặc định) : 0,3379 ns/phần tử
không tách (-fno-reorder-...) : 0,3424 ns/phần tử
Chênh ~1% — nằm trong biên nhiễu đo. Ở vi benchmark này, tách hay không gần như không đổi tốc độ. Và một chi tiết đáng ghi: section .text.unlikely riêng không hề xuất hiện ở -O2 cho chương trình này — nó thường chỉ được tạo khi có hồ sơ PGO xác nhận cái gì thật sự lạnh.
Đây là đo hớ của tôi. Tôi tin tách nóng-lạnh "tăng tốc rõ". Đo ra: compiler đã tách (khối lạnh xuống cuối hàm, thân nóng liền mạch), nhưng tác động thời gian ở vi benchmark không đo được — vì vòng nóng của tôi vốn đã nhỏ và gọn trong i-cache dù khối lạnh nằm đâu. Cái lợi của tách nóng-lạnh chỉ hiện ở quy mô, không ở một kernel lẻ.
cold, hot, và những đường lỗi
Có một họ chú thích để bạn nói cho compiler biết nhiệt độ của mã. __attribute__((cold)) trên một hàm bảo "hàm này hiếm chạy" — compiler đặt nó vào vùng lạnh và giả định mọi lời gọi tới nó là nhánh unlikely. __attribute__((hot)) thì ngược lại. Đặt cold lên các hàm xử lý lỗi, ghi log hiếm, dọn dẹp khi thoát — những thứ chạy một lần trong nghìn — giúp compiler dồn chúng ra xa mà không cần bạn rải unlikely ở từng điểm gọi.
Điều thú vị là nhiều đường lạnh sinh ra tự nhiên mà bạn không khai: trong C++, các khối xử lý ngoại lệ (catch) được compiler mặc định coi là lạnh và tách ra; các lời gọi tới abort, __assert_fail, hàm ném ngoại lệ cũng được đánh dấu lạnh sẵn. Nên phần lớn việc tách nóng-lạnh diễn ra tự động quanh chính những cấu trúc xử lý lỗi bạn viết — bạn thừa hưởng nó mà không phải làm gì, và chỉ cần thêm hint ở những đường lạnh mà compiler không tự nhận ra (một hàm log của riêng bạn, một nhánh fallback hiếm).
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: tách nóng-lạnh là tối ưu bố cục, lợi ở chương trình lớn — nơi bạn không thấy trong một vi benchmark. Trong một ứng dụng thật với hàng nghìn hàm, nhiều hàm nóng có nhánh lỗi/ngoại lệ dài; nếu những khối lạnh đó nằm rải khắp, working set i-cache của toàn đường nóng phình lên. Gom chúng ra .text.unlikely giữ các đường nóng của nhiều hàm gần nhau, đường nóng chiếm ít dòng i-cache hơn — và ở quy mô đó, vài phần trăm i-cache tiết kiệm được cộng dồn thành tăng tốc thật. Nhân hệ điều hành và trình duyệt bật -freorder-blocks-and-partition chính vì lý do này.
Hệ quả thứ hai: compiler tự làm phần lớn; bạn giúp nó bằng cách chỉ ra cái gì lạnh. __attribute__((cold)) trên một hàm (hay ((hot))) là gợi ý tường minh; __builtin_expect/unlikely trên một nhánh cũng vậy; và PGO là cách chính xác nhất — nó đo cái gì lạnh rồi tách đúng. Bạn hiếm khi cần can thiệp tay vào bố cục; việc của bạn là cung cấp thông tin (hint hoặc hồ sơ), phần đặt-để-đâu để compiler lo.
Hệ quả thứ ba là bài học đo lường nhất quán với căn lề mã và __builtin_expect: tối ưu bố cục có tác động thật nhưng nhỏ và khó thấy ở vi benchmark — đừng kỳ vọng nó cứu một vòng nhỏ. Nếu bạn đo một kernel lẻ và thấy tách nóng-lạnh không giúp, đó là bình thường, không phải compiler hỏng. Con số mang theo: tách đường nóng-lạnh đẩy mã lạnh (nhánh unlikely) ra xa đường nóng để i-cache đường nóng gọn — compiler đã tự làm (objdump: khối lạnh xuống cuối hàm, thân vòng nóng 0 lệnh lạnh), nhưng tác động ở vi benchmark chỉ ~1% (0,3379 so 0,3424 ns, trong nhiễu) vì vòng nhỏ vừa i-cache dù mã lạnh nằm đâu; section .text.unlikely riêng cần PGO — nên đây là tối ưu bố cục lợi ở quy mô lớn (nhân HĐH, app nhiều đường lạnh), không phải nút tăng tốc kernel nhỏ. Cung cấp hint/PGO cho compiler; đừng đo một vòng lẻ rồi kết luận nó vô dụng.
Thử ba mươi giây
Viết một hàm có vòng nóng và một nhánh if(unlikely(hiếm)){ ... khối tính toán dài ... }. Biên dịch gcc -O2 -S -o - t.c và tìm hàm: bạn sẽ thấy khối trong nhánh unlikely không nằm giữa thân vòng, mà được đặt ở cuối hàm (sau nhãn kết thúc / trước ret của các đường khác), với một nhánh nhảy tới khi điều kiện hiếm xảy ra — đường nóng chạy thẳng không vấp phải nó. Thử thêm -fprofile-generate, chạy, rồi -fprofile-use, và objdump -h xem có section .text.unlikely xuất hiện không. Ba mươi giây đó cho bạn thấy compiler sắp mã theo nhiệt độ chạy — một tối ưu âm thầm mà bạn chỉ cần cho nó biết cái gì lạnh.