bài tối ưu theo hồ sơ, tôi nói cái PGO làm giỏi nhất là bố cục mã — dồn mã nóng gần nhau, đẩy mã lạnh ra xa. Bài này nhìn thẳng vào chính cơ chế đó khi làm thủ công: thuộc tính __attribute__((hot))__attribute__((cold)), cùng việc trình biên dịch tự tách các nhánh lạnh ra một vùng riêng. Tôi đo trong container — và một lần nữa, cái đồng hồ nói một đằng còn readelf nói một nẻo, dạy tôi một bài về đo đúng đại lượng.

Sắp mã nóng lạnh

Nóng, lạnh, và cache lệnh

CPU không nạp lệnh từng byte một; nó nạp cả dòng cache lệnh (thường 64 byte) vào một bộ nhớ đệm nhỏ và nhanh gọi là cache lệnh (instruction cache, icache). Nếu mã nóng — cái chạy đi chạy lại — nằm rải rác, xen kẽ với mã lạnh (xử lý lỗi, trường hợp hiếm), thì mỗi dòng cache nạp về chứa cả mã bạn cần lẫn mã bạn hầu như không chạy, lãng phí dung lượng icache quý giá. Sắp mã nóng lạnh giải quyết đúng việc đó: gom mã nóng lại một chỗ cho dày đặc, đày mã lạnh đi nơi khác.

Trong C, bạn ra hiệu bằng thuộc tính: __attribute__((hot)) đánh dấu một hàm là nóng, __attribute__((cold)) là lạnh (và gcc còn tối ưu hàm cold theo kích thước thay vì tốc độ, vì nó hiếm chạy). Trình biên dịch cũng tự nhận ra nhánh lạnh — một khối theo sau __builtin_expect(..., 0) hay một lời gọi tới hàm cold — và tách nó ra. Cơ chế thực hiện là section: mã nóng vào .text.hot, mã lạnh vào .text.unlikely, mã thường vào .text.

Đo: section đổi, đồng hồ đứng yên

Tôi viết ba hàm có thân giống hệt nhau, chỉ khác thuộc tính, rồi xem objdump -hreadelf xếp chúng vào đâu:

slow_err  ((cold))   -> section .text.unlikely
hot_fn    ((hot))    -> section .text.hot
normal_fn (mặc định) -> section .text

Ba hàm cùng một dòng mã, ba section khác nhau — thuộc tính thật sự điều khiển nơi mã nằm. Tôi cũng viết một hàm process có vòng lặp nóng và một nhánh lỗi hiếm (if(unlikely(a[i]<0)) report(...)). Đọc assembly: vòng cộng dồn nóng nằm liền mạch một khối, còn khối gọi report bị đẩy xuống cuối hàm, sau cả lệnh ret của đường nóng — nó không chen vào giữa vòng lặp, nên các dòng icache chứa vòng nóng không bị mã xử lý lỗi làm loãng.

Cho tới đây, mọi thứ khớp lý thuyết. Rồi tôi đo thời gian, và bất ngờ:

Hàm Section Thời gian (vòng 50 triệu)
normal_fn .text 38,35 ms
hot_fn (hot) .text.hot 38,33 ms

Bằng nhau — chênh nhau nằm trong nhiễu.

Một lần tôi đo hớ: đo nhầm đại lượng

Tôi đánh dấu hot_fn với __attribute__((hot)), gọi nó 50 triệu lần trong một vòng, và tin chắc sẽ thấy nó nhanh hơn normal_fn. Đo ra 38,33 so với 38,35 ms — không nhanh hơn một mảy may. Phản xạ đầu tiên của tôi là kết luận gọn: "thuộc tính hot/cold chẳng làm gì cả, chỉ là trang trí".

May là tôi kiểm readelf trước khi viết câu đó. Và readelf phản bác ngay: hot_fn đã nằm ở section .text.hot, slow_err.text.unlikely, tách hẳn khỏi .text — thuộc tính đã làm đúng việc của nó, một cách hoàn toàn xác định. Vấn đề không phải là thuộc tính vô dụng; vấn đề là tôi đo nhầm đại lượng.

