Bài trước ta thấy allocator ba tầng của Go cấp đối tượng nhỏ cực nhanh nhờ mcache. Bí quyết đằng sau tốc độ đó là một sự đánh đổi: Go không cấp đúng số byte bạn xin, mà làm tròn lên một trong khoảng 68 "lớp kích thước" (size class) cố định. Điều này khiến việc quản bộ nhớ nhanh và đơn giản, nhưng đẻ ra phí phạm nội bộ (internal fragmentation). Bài này đo chính xác sự làm tròn đó và dạy bạn thiết kế struct để giảm phí.

Vì sao có lớp kích thước

Nếu allocator phải cấp đúng số byte tùy ý cho mọi yêu cầu (33, 57, 1001...), nó sẽ phải quản lý một mớ hỗn độn các khối kích thước bất kỳ — chậm và dễ phân mảnh. Thay vào đó, Go định nghĩa sẵn một tập cố định các kích thước và làm tròn lên lớp gần nhất. Điều này cho phép mỗi span (dải bộ nhớ) chứa toàn các slot bằng nhau, nên cấp/thu hồi chỉ là đánh dấu slot rảnh — không cần tìm khối vừa cỡ.

Các lớp kích thước nhỏ của Go: 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, ... cho tới 32KB (khoảng 68 lớp). Chú ý một chi tiết: sau 32 nhảy thẳng lên 48 — không có lớp 40.

Ảnh chụp đoạn mã Go nền tối minh hoạ size classes và span Go làm tròn mọi cấp phát, Go không cấp đúng số byte bạn xin mà làm tròn lên một trong khoảng 68 lớp kích thước cố định cho phép quản bộ nhớ nhanh nhưng đẻ ra phí phạm nội bộ, hàm classSize đo lớp kích thước thực cấp N object cỡ s HeapAlloc chia N bằng cỡ lớp, các lớp kích thước nhỏ byte 8 16 24 32 48 64 80 96 112 128 144 160 176 192 208 224 240 256 chú ý sau 32 nhảy thẳng lên 48 không có 40 khoảng 68 lớp tới 32KB, span một span là dải trang bộ nhớ liên tiếp mỗi trang 8KB dành riêng cho một lớp kích thước chia đều thành các slot bằng nhau ví dụ span của lớp 32 byte chứa nhiều slot 32 byte mcache giữ sẵn một span cho mỗi lớp nên cấp object nhỏ chỉ là lấy slot rảnh kế tiếp cực nhanh

Hình 1: Go làm tròn mọi cấp phát lên lớp gần nhất. Một span là dải trang dành riêng cho một lớp, chia thành các slot bằng nhau — nên cấp object chỉ là lấy slot rảnh kế tiếp.

Đo thật: sự làm tròn và phí phạm

Cách đo trực tiếp: cấp 200.000 object của mỗi cỡ, đọc HeapAlloc trước/sau, chia cho số object ra kích thước lớp thực. (Lưu ý kỹ thuật: phải cấp mảng keep giữ tham chiếu trước khi đo, nếu không slice header của nó lẫn vào kết quả.)

runtime.ReadMemStats(&a)
for i := 0; i < N; i++ { keep[i] = make([]byte, s) }
runtime.ReadMemStats(&b)
return int(b.HeapAlloc-a.HeapAlloc) / N   // = kích thước lớp thực

Ảnh chụp bảng kết quả đo thật nền tối yêu cầu tới lớp kích thước tới phí phạm cấp 200000 object mỗi cỡ đo HeapAlloc trên object Go 1.23 arm64 nhiễu đo 1 byte, 8 tới 8 phí 0 byte 0 phần trăm khớp lớp không phí, 9 tới 16 phí 7 byte 44 phần trăm vừa qua lớp 8 phí nhiều, 16 tới 16 phí 0, 17 tới 24 phí 7 byte 29 phần trăm, 24 tới 24 phí 0, 25 tới 32 phí 7 byte 22 phần trăm, 32 tới 32 phí 0, 33 tới 48 phí 15 byte 31 phần trăm sau 32 nhảy thẳng 48, 48 tới 48 phí 0, 49 tới 64 phí 15 byte 23 phần trăm, 65 tới 80 phí 15 byte 19 phần trăm, 80 tới 80 phí 0, 129 tới 144 phí 15 byte 10 phần trăm, 256 tới 256 phí 0, cốt lõi Go làm tròn mọi cấp phát lên lớp gần nhất object khớp đúng cỡ lớp thì 0 phí object vừa nhỉnh hơn một lớp phí nhiều nhất tới 44 phần trăm

Hình 2: Object khớp lớp (8, 16, 24, 32, 48, 80, 256) tốn 0 phí. Object vừa nhỉnh hơn ranh giới lớp phí nhiều nhất: 9→16 (44%), 33→48 (31%), 49→64 (23%). Đo thật với ~1 byte nhiễu.

