Hình dung mặt bàn bếp của một đầu bếp. Dao, thớt, muối tiêu — những thứ dùng liên tục — nằm ngay trong tầm tay. Còn cái khuôn bánh dùng mỗi năm một lần, hay bình chữa cháy, thì cất trong tủ phía sau. Không phải vì chúng không quan trọng, mà vì để chúng chình ình trên mặt bàn chỉ làm chật chỗ những thứ hay dùng. Compiler sắp xếp mã máy theo đúng logic đó: giữ đường chạy thường xuyên gọn gàng, đẩy mã hiếm khi chạy (xử lý lỗi, ngoại lệ) ra "tủ sau". Tôi vào đo cái lợi của việc sắp xếp này, tin nó tăng tốc rõ — và nhận một câu chuyện khiêm tốn hơn nhiều.

Ở 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 đó cho 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 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 đo cái lợi đó trong container gcc:13 (ARM), và đo được đúng một 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. Đúng cái khuôn bánh chình ình giữa bàn. 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.

Đ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à chỗ tôi nhầm. 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ẻ. Cái bàn bếp nhỏ xíu thì cất khuôn bánh đi hay không cũng chẳng thoáng thêm mấy.

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ì, 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ề đến code hằng ngày

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

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

Và cái ý "để cái hay dùng gần nhau, cái hiếm dùng ra xa" hóa ra là một trong những nguyên lý hiệu năng phổ quát nhất, chỉ đổi tầng. JVM làm y hệt lúc chạy: JIT biên dịch các đường nóng và để đường lỗi/hiếm dưới dạng "uncommon trap" — nếu một nhánh tưởng-lạnh bỗng chạy nhiều, nó deoptimize và biên dịch lại, tức là đo rồi bố trí, đúng tinh thần PGO nhưng liên tục. Ở tầng dữ liệu, đây chính là nguyên lý hot/cold data: đẩy các cột hiếm dùng sang bảng phụ, lưu dữ liệu cũ (cold) sang tầng lưu trữ rẻ và giữ dữ liệu nóng trong RAM/SSD — Cassandra, các data warehouse phân tầng, cache LRU đều sống bằng ý này. Mẫu số chung: không phải mọi thứ đều đáng nằm ở chỗ đắt và nhanh nhất — tách cái nóng khỏi cái lạnh để tài nguyên quý (i-cache, RAM, bộ nhớ nhanh) chỉ chứa thứ thật sự hay dùng, và cái lợi luôn hiện rõ nhất ở quy mô lớn.

Muốn tự thấy thì 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, 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.