Bài devirtualization cho thấy compiler biến lời gọi interface thành lời gọi trực tiếp khi chứng minh được kiểu tĩnh. Nhưng phần lớn lời gọi interface trong code thật thì compiler không chứng minh được — kiểu động đến từ nơi khác. PGO (Profile-Guided Optimization) giải quyết đúng chỗ này: nó dùng một profile CPU từ chương trình chạy thật để biết lời gọi nào thường là kiểu nào, rồi tối ưu dựa trên đó. Bài này đo thật PGO làm gì — và trung thực về khi nào nó không giúp gì.
Vấn đề PGO giải quyết
Xét một hàm nóng nhận interface, mà nơi gọi giấu kiểu cụ thể:
type Xuly interface{ Chay(x int) int }
// nhận interface → compiler KHÔNG biết kiểu động tĩnh
func xuLyNong(h Xuly, n int) int {
s := 0
for i := 0; i < n; i++ { s += h.Chay(i) } // dispatch động, không devirt tĩnh được
return s
}
Devirtualization tĩnh (bài trước) bó tay: h đến từ hàm khác, compiler không biết chắc kiểu. Nhưng lúc chạy, giả sử 99% trường hợp h là kiểu A. Thông tin này chỉ có khi quan sát chương trình chạy — đó là điều profile cung cấp.

Hình 1: Vấn đề PGO giải quyết và ba bước dùng. PGO devirtualize suy đoán dựa profile: chèn kiểm tra kiểu + gọi trực tiếp cho nhánh nóng, dispatch động làm dự phòng.
Ba bước dùng PGO
# 1. Thu profile CPU từ chạy thật (production hoặc benchmark đại diện)
pprof.StartCPUProfile(f); defer pprof.StopCPUProfile()
# 2. Đặt profile tên default.pgo cạnh package main
cp cpu.pprof default.pgo # Go 1.21+ TỰ nhận file này
# 3. Build — compiler tự dùng nó
go build . # hoặc -pgo=default.pgo tường minh
Từ Go 1.21, nếu có file default.pgo trong thư mục package main, go build tự dùng nó — không cần cờ. Đây là thiết kế để PGO dễ áp dụng: chỉ cần commit profile vào repo.
PGO làm gì: devirtualize suy đoán
Điểm khác cốt lõi với devirt tĩnh: PGO không cần chắc chắn kiểu, chỉ cần biết kiểu nào phổ biến. Với profile cho thấy h.Chay 99% là A.Chay, compiler chèn:
if kiểu(h) == A {
A.Chay(i) // gọi TRỰC TIẾP, inline được → nhánh nóng nhanh
} else {
h.Chay(i) // dispatch động → nhánh dự phòng
}
Một kiểm tra kiểu rẻ, rồi nhánh nóng gọi trực tiếp (và có thể inline sâu), nhánh dự phòng vẫn dùng dispatch động cho các kiểu khác. Đây là cách PGO vượt qua giới hạn "phải chứng minh tĩnh" của devirt thường.
Đo thật: nó devirtualize, nhưng...
Xác minh PGO có tác dụng bằng -gcflags=-m:

Hình 2: -pgo=off không devirt; -pgo=default.pgo in "PGO devirtualizing interface call h.Chay to A.Chay". Nhưng benchmark micro gần như không đổi (~1690 ns cả hai) — lợi ích thật là 2-14% trên app lớn.
-pgo=off: không dòng nào — dispatch động giữ nguyên.-pgo=default.pgo:PGO devirtualizing interface call h.Chay to A.Chay— PGO đã devirtualize dựa profile.
PGO đã làm điều devirt tĩnh không làm được. Nhưng benchmark thời gian thì trung thực mà nói: gần như không đổi — không PGO ~1.685 ns, có PGO ~1.700 ns. Vì sao? Lời gọi Chay quá rẻ (x+1), nên chi phí kiểm tra kiểu thêm vào bù trừ đúng phần dispatch tiết kiệm được. Đây là sự thật quan trọng: PGO không phải phép màu cho mọi code.
Lợi ích thật của PGO ở đâu
- Benchmark micro: gần như 0 — lời gọi quá rẻ, type-guard bù trừ phần lợi.
- App lớn thật: 2-14% (con số Go team báo cáo) — nơi có nhiều đường nóng phức tạp.
- Lợi nhiều nhất: hàm nóng đắt, nơi devirtualize mở khóa inline cả một chuỗi hàm, kích hoạt các tối ưu xuyên hàm khác.
PGO tỏa sáng trên dịch vụ lớn với nhiều hot path, không phải trên benchmark đồ chơi. Con số benchmark nhỏ ở đây minh họa đúng điều này — đừng kỳ vọng phép màu từ một hàm x+1.
Ứng dụng thực tế
Áp PGO cho dịch vụ backend nóng. Nếu bạn chạy một service Go xử lý nhiều request, thu profile CPU từ production (hoặc staging đại diện), commit thành default.pgo, và build sẽ tự dùng. Vài phần trăm hiệu năng "miễn phí" ở quy mô lớn là đáng.
Profile phải đại diện tải thật. PGO tối ưu theo cái profile thấy. Nếu profile lấy từ tải không giống production (ví dụ chỉ một endpoint), PGO tối ưu sai chỗ. Thu profile từ tải thực tế, đa dạng, đủ dài.
Cập nhật profile định kỳ. Khi code và mẫu tải đổi, profile cũ có thể lỗi thời. Nhiều nhóm tự động hóa: thu profile từ production hàng tuần, cập nhật default.pgo trong CI. Profile lỗi thời vẫn an toàn (chỉ là tối ưu kém tối ưu), không gây sai.
Đánh đổi cần cân nhắc
PGO tăng thời gian biên dịch. Compiler phải đọc profile và làm thêm phân tích, nên build với PGO chậm hơn (Go team báo ~vài % tới hơn thế tùy dự án). Đổi thời gian build lấy hiệu năng runtime — đáng với service chạy lâu, ít đáng với công cụ CLI chạy ngắn.
Lợi ích rất khác nhau, phải đo trên tải thật. Như benchmark cho thấy, PGO có thể cho ~0% trên code đơn giản và tới 14% trên code phức tạp. Đừng áp PGO rồi giả định nhanh hơn — đo trên tải production thật trước và sau. Với nhiều ứng dụng, lợi ích không đủ để bù phức tạp vận hành (quản lý profile).
PGO an toàn nhưng thêm phức tạp quy trình. Profile sai/lỗi thời không làm chương trình sai (chỉ tối ưu kém), nên PGO an toàn về đúng đắn. Nhưng nó thêm một artifact phải quản lý (thu, cập nhật, commit profile). Cân nhắc chi phí vận hành này với lợi ích đo được — chỉ áp khi lợi ích rõ ràng.
Ba ý mang về
- PGO dùng profile CPU chạy thật để tối ưu điều phân tích tĩnh không làm được: đo thật, PGO devirtualize suy đoán lời gọi
h.ChaythànhA.Chay(dựa profile 99% kiểu A) mà devirt tĩnh bó tay — chèn kiểm tra kiểu + gọi trực tiếp cho nhánh nóng, dispatch động làm dự phòng; xác nhận bằng-gcflags=-m. - Dùng PGO cực dễ nhưng lợi ích tùy khối lượng: chỉ cần đặt
default.pgocạnh package main (Go 1.21+ tự nhận) — nhưng đo thật benchmark micro gần như không đổi (~1690 ns) vì lời gọi quá rẻ, type-guard bù trừ; lợi ích thật là 2-14% trên app lớn với nhiều hot path. - PGO an toàn nhưng đo trên tải thật trước khi áp: profile phải đại diện production và cập nhật định kỳ, PGO tăng thời gian build và thêm artifact phải quản lý — chỉ đáng cho dịch vụ nóng chạy lâu nơi vài phần trăm hiệu năng có giá trị.
Phần sau ta chuyển sang một chủ đề nền tảng của lập trình đồng thời: Phần sau mổ xẻ Go memory model — quan hệ happens-before, vì sao đọc/ghi không đồng bộ giữa goroutine là hành vi không xác định, và các bảo đảm mà channel/mutex/atomic cung cấp.