Bắt đầu từ Go 1.20, compiler của Go hỗ trợ PGO (profile-guided optimization — tối ưu hoá dựa trên profile thực thi) để tối ưu build sâu hơn nữa.
Mục lục:
Tổng quan
Thu thập profile
Build với PGO
Ghi chú
Câu hỏi thường gặp
Phụ lục: các nguồn profile thay thế
Tổng quan
PGO (profile-guided optimization), còn được gọi là FDO (feedback-directed optimization), là một kỹ thuật tối ưu hoá của compiler: nó đưa thông tin (một profile) từ những lần chạy tiêu biểu của ứng dụng ngược trở lại cho compiler ở lần build tiếp theo, và compiler dùng thông tin đó để ra quyết định tối ưu hoá sáng suốt hơn. Ví dụ, compiler có thể quyết định inlining mạnh tay hơn với những hàm mà profile cho thấy được gọi thường xuyên.
Trong Go, compiler dùng CPU profile dạng pprof làm profile đầu vào, chẳng hạn từ runtime/pprof hoặc net/http/pprof.
Tính đến Go 1.22, benchmark trên một tập chương trình Go tiêu biểu cho thấy build với PGO cải thiện hiệu năng khoảng 2-14%. Chúng tôi kỳ vọng mức cải thiện hiệu năng nhìn chung sẽ tăng dần theo thời gian, khi có thêm các tối ưu hoá tận dụng PGO trong những phiên bản Go tương lai.
Thu thập profile
Compiler của Go mong đợi một CPU profile dạng pprof làm đầu vào cho PGO. Profile do Go runtime sinh ra (chẳng hạn từ runtime/pprof và net/http/pprof) có thể dùng trực tiếp làm đầu vào cho compiler. Cũng có thể dùng/chuyển đổi profile từ các hệ thống profiling khác. Xem phụ lục để biết thêm thông tin.
Để có kết quả tốt nhất, điều quan trọng là profile phải tiêu biểu cho hành vi thực tế trong môi trường production của ứng dụng. Dùng một profile không tiêu biểu nhiều khả năng sẽ cho ra một binary cải thiện rất ít hoặc không cải thiện gì trong production. Vì vậy, khuyến nghị là thu thập profile trực tiếp từ môi trường production, và đó cũng là cách chính mà PGO của Go được thiết kế để phục vụ.
Quy trình làm việc điển hình như sau:
- Build và phát hành một binary ban đầu (không có PGO).
- Thu thập profile từ production.
- Khi đến lúc phát hành một binary cập nhật, build từ mã nguồn mới nhất và cung cấp profile từ production.
- GOTO 2
PGO của Go nhìn chung chịu được độ lệch giữa phiên bản ứng dụng đã được profile và phiên bản đang được build với profile đó, cũng như chịu được việc build với profile thu thập từ những binary vốn đã được tối ưu. Đây là điều làm cho vòng đời lặp này khả thi. Xem phần AutoFDO để biết thêm chi tiết về quy trình này.
Nếu việc thu thập từ môi trường production là khó hoặc bất khả thi (ví dụ, một công cụ dòng lệnh phân phối cho người dùng cuối), bạn cũng có thể thu thập từ một benchmark tiêu biểu. Lưu ý rằng xây dựng benchmark tiêu biểu thường khá khó (và giữ cho chúng tiếp tục tiêu biểu khi ứng dụng tiến hoá cũng vậy). Đặc biệt, microbenchmark thường là ứng viên tồi cho việc lấy profile phục vụ PGO, vì chúng chỉ chạy qua một phần nhỏ của ứng dụng, dẫn tới mức cải thiện nhỏ khi áp dụng cho toàn bộ chương trình.
Build với PGO
Cách build tiêu chuẩn là lưu một CPU profile dạng pprof với tên tệp default.pgo trong thư mục của package main của binary được profile. Theo mặc định, go build sẽ tự động phát hiện tệp default.pgo và bật PGO.
Khuyến nghị commit profile trực tiếp vào repository mã nguồn, vì profile là một đầu vào của build, quan trọng cho các build có thể tái lập (và có hiệu năng tốt!). Lưu cùng mã nguồn giúp trải nghiệm build đơn giản hơn, vì không cần bước nào thêm để lấy profile ngoài việc tải mã nguồn về.
Với những tình huống phức tạp hơn, cờ go build -pgo điều khiển việc chọn profile cho PGO. Cờ này mặc định là -pgo=auto, tức hành vi default.pgo mô tả ở trên. Đặt cờ thành -pgo=off sẽ tắt hoàn toàn các tối ưu hoá PGO.
Nếu bạn không thể dùng default.pgo (ví dụ, các profile khác nhau cho các kịch bản khác nhau của cùng một binary, không thể lưu profile cùng mã nguồn, v.v.), bạn có thể truyền thẳng đường dẫn tới profile cần dùng (ví dụ, go build -pgo=/tmp/foo.pprof).
Lưu ý: Đường dẫn truyền cho -pgo áp dụng cho mọi package main. Ví dụ, go build -pgo=/tmp/foo.pprof ./cmd/foo ./cmd/bar áp dụng foo.pprof cho cả hai binary foo và bar, điều mà thường không phải là thứ bạn muốn. Thông thường các binary khác nhau nên có profile khác nhau, truyền qua những lần gọi go build riêng biệt.
Lưu ý: Trước Go 1.21, mặc định là -pgo=off. PGO phải được bật một cách tường minh.
Ghi chú
Thu thập profile tiêu biểu từ production
Môi trường production của bạn là nguồn profile tiêu biểu tốt nhất cho ứng dụng của bạn, như đã mô tả ở phần Thu thập profile.
Cách đơn giản nhất để bắt đầu là thêm net/http/pprof vào ứng dụng rồi lấy /debug/pprof/profile?seconds=30 từ một instance bất kỳ của dịch vụ. Đây là cách khởi đầu rất tốt, nhưng có những lý do khiến nó có thể không tiêu biểu:
-
Instance đó có thể đang không làm gì tại thời điểm được profile, dù bình thường nó rất bận.
-
Mẫu lưu lượng có thể thay đổi trong ngày, khiến hành vi cũng thay đổi theo.
-
Các instance có thể thực hiện những thao tác chạy dài (ví dụ, 5 phút làm thao tác A, rồi 5 phút làm thao tác B, v.v.). Một profile 30 giây nhiều khả năng chỉ bao phủ một loại thao tác duy nhất.
-
Các instance có thể không nhận được phân bố request công bằng (một số instance nhận nhiều request thuộc một loại hơn các instance khác).
Một chiến lược vững hơn là thu thập nhiều profile ở các thời điểm khác nhau từ các instance khác nhau, để hạn chế ảnh hưởng từ khác biệt giữa profile của từng instance riêng lẻ. Nhiều profile sau đó có thể được gộp lại thành một profile duy nhất để dùng với PGO.
Nhiều tổ chức vận hành dịch vụ “continuous profiling” thực hiện kiểu lấy mẫu profiling trên toàn bộ đội máy một cách tự động, và kết quả đó có thể dùng làm nguồn profile cho PGO.
Gộp profile
Công cụ pprof có thể gộp nhiều profile như sau:
$ go tool pprof -proto a.pprof b.pprof > merged.pprof
Việc gộp này thực chất là phép cộng đơn giản các mẫu trong đầu vào, bất kể thời lượng thực tế của profile. Do đó, khi profile một lát cắt thời gian nhỏ của ứng dụng (ví dụ, một server chạy vô thời hạn), bạn nhiều khả năng muốn bảo đảm mọi profile đều có cùng thời lượng thực tế (tức là mọi profile đều thu thập trong 30 giây). Nếu không, những profile có thời lượng dài hơn sẽ bị chiếm tỷ trọng quá mức trong profile đã gộp.
AutoFDO
PGO của Go được thiết kế để hỗ trợ quy trình kiểu “AutoFDO”.
Hãy xem kỹ hơn quy trình đã mô tả ở phần Thu thập profile:
- Build và phát hành một binary ban đầu (không có PGO).
- Thu thập profile từ production.
- Khi đến lúc phát hành một binary cập nhật, build từ mã nguồn mới nhất và cung cấp profile từ production.
- GOTO 2
Nghe thì đơn giản đến mức dễ gây nhầm, nhưng có vài tính chất quan trọng cần lưu ý ở đây:
-
Quá trình phát triển luôn tiếp diễn, nên mã nguồn của phiên bản binary được profile (bước 2) nhiều khả năng khác đôi chút so với mã nguồn mới nhất đang được build (bước 3). PGO của Go được thiết kế để chịu được điều này, và chúng tôi gọi đó là tính ổn định theo mã nguồn (source stability).
-
Đây là một vòng lặp khép kín. Nghĩa là sau vòng lặp đầu tiên, phiên bản binary được profile bản thân nó đã được tối ưu bằng PGO với profile từ vòng lặp trước. PGO của Go cũng được thiết kế để chịu được điều này, và chúng tôi gọi đó là tính ổn định qua các vòng lặp (iterative stability).
Tính ổn định theo mã nguồn đạt được nhờ các heuristic khớp mẫu từ profile với mã nguồn đang được biên dịch. Nhờ vậy, nhiều thay đổi trong mã nguồn, như thêm hàm mới, không ảnh hưởng đến việc khớp mã hiện có. Khi compiler không khớp được phần mã đã thay đổi, một số tối ưu hoá bị mất, nhưng lưu ý rằng đây là một sự suy giảm êm ái. Một hàm không khớp được có thể bỏ lỡ cơ hội tối ưu, nhưng lợi ích tổng thể của PGO thường trải đều trên nhiều hàm. Xem phần tính ổn định theo mã nguồn để biết thêm chi tiết về việc khớp và suy giảm.
Tính ổn định qua các vòng lặp là việc ngăn chặn các chu kỳ hiệu năng dao động qua những lần build PGO liên tiếp (ví dụ, build #1 nhanh, build #2 chậm, build #3 nhanh, v.v.). Chúng tôi dùng CPU profile để xác định các hàm “nóng” cần nhắm tới để tối ưu. Về lý thuyết, một hàm nóng có thể được PGO tăng tốc nhiều đến mức nó không còn nóng trong profile kế tiếp và không được tối ưu nữa, khiến nó chậm trở lại. Compiler của Go tiếp cận các tối ưu hoá PGO một cách thận trọng, và chúng tôi tin rằng điều đó ngăn được biến động đáng kể. Nếu bạn thực sự quan sát thấy kiểu bất ổn này, xin hãy mở một issue tại go.dev/issue/new.
Cùng với nhau, tính ổn định theo mã nguồn và qua các vòng lặp loại bỏ nhu cầu build hai giai đoạn, trong đó bản build đầu tiên chưa tối ưu được profile như một canary, rồi build lại với PGO cho production (trừ khi bạn thực sự cần hiệu năng đỉnh tuyệt đối).
Tính ổn định theo mã nguồn và refactor
Như đã mô tả ở trên, PGO của Go nỗ lực hết sức để tiếp tục khớp các mẫu từ profile cũ với mã nguồn hiện tại. Cụ thể, Go dùng độ lệch dòng bên trong hàm (ví dụ, lời gọi ở dòng thứ 5 của hàm foo).
Nhiều thay đổi thường gặp sẽ không làm hỏng việc khớp, bao gồm:
-
Thay đổi trong một tệp nhưng nằm ngoài hàm nóng (thêm/sửa mã phía trên hoặc phía dưới hàm đó).
-
Chuyển một hàm sang tệp khác trong cùng package (compiler hoàn toàn bỏ qua tên tệp mã nguồn).
Một số thay đổi có thể làm hỏng việc khớp:
-
Thay đổi bên trong một hàm nóng (có thể ảnh hưởng tới độ lệch dòng).
-
Đổi tên hàm (và/hoặc kiểu dữ liệu đối với method) (làm đổi tên symbol).
-
Chuyển hàm sang package khác (làm đổi tên symbol).
Nếu profile tương đối mới, khác biệt nhiều khả năng chỉ ảnh hưởng tới một số ít hàm nóng, giới hạn tác động của những tối ưu hoá bị bỏ lỡ ở các hàm không khớp được. Dù vậy, sự suy giảm sẽ tích tụ chậm dần theo thời gian vì mã nguồn hiếm khi được refactor trở lại dạng cũ, nên việc thu thập profile mới thường xuyên là quan trọng để hạn chế độ lệch so với mã nguồn đang chạy trong production.
Một tình huống mà việc khớp profile có thể suy giảm đáng kể là một đợt refactor quy mô lớn đổi tên nhiều hàm hoặc chuyển chúng giữa các package. Trong trường hợp này, bạn có thể chịu thiệt hại hiệu năng trong ngắn hạn cho tới khi một profile mới phản ánh cấu trúc mới.
Với những lần đổi tên máy móc, về lý thuyết có thể viết lại một profile sẵn có để đổi tên symbol cũ thành tên mới. github.com/google/pprof/profile chứa các nguyên hàm cần thiết để viết lại một profile pprof theo cách đó, nhưng tại thời điểm viết bài này chưa có công cụ làm sẵn nào cho việc đó.
Hiệu năng của mã mới
Khi bạn thêm mã mới hoặc bật các nhánh mã mới bằng cách lật một cờ, phần mã đó sẽ không có mặt trong profile ở lần build đầu tiên, và do đó sẽ không nhận được tối ưu hoá PGO cho tới khi thu thập được một profile mới phản ánh phần mã mới đó. Hãy nhớ điều này khi đánh giá việc triển khai mã mới: bản phát hành đầu tiên sẽ không phản ánh hiệu năng ở trạng thái ổn định của nó.
Câu hỏi thường gặp
Có thể dùng PGO để tối ưu các package trong thư viện chuẩn của Go không?
Có. PGO trong Go áp dụng cho toàn bộ chương trình. Mọi package đều được build lại để cân nhắc các tối ưu hoá dựa trên profile, bao gồm cả package của thư viện chuẩn.
Có thể dùng PGO để tối ưu các package trong module phụ thuộc không?
Có. PGO trong Go áp dụng cho toàn bộ chương trình. Mọi package đều được build lại để cân nhắc các tối ưu hoá dựa trên profile, bao gồm cả package trong các phụ thuộc. Điều này nghĩa là cách riêng mà ứng dụng của bạn sử dụng một phụ thuộc sẽ ảnh hưởng tới những tối ưu hoá được áp dụng cho phụ thuộc đó.
PGO với một profile không tiêu biểu có làm chương trình của tôi chậm hơn so với không dùng PGO không?
Lẽ ra là không. Dù một profile không tiêu biểu cho hành vi trong production sẽ dẫn tới việc tối ưu những phần “nguội” của ứng dụng, nó không nên làm những phần nóng của ứng dụng chậm đi. Nếu bạn gặp một chương trình mà PGO cho hiệu năng tệ hơn so với khi tắt PGO, xin hãy mở một issue tại go.dev/issue/new.
Tôi có thể dùng cùng một profile cho các build GOOS/GOARCH khác nhau không?
Có. Định dạng của profile là như nhau giữa các cấu hình hệ điều hành và kiến trúc, nên chúng có thể dùng chéo giữa các cấu hình khác nhau. Ví dụ, một profile thu thập từ binary linux/arm64 có thể dùng cho một build windows/amd64.
Dù vậy, những lưu ý về tính ổn định theo mã nguồn bàn ở phần trên cũng áp dụng ở đây. Bất kỳ đoạn mã nguồn nào khác nhau giữa các cấu hình đó sẽ không được tối ưu. Với hầu hết ứng dụng, phần lớn mã là độc lập nền tảng, nên sự suy giảm kiểu này bị giới hạn.
Lấy một ví dụ cụ thể, phần nội bộ của việc xử lý tệp trong package os khác nhau giữa Linux và Windows. Nếu các hàm đó nóng trong profile trên Linux, các hàm tương đương trên Windows sẽ không nhận được tối ưu hoá PGO vì chúng không khớp với profile.
Bạn có thể gộp profile của các build GOOS/GOARCH khác nhau. Xem câu hỏi tiếp theo để biết những đánh đổi khi làm vậy.
Tôi nên xử lý thế nào khi một binary duy nhất được dùng cho nhiều loại workload khác nhau?
Không có lựa chọn nào hiển nhiên ở đây. Một binary duy nhất dùng cho nhiều loại workload khác nhau (ví dụ, một cơ sở dữ liệu được dùng thiên về đọc trong một dịch vụ, và thiên về ghi trong dịch vụ khác) có thể có những thành phần nóng khác nhau, hưởng lợi từ những tối ưu hoá khác nhau.
Có ba lựa chọn:
-
Build các phiên bản binary khác nhau cho từng workload: dùng profile từ mỗi workload để tạo nhiều bản build của binary dành riêng cho từng workload. Cách này cho hiệu năng tốt nhất với mỗi workload, nhưng có thể làm tăng độ phức tạp vận hành khi phải quản lý nhiều binary và nhiều nguồn profile.
-
Build một binary duy nhất chỉ dùng profile từ workload “quan trọng nhất”: chọn ra workload “quan trọng nhất” (chiếm nhiều tài nguyên nhất, nhạy cảm nhất về hiệu năng), rồi build chỉ với profile từ workload đó. Cách này cho hiệu năng tốt nhất với workload đã chọn, và nhiều khả năng vẫn cải thiện hiệu năng ở mức khiêm tốn cho các workload khác, nhờ tối ưu hoá phần mã dùng chung giữa các workload.
-
Gộp profile của các workload: lấy profile từ mỗi workload (có trọng số theo tổng tài nguyên chiếm dụng) và gộp chúng thành một profile “toàn đội máy” duy nhất, rồi dùng để build. Cách này nhiều khả năng cho mức cải thiện hiệu năng khiêm tốn cho mọi workload.
PGO ảnh hưởng thế nào tới thời gian build?
Bật PGO nhiều khả năng sẽ khiến thời gian build package tăng lên một cách đo được. Phần dễ nhận thấy nhất là profile của PGO áp dụng cho mọi package trong một binary, nghĩa là lần đầu dùng một profile sẽ đòi hỏi build lại mọi package trong đồ thị phụ thuộc. Các bản build này được cache như mọi bản build khác, nên những lần build tăng dần sau đó với cùng profile không cần build lại toàn bộ.
Nếu bạn thấy thời gian build tăng lên cực kỳ nhiều, xin hãy mở một issue tại go.dev/issue/new.
PGO ảnh hưởng thế nào tới kích thước binary?
PGO có thể làm binary lớn hơn một chút do có thêm việc inlining hàm.
Phụ lục: các nguồn profile thay thế
CPU profile do Go runtime sinh ra (qua runtime/pprof, v.v.) vốn đã ở đúng định dạng để dùng trực tiếp làm đầu vào cho PGO. Tuy nhiên, các tổ chức có thể có bộ công cụ ưa thích khác (ví dụ, Linux perf), hoặc các hệ thống continuous profiling toàn đội máy sẵn có mà họ muốn dùng với PGO của Go.
Profile từ nguồn thay thế có thể dùng với PGO của Go nếu được chuyển sang định dạng pprof, với điều kiện chúng tuân theo các yêu cầu chung sau:
-
Một trong các chỉ số mẫu phải có type/unit là “samples”/“count” hoặc “cpu”/“nanoseconds”.
-
Các mẫu phải biểu diễn mẫu thời gian CPU tại vị trí lấy mẫu.
-
Profile phải được symbolize (Function.name phải được đặt).
-
Các mẫu phải chứa stack frame cho những hàm đã được inline. Nếu bỏ qua các hàm được inline, Go sẽ không thể duy trì tính ổn định qua các vòng lặp.
-
Function.start_line phải được đặt. Đây là số dòng bắt đầu của hàm. Tức là dòng chứa từ khoá
func. Compiler của Go dùng trường này để tính độ lệch dòng của các mẫu (Location.Line.line - Function.start_line). Lưu ý rằng nhiều công cụ chuyển đổi pprof hiện có bỏ qua trường này.
Lưu ý: Trước Go 1.21, siêu dữ liệu DWARF bỏ qua dòng bắt đầu của hàm (DW_AT_decl_line), điều này có thể khiến các công cụ khó xác định được dòng bắt đầu.
Xem trang PGO Tools trên Go Wiki để biết thêm thông tin về khả năng tương thích PGO của các công cụ bên thứ ba cụ thể.