Kết quả rõ ràng một quy luật:

  • Object khớp đúng cỡ lớp → 0 phí: xin 8 → tốn 8, xin 16 → 16, xin 32 → 32, xin 48 → 48, xin 80 → 80, xin 256 → 256.
  • Object vừa nhỉnh hơn một lớp → phí nhiều nhất: xin 9 → tốn 16 (phí 7 byte, 44%!), xin 33 → tốn 48 (phí 15 byte, 31%), xin 129 → tốn 144 (10%).

Phần trăm phí giảm dần khi object lớn hơn (vì 15 byte thừa trên 256 byte chỉ là 6%), nhưng ở cỡ nhỏ nó có thể lên tới 44%. Đây chính là phí phạm nội bộ: bộ nhớ bạn trả tiền nhưng không dùng, cái giá của việc quản bộ nhớ nhanh bằng lớp cố định.

Span: đơn vị tổ chức bộ nhớ

Một span (mspan) là một dải các trang bộ nhớ liên tiếp (mỗi trang 8KB), dành riêng cho một lớp kích thước, chia đều thành các slot bằng nhau. Ví dụ, một span 8KB cho lớp 32-byte chứa 256 slot 32 byte. mcache giữ sẵn một span cho mỗi lớp, nên cấp một object nhỏ chỉ là "lấy slot rảnh kế tiếp trong span hiện tại" — không cần tính toán, không cần khoá. Khi span đầy, mcache xin span mới từ mcentral. Cấu trúc "một span, một lớp, slot bằng nhau" là thứ khiến cấp phát vừa nhanh vừa dễ cho GC quét (mọi object trong span cùng cỡ, cùng bố cục).

Ứng dụng thực tế

Thiết kế struct khớp ranh giới lớp. Nếu một struct của bạn là 33 byte, mỗi thể hiện tốn 48 byte — phí 15 byte. Cắt xuống 32 byte (khớp lớp) tiết kiệm 31% bộ nhớ cho object đó. Với struct cấp phát hàng triệu lần, chênh lệch này đáng kể cho cả bộ nhớ lẫn áp lực GC (ít byte hơn = ít cần thu gom).

Sắp xếp lại field để giảm kích thước struct. Kích thước struct phụ thuộc thứ tự field (do padding căn lề — chủ đề riêng). Đặt field lớn trước, gộp field nhỏ, có thể kéo struct từ 33 xuống 32 byte và nhảy về lớp thấp hơn. Dùng unsafe.Sizeof để kiểm.

Object đúng lũy thừa 2 hoặc bội của 16 thường khớp lớp. Buffer 64, 128, 256, 512 byte khớp chính xác các lớp — không phí. Nếu bạn tự do chọn kích thước buffer, chọn các cỡ này thay vì số lẻ như 100 hay 200.

Đánh đổi cần cân nhắc

Phí phạm nội bộ là cái giá của tốc độ — thường đáng. Đừng ám ảnh tối ưu từng byte. Lớp kích thước cố định là thứ khiến allocator nhanh và GC đơn giản; vài phần trăm phí là đổi lấy điều đó. Chỉ tối ưu khớp lớp khi struct được cấp phát ở quy mô rất lớn và profiler cho thấy bộ nhớ là nút thắt.

Không có lớp 40 (và các khoảng cách khác) là cố ý. Bộ lớp kích thước được chọn để cân bằng giữa số lượng lớp (nhiều lớp = ít phí nhưng nhiều metadata) và phí phạm. Các khoảng cách lớn hơn ở cỡ lớn (256, 288, 320...) chấp nhận nhiều phí tuyệt đối hơn vì phần trăm vẫn nhỏ.

Object >32KB không dùng size class. Chúng đi thẳng vào mheap và được cấp theo bội số trang (8KB), nên "phí phạm" của chúng là phần lẻ trang cuối. Quy tắc khớp-lớp chỉ áp cho object nhỏ và vừa (≤32KB).

Ba ý mang về

  1. Go làm tròn mọi cấp phát lên một trong ~68 lớp kích thước cố định (8, 16, 24, 32, 48, 64, 80...) — cho phép mỗi span chứa các slot bằng nhau, cấp/thu hồi cực nhanh và GC dễ quét.
  2. Object khớp cỡ lớp tốn 0 phí, object vừa nhỉnh hơn ranh giới phí nhiều nhất: đo thật, xin 9 byte tốn 16 (phí 44%), xin 33 tốn 48 (phí 31%), nhưng xin đúng 16/32/48/256 thì không phí gì.
  3. Thiết kế struct khớp ranh giới lớp giảm bộ nhớ và áp lực GC: kéo struct 33 byte xuống 32 tiết kiệm 31% cho object đó — nhưng chỉ đáng làm khi cấp phát ở quy mô lớn và profiler chỉ ra bộ nhớ là nút thắt.

Phần sau ta soi kỹ tối ưu đã nhắc ở bài allocator: Phần sau mổ xẻ tiny allocator — cách Go gộp nhiều object dưới 16 byte không con trỏ vào chung một khối, và khi nào cơ chế này giúp (hoặc không giúp) bạn.