Zero-Growth Stack, Real Gains: How Stack Allocation Can Save 10% CPU in Go

Bài tóm tắt Uber 2026 4. Điều chỉnh Độ khó: Khó

Uber · 2026 · 4. Điều chỉnh · Độ khó: Khó — bài gốc

Bối cảnh

  • Khoảng 65% service của Uber viết bằng Go, hơn 2 triệu core; theo Uber, cải thiện 1% hiệu suất toàn fleet đáng vài triệu USD.
  • Goroutine bắt đầu với stack 2 KB (thread OS thường 2 MB). Khi hàm sắp tràn, runtime cấp stack gấp đôi và copy nội dung sang (runtime.morestack → copystack). Go 1.19 thêm stack khởi đầu thích ứng theo kích thước trung bình đang theo dõi, nhưng vẫn có thể đắt với một số ứng dụng. Một service lớn có khoảng 10% CPU cho việc nới stack, trong khi bộ nhớ stack rất thấp (12–16 MB trên instance 16 GB).

Thay đổi chính

  • Hai hướng: pool goroutine (team M3 đã dùng; phải sửa code, hiểu ứng dụng, giao tiếp qua channel có chi phí, kích thước tĩnh) hoặc chỉnh runtime. Chọn chỉnh runtime theo cách ít đụng nhất: patch runtime để cho linkname tới hai biến riêng (debug và startingStackSize, vì từ Go 1.23 linkname cần "hợp đồng"), viết hàm công khai trong module nội bộ để tắt stack khởi đầu thích ứng và tắt co stack (co stack cũng tốn CPU), rồi đặt kích thước tĩnh khi ứng dụng khởi động; giá trị đến từ hệ cấu hình nên thử được theo môi trường, zone, region.
  • Rủi ro được nói rõ: cộng đồng Go có thể đổi cách định nghĩa, làm code này hỏng; dùng build tag với file rỗng cho phiên bản Go sắp tới, và mỗi bản Go mới phải kiểm cấu trúc riêng còn như cũ.
  • Chọn kích thước bằng profile: thử dần (4 KB, 8 KB...) quá chậm và có thể phản tác dụng; đo kích thước stack lúc chạy thì quá đắt. Cách chọn: lấy profile bằng thư viện pprof, giữ các stack trace có copystack, và với mỗi hàm trong trace đọc lệnh sub đầu tiên trong mã máy (dò bằng thư viện disassemble trên binary có debug symbol) để biết hàm đó dành bao nhiêu stack; cộng theo trace. Trace lớn nhất khoảng 19 KB, làm tròn lên lũy thừa của 2 là 32 KB. Bộ phân tích còn chỉ ra hàm nóng tốn stack: middleware observability của yarpc dùng 2,6 KB mỗi request, sửa còn 600 byte (đã đưa lên GitHub).
  • Kết quả: stack khởi đầu 2 KB → 32 KB; CPU cho việc nới stack từ gần 10% xuống dưới 1%; stack mỗi instance khoảng 50 MB, vài cái tới 200 MB (dưới 2% của 16 GB); CPU của service giảm khoảng 16%. Tiếp theo: tự động tìm service đáng làm (CPU nới stack cao, còn dư bộ nhớ).

Vì sao

  • Mặc định của runtime cân giữa bộ nhớ thấp và CPU; với service có nhiều bộ nhớ dư, đổi bộ nhớ lấy CPU là có lợi, nhưng runtime không cho chỉnh điều đó.

Bài học cho SWE

  • Mặc định của runtime tối ưu cho trường hợp chung; ở quy mô lớn, một vài phần trăm CPU trong một cơ chế nội bộ là đáng đo.
  • Chỉnh vào chỗ không công khai thì cô lập bằng build tag, kiểm lại ở mỗi phiên bản, và nói rõ đây là cách tạm cho tới khi có giải pháp chính thức.
  • Lấy số từ dữ liệu sẵn có (profile + mã máy) thay vì thử deploy nhiều vòng.

Liên quan