hot/cold là một tối ưu bố cục, và lợi ích của bố cục là cục bộ hóa cache lệnh — ít trượt icache hơn. Nhưng một microbenchmark gọi đúng một hàm nhỏ trong một vòng chặt thì hàm đó nằm gọn trong icache L1 ngay từ vòng lặp đầu tiên; không có áp lực icache nào để mà giảm, nên bố cục nằm ở đâu cũng thế. Lợi ích của hot/cold chỉ lộ ra ở một chương trình lớn — hàng trăm nghìn dòng mã, nhiều hàm cạnh tranh cùng một icache có hạn — nơi việc gom mã nóng lại và banish mã lạnh thật sự cắt được lỗi cache. Đo nó trên một vòng bé giống như đo lợi ích của thư viện gọn nhẹ bằng cách chạy đúng một hàm: sai chỗ.

Đây lặp lại đúng bài học của PGO: các tối ưu bố cục cần quy mô mới đáng đo, và microbenchmark là công cụ sai để đánh giá chúng. Bài học đo lường: khi một thay đổi rõ ràng có tác dụng (readelf chứng minh section đổi) mà đồng hồ đứng yên, đừng vội kết luận "vô dụng" — hãy hỏi nó tối ưu đại lượng nào, và mình có đang đo đúng đại lượng đó không. Locality không phải là số chu kỳ của một hàm đơn lẻ; nó là hành vi cache của cả một chương trình dưới tải thật.

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

Hệ quả đầu tiên: dùng cold cho các đường hiếm để dọn đường nóng. Đánh dấu các hàm xử lý lỗi, hàm khởi tạo chạy một lần, các nhánh "không bao giờ xảy ra" bằng __attribute__((cold)) (hoặc bọc điều kiện trong unlikely()), và trình biên dịch sẽ đẩy chúng ra .text.unlikely, để đường nóng của bạn dày đặc hơn trong icache. Đây là một trong những cách rẻ nhất để cải thiện cục bộ mã mà không đổi một dòng logic. hot thì ít cần hơn — trình biên dịch và PGO thường tự nhận ra mã nóng — nhưng cold cho một hàm bạn biết chắc hiếm chạy là chỉ dẫn đáng giá.

Hệ quả thứ hai: đo tối ưu bố cục ở đúng quy mô. Nếu bạn muốn biết hot/cold, PGO, hay việc sắp xếp hàm có giúp gì không, đừng đo một microbenchmark — đo cả ứng dụng dưới một tải giống thật, và nhìn vào chỉ số icache (perf stat -e L1-icache-load-misses hay tương tự) chứ không chỉ tổng thời gian. Một cải thiện cục bộ 2% trên một dịch vụ lớn là tiền thật, nhưng nó vô hình trên một vòng lặp đồ chơi.

Hệ quả thứ ba là bài học mang theo: section là nơi trình biên dịch ghi lại quyết định bố cục — đọc chúng để biết nó đã làm gì. Con số mang theo: thuộc tính hot/cold xếp hàm vào .text.hot / .text.unlikely (readelf thấy rõ, hoàn toàn xác định) và đẩy nhánh lạnh ra khỏi đường nóng, nhưng lợi ích của nó — cục bộ hóa cache lệnh — bằng không trên microbenchmark (38,33 so 38,35 ms) và chỉ lộ ở chương trình lớn có áp lực icache thật. Khi đồng hồ đứng yên mà readelf nói có đổi, hãy tin rằng bạn đang đo nhầm đại lượng, không phải rằng tối ưu vô dụng.

Thử ba mươi giây

Viết một hàm bình thường và một hàm __attribute__((cold)) có thân giống hệt nhau, biên dịch gcc -O2 -c, rồi chạy objdump -h hoặc readelf -S trên file .o và tìm các section tên .text, .text.hot, .text.unlikely. Bạn sẽ thấy hàm cold nằm ở một section riêng, tách khỏi mã thường — trình biên dịch đã đày nó đi mà bạn chẳng cần làm gì thêm. Thử thêm một hàm với nhánh lỗi bọc trong if(__builtin_expect(cond,0)) và đọc objdump -d: khối xử lý lỗi sẽ nằm ở cuối, sau lệnh ret của đường nóng. Đó là bố cục nóng-lạnh làm việc — và nếu bạn đo thời gian mà không thấy khác biệt, đừng ngạc nhiên: lợi ích của nó sống ở cache lệnh của một chương trình lớn, không ở đồng hồ của một vòng lặp nhỏ.