bài LTO compiler thấy toàn bộ chương trình để tối ưu xuyên file. Nhưng cả LTO lẫn -O2 vẫn thiếu một thứ: chúng không biết thực tế chương trình chạy thế nào — nhánh nào hay đúng, hàm nào thật sự nóng. Chúng phải đoán bằng quy tắc tĩnh. PGO (Profile-Guided Optimization — tối ưu theo hồ sơ chạy) thay phỏng đoán bằng đo đạc: chạy thử một lần, ghi lại hành vi thật, rồi biên dịch lại theo số liệu đó. Tôi vào đo với một giả thuyết về mặt trái của nó — và giả thuyết ấy không đứng vững.

PGO theo hồ sơ

Ba bước: đo, rồi mới quyết

Không có hồ sơ, compiler đoán nhánh nào hot bằng heuristic tĩnh: vòng lặp thường chạy nhiều, nhánh kiểm lỗi thường không xảy ra, v.v. Đoán đúng phần lớn, nhưng trật ở những chỗ chỉ dữ liệu thật mới biết. PGO đo thật qua ba bước:

  1. gcc -O2 -fprofile-generate dựng một bản có đo: compiler cấy một bộ đếm vào mỗi nhánh và mỗi lời gọi.
  2. Chạy bản đó với dữ liệu đại diện. Mỗi lần một nhánh được rẽ, bộ đếm tăng. Kết thúc, chương trình ghi một file .gcda chứa hồ sơ: nhánh nào chạy bao nhiêu lần, hàm nào nóng.
  3. gcc -O2 -fprofile-use biên dịch lại, lần này dùng hồ sơ: sắp đường nóng thành fall-through (chạy thẳng, ít lệnh nhảy — tốt cho dự đoán nhánh và i-cache), đẩy đường lạnh ra xa, nội tuyến đúng những hàm thật sự hot.

Điểm mấu chốt lộ ra ngay từ mô tả: PGO chỉ tốt bằng độ đại diện của dữ liệu bạn dùng ở bước 2. Nếu hồ sơ không giống thực tế thì sao? Đó là câu hỏi tôi mang vào đo.

Đo: nhanh 13% với hồ sơ đúng

Tôi viết trong container gcc:13 (ARM AArch64) một hàm process duyệt 20 triệu phần tử với một nhánh lệch mạnh: 5% phần tử là "đặc biệt" đi vào đường lạnh đắt (tám vòng nhân liên tiếp), 95% còn lại đi đường nóng rẻ (một phép cộng). Đây là dạng nhánh mà bố cục mã ảnh hưởng tới tốc độ.

(1) baseline  gcc -O2                      : 0,644 ns / phần tử
(2) train ĐÚNG (chạy bản -fprofile-generate
    với dữ liệu 5% đặc biệt = thực tế) -> .gcda
(3) gcc -O2 -fprofile-use                   : 0,562 ns / phần tử

PGO với hồ sơ khớp thực tế cho 0,562 ns, nhanh hơn baseline -O2 khoảng 1,15 lần (giảm 13%). Cùng kết quả (sink giống hệt) — PGO không đổi ngữ nghĩa, chỉ đổi bố cục mã. objdump xác nhận: trong hàm process, bản baseline có 4 lệnh nhánh có điều kiện, bản PGO chỉ còn 2 — đường nóng được duỗi thẳng, bớt nhảy.

Tới đây mọi thứ đúng như sách. Rồi tôi thử làm sai hồ sơ để đo mặt trái — và bất ngờ.

Đo hớ: hồ sơ sai không hề phản đòn

Giả thuyết của tôi: nếu thu hồ sơ trên dữ liệu lệch ngược thực tế, PGO sẽ tối ưu nhầm — coi đường lạnh là nóng, sắp nó lên fall-through, phạt đường thật sự phổ biến — và kết quả phải chậm hơn cả baseline. Đó là lời cảnh báo kinh điển về PGO.

Tôi đo đúng ca đó: chạy bản -fprofile-generate với dữ liệu 95% đặc biệt (ngược hẳn thực tế 5%), lấy .gcda "sai", biên dịch lại, rồi đo trên dữ liệu thật (5% đặc biệt):

PGO hồ sơ SAI (train 95%, chạy 5%) : 0,561 ns / phần tử

0,561 ns — nhanh y như hồ sơ đúng (0,562), và vẫn hơn baseline (0,644). Hình phạt tôi chờ đợi không xuất hiện. Hồ sơ sai không hề làm chậm.

Lý do, khi nghĩ kỹ, rất soi sáng: với một nhánh nằm trong một vòng nóng, bộ dự đoán nhánh của CPU tự học hướng đúng lúc chạy chỉ sau vài lần lặp — dù compiler sắp bố cục theo hướng nào, phần cứng vẫn đoán đúng 95% số lần. Cái PGO thắng ở đây không phải hướng nhánh, mà là bố cục và tinh chỉnh vòng (giảm 4 nhánh xuống 2, duỗi thẳng thân vòng) — và những thay đổi đó, hồ sơ lệch vẫn kích hoạt vì cả hai hồ sơ đều thấy nhánh này là điểm nóng cần chăm, chỉ khác nhau ở hướng.

Nói cách khác, tôi đã gán cho PGO một cơ chế (sửa hướng nhánh) không phải là chỗ nó tạo ra khác biệt trong ca này. Hình phạt "hồ sơ sai" là có thật, nhưng nó lộ ra ở mã nhiều nhánh và nhiều quyết định nội tuyến — nơi PGO phải chọn hàm nào nội tuyến, nhánh nào trong hàng chục nhánh được ưu tiên — chứ không phải một vòng một-nhánh gọn gàng nơi phần cứng đã lo phần hướng. Đo một ca đơn giản rồi suy ra "PGO sai thì phản đòn" là kết luận vượt quá dữ liệu.

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

Hệ quả đầu tiên: PGO là món hời cho binary hot, nhưng hồ sơ phải đại diện. Chạy bản -fprofile-generate bằng workload gần với sản xuất — dữ liệu thật, các đường code người dùng thật đi qua — rồi mới -fprofile-use. Hồ sơ thu từ một benchmark vi mô không giống tải thật có thể dẫn compiler tối ưu sai chỗ; và ở mã phức tạp, sai chỗ đó trả giá.

Hệ quả thứ hai: đừng gán nhầm cơ chế cho một tối ưu. Tôi tưởng PGO thắng nhờ "đoán đúng hướng nhánh", nên tưởng hồ sơ sai sẽ phá đúng chỗ đó. Đo ra cái nó thắng là bố cục và vòng, một thứ mà cả CPU (dự đoán nhánh động) lẫn compiler cùng góp phần. Hiểu sai cơ chế dẫn tới dự đoán sai về cả mặt lợi lẫn mặt hại — chỉ đo mới chỉnh lại được, như khi -O2 đã vector hóa mà tôi tưởng cần -O3 từng dạy.

Hệ quả thứ ba: PGO bổ trợ chứ không thay phần cứng. Bộ dự đoán nhánh của CPU đã xử lý rất tốt các nhánh lặp lại có quy luật; PGO thêm vào phần bố cục tĩnh mà phần cứng không làm được (sắp mã trong bộ nhớ, chọn nội tuyến). Hai tầng này chồng lên nhau, và biết tầng nào lo việc gì giúp bạn không kỳ vọng nhầm. Con số mang theo: PGO (3 bước generate → chạy thu hồ sơ .gcda → use) sắp đường nóng fall-through, cho nhanh ~13% ở ca này (0,644 → 0,562 ns, 4 nhánh xuống 2); nhưng hồ sơ SAI (train 95%, chạy 5%) KHÔNG phản đòn — vẫn 0,561 ns — vì với một nhánh trong vòng nóng, CPU tự đoán hướng, PGO chỉ thắng ở bố cục mà hồ sơ lệch vẫn kích hoạt; hình phạt hồ sơ sai chỉ lộ ở mã nhiều nhánh. PGO chỉ tốt bằng độ đại diện của hồ sơ — nhưng chính xác nó tốt hay hại ở đâu thì phải đo, đừng đoán.

Thử ba mươi giây

Lấy một chương trình C của bạn có vòng nóng thật. Build ba lần: gcc -O2 prog.c -o a; rồi gcc -O2 -fprofile-generate prog.c -o gen && ./gen <dữ liệu thật> (sinh .gcda); rồi gcc -O2 -fprofile-use prog.c -o b. Chạy time ./atime ./b trên cùng dữ liệu để thấy PGO nhanh bao nhiêu. Rồi objdump -d aobjdump -d b, tìm hàm nóng của bạn và đếm số lệnh nhánh có điều kiện — bản PGO thường ít hơn, đường nóng duỗi thẳng. Nếu chênh lệch nhỏ, hàm của bạn có thể vốn đã dễ đoán; nếu lớn, bạn vừa tìm ra một món hời chỉ tốn thêm một bước build.