Hình dung một siêu thị sắp lại kệ hàng dựa trên việc quan sát khách thật đi đường nào, mua gì nhiều — món bán chạy được bày ngay lối vào, hàng ế đẩy vào góc. Cách này thắng hẳn kiểu "đoán theo cảm tính của quản lý" (đường lối vào chắc đông, quầy đông lạnh chắc ít ai ghé). Nhưng nó chỉ tốt bằng đám khách bạn quan sát: nếu bạn khảo sát nhằm một hôm hội chợ bất thường, bạn có thể bày sai. Trình biên dịch có đúng hai lối này — đoán tĩnh, và quan sát-rồi-quyết — và cái thứ hai tên là PGO. Tôi vào đo mặt trái "khảo sát sai" của nó, và bất ngờ.
Ở 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 đo trong container gcc:13 (ARM) 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.
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:
gcc -O2 -fprofile-generatedự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.- 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
.gcdachứa hồ sơ: nhánh nào chạy bao nhiêu lần, hàm nào nóng. gcc -O2 -fprofile-usebiê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 — đúng đám khách bạn quan sát. 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 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ờ.
Cú vấp: 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, đúng cảnh khảo sát nhằm hôm hội chợ rồi bày sai kệ.
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. Khách vẫn tự tìm ra lối đi duy nhất dù bày kiểu gì.
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ề đến code hằng ngày
Điều đầ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ỗ đó có trả giá.
Điều 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.
Và đây là chỗ đáng nhớ nhất, vì "quan sát chạy thật rồi tối ưu" chính là triết lý đã thắng cuộc ở tầng cao — nơi nó được đưa vào lõi thay vì là một bước build tùy chọn. JVM là PGO liên tục, tự động: JIT luôn đo hồ sơ lúc chạy (nhánh nào, kiểu nào, hàm nào nóng) rồi biên dịch lại theo đó, và deoptimize khi giả định sai — không cần bạn chạy hai lượt build như C. Đây là lý do một dịch vụ Java chạy lâu đôi khi nhanh hơn bản C biên dịch tĩnh ở đúng những đường mà hồ sơ động bắt được còn compiler tĩnh phải đoán. Các trình duyệt (V8) làm y hệt cho JavaScript. Ngay cả CSDL: bộ tối ưu dựa trên ANALYZE thu thống kê phân bố dữ liệu thật rồi chọn kế hoạch — và một thống kê cũ (hồ sơ lỗi thời) đúng là nguyên nhân kinh điển của truy vấn bỗng chậm. Mẫu số chung: quyết định dựa trên đo hành vi thật gần như luôn thắng đoán tĩnh — nên các hệ thống trưởng thành chuyển từ "biên dịch một lần theo phỏng đoán" sang "đo liên tục rồi tự điều chỉnh"; và khi thứ đó chậm bất thường, nghi ngay hồ sơ/thống kê đã lệch khỏi thực tế.
Muốn tự thấy thì lấy một chương trình C 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 ./a và time ./b trên cùng dữ liệu để thấy PGO nhanh bao nhiêu. Rồi objdump -d a và objdump -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.