Ở bài về gợi ý nhánh và các phần tối ưu trước, trình biên dịch phải đoán nhánh nào hay chạy, hàm nào đáng nội tuyến. Tối ưu theo hồ sơ (profile-guided optimization, PGO) thay việc đoán bằng việc đo: chạy thử chương trình, ghi lại nhánh nào thật sự nóng, rồi biên dịch lại với số liệu đó. Nghe như một cách chắc thắng. Bài này chạy đủ quy trình PGO trong container — và biến thành một bài học đo lường về chính bản thân phép đo, khi con số "PGO nhanh hơn" của tôi hóa ra đo nhầm thứ.
PGO gồm hai bước
Quy trình PGO có hai lượt biên dịch. Bước một, gcc -O2 -fprofile-generate dựng một bản có đo đạc (instrumented): mỗi nhánh, mỗi vòng lặp được cài bộ đếm. Bạn chạy bản này trên dữ liệu đại diện cho tải thật, và nó ghi ra file .gcda chứa số liệu: nhánh này rẽ trái bao nhiêu lần, vòng kia lặp bao nhiêu. Bước hai, gcc -O2 -fprofile-use biên dịch lại từ đầu, nhưng lần này đọc .gcda và dùng số thật để quyết định: xếp nhánh nóng thành đường đi thẳng (fall-through), đẩy nhánh lạnh ra xa, tách mã nóng khỏi mã lạnh, và nội tuyến những hàm mà hồ sơ cho thấy hay được gọi.
Điều kiện sống còn — sẽ quay lại ở phần đo hớ — là hồ sơ phải đại diện cho tải thật. PGO tối ưu cho đúng cái nó thấy lúc huấn luyện; cho nó xem sai, nó tối ưu sai.
Đo: PGO "nhanh hơn" 1,3 lần... hay không?
Tôi viết một chương trình duyệt mảng 4 triệu phần tử, mỗi phần tử rẽ một trong hai nhánh: một nhánh rẻ (cộng một số) và một nhánh đắt (mười hai vòng số học). Tham số p điều khiển bao nhiêu phần trăm phần tử đi nhánh rẻ. Tôi huấn luyện hồ sơ với p=90 (nhánh rẻ là thường gặp), rồi đo trên chính p=90:
| Cấu hình | Thời gian (p=90) |
|---|---|
-O2 (không hồ sơ) |
6,60 ms |
-O2 -fprofile-use (PGO) |
5,04 ms |
PGO nhanh hơn -O2 khoảng 1,3 lần. Con số đẹp, câu chuyện gọn: "đo nhánh thật rồi tối ưu, nhanh hơn hẳn". Tôi đã suýt dừng ở đây và viết bài. Nhưng có một chi tiết tôi lướt qua ở bước biên dịch — và nó lật ngược kết luận.
Một lần tôi đo hớ: hồ sơ chưa từng được nạp
Ở những lần chạy đầu, khi biên dịch bước -fprofile-use, gcc in ra một cảnh báo mà tôi liếc qua rồi bỏ: profile count data file not found. Lý do kỹ thuật là file .gcda được đặt tên theo tên file đầu ra, mà bản đo đạc (gen) và bản cuối (use) có tên khác nhau, nên gcc tìm không thấy hồ sơ. Nói cách khác: hồ sơ chưa từng được nạp. Bản "PGO" của tôi thực chất được biên dịch mà không có một byte số liệu nào.
Vậy tại sao nó vẫn nhanh hơn -O2? Đáng lẽ tôi phải khựng lại ngay ở câu hỏi đó. Và có một dấu hiệu thứ hai còn rõ hơn: khi tôi đo bản "PGO" trên dữ liệu ngược với huấn luyện (p=10 — giờ nhánh đắt mới là thường gặp), nó vẫn nhanh gấp đôi -O2 (8,2 so với 17,2 ms). Điều này bất khả nếu hồ sơ thật đang định hướng bố cục: một hồ sơ huấn luyện trên p=90 phải giúp trường hợp p=90 và làm hại trường hợp p=10, chứ không thể giúp cả hai. Một phép đo cho kết quả bất khả nghĩa là công cụ đang nói dối — ở đây, "PGO" nhanh trên mọi dữ liệu nghĩa là cái nhanh đó không đến từ hồ sơ.
Thủ phạm là một biến ẩn: cờ -fprofile-use tự động bật thêm một loạt tối ưu — -funroll-loops, -finline-functions và vài cái khác — dù hồ sơ có nạp được hay không. Chính những cờ này, không phải số liệu hồ sơ, tạo ra cái nhanh tôi đo được. Để kiểm chứng, tôi sửa lại tên file cho hồ sơ nạp thật (giờ không còn cảnh báo), rồi so với một baseline công bằng: -O2 cộng đúng những cờ mà -fprofile-use ngầm bật.
| Cấu hình | p=90 | p=10 |
|---|---|---|
-O2 |
6,60 ms | 17,20 ms |
-O2 -funroll-loops -finline-functions |
4,42 ms | 8,34 ms |
| PGO (hồ sơ nạp thật) | 5,04 ms | 8,17 ms |
Kết quả trung thực làm tôi tỉnh người: baseline với cùng bộ cờ (4,42 ms) còn nhanh hơn PGO thật (5,04 ms) ở trường hợp khớp huấn luyện. Toàn bộ "cái lợi của PGO" mà tôi đo lúc đầu thực ra là của mấy cái cờ ngầm; số liệu hồ sơ ở bài toán bé này gần như không thêm gì. Đọc assembly cũng khớp: cả -O2 lẫn PGO đều đã xếp nhánh rẻ làm đường đi thẳng — vì heuristic tĩnh của trình biên dịch vốn đã đoán đúng, hồ sơ chẳng có gì để sửa.
Bài học đo lường chồng ba lớp, tất cả đều là chủ đề của sê-ri này: cái cảnh báo tôi lướt qua chính là công cụ đang nói cho tôi biết; một con số bất khả (nhanh trên cả dữ liệu ngược) là dấu đang đo nhầm đại lượng — tôi tưởng đo tác dụng của hồ sơ mà thật ra đo tác dụng của cờ ngầm; và "PGO nhanh hơn" là vô nghĩa nếu chưa hỏi "nhanh hơn baseline nào" — so với -O2 trần thì có, so với baseline cùng cờ thì không.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là về chính PGO: nó tỏa sáng ở chương trình lớn, không phải microbenchmark. Cái PGO làm giỏi nhất là bố cục mã — dồn các hàm nóng gần nhau, tách đường lạnh (xử lý lỗi, trường hợp hiếm) ra xa để đường nóng nằm gọn trong cache lệnh. Trong một vòng lặp bé xíu chạy gọn trong L1 như bài này, không có áp lực cache lệnh để mà giảm, nên lợi ích bố cục gần như bằng không và mấy cờ tối ưu vòng lặp lấn át tất cả. Trên một trình duyệt web hay một cơ sở dữ liệu megabyte mã, PGO cắt được lỗi cache lệnh và dự đoán nhánh ở quy mô mà microbenchmark không bao giờ thấy — đó là nơi con số PGO thật sự đáng.
Hệ quả thứ hai là cái bẫy đại diện: hồ sơ huấn luyện phải giống tải thật, nếu không PGO tối ưu cho sai trường hợp và có thể chậm hơn không dùng. Nếu bạn huấn luyện trên dữ liệu thử nghiệm nhỏ, đều đặn, trong khi production là dữ liệu lệch, PGO sẽ xếp bố cục cho một thực tế không tồn tại. Đây là lý do PGO khó áp dụng đúng: bạn phải có một tải huấn luyện đại diện, và phải cập nhật hồ sơ khi tải thay đổi — nếu không, một hồ sơ cũ lệch còn tệ hơn không hồ sơ.
Hệ quả thứ ba là bài học đo lường tổng quát, và là cái đáng mang theo nhất: khi so hai cấu hình biên dịch, phải kiểm soát mọi cờ ngầm, đọc mọi cảnh báo, và nghi ngờ mọi con số quá đẹp. Một cờ như -fprofile-use kéo theo cả một chùm cờ khác; quy cái nhanh cho "hồ sơ" trong khi thủ phạm là "-funroll-loops" là đo nhầm đại lượng. Con số mang theo: cái "PGO nhanh 1,3 lần" của tôi hóa ra là tác dụng của các cờ mà -fprofile-use tự bật, không phải của hồ sơ — vì hồ sơ ban đầu còn chưa nạp được, mà tôi bỏ qua cảnh báo và không thấy điều bất khả rằng nó nhanh cả trên dữ liệu ngược. PGO là công cụ thật và mạnh ở đúng chỗ của nó; nhưng để biết nó có giúp bạn không, hãy so với một baseline công bằng, đọc cảnh báo, và tin cái đồng hồ hơn cái tên cờ.
Thử ba mươi giây
Lần tới khi ai đó khoe một cờ biên dịch làm chương trình nhanh hơn, hỏi đúng một câu: "nhanh hơn baseline nào?". Rồi tự kiểm: xem tài liệu của cờ đó (man gcc, mục -fprofile-use chẳng hạn) liệt kê những cờ nào nó ngầm bật, và dựng một baseline có đúng những cờ ấy nhưng thiếu cái tính năng đang xét. Nếu baseline đó nhanh ngang, thì cái "tính năng" bạn tưởng đang đo thực ra chẳng đóng góp gì — công lao thuộc về mấy cờ đi kèm. Và luôn đọc cảnh báo của trình biên dịch: một dòng profile count data file not found là sự khác biệt giữa "đang đo PGO" và "tưởng đang đo PGO".