Hãy hình dung một toà nhà và một cuốn danh bạ nhỏ ghi "tên người → số phòng". Nếu toà nhà chia thành hàng nghìn phòng tí hon, cuốn danh bạ đầy ngay lập tức — mỗi lần tìm một người không có trong danh bạ, bảo vệ phải chạy đi tra sổ cái đồ sộ ở tầng hầm. Bây giờ gộp lại thành ít căn hộ lớn: một dòng danh bạ giờ phủ cả một căn hộ rộng, tra nhanh hơn hẳn. Nhưng có cái giá: nếu bạn thuê nguyên một căn hộ hai triệu byte chỉ để treo đúng một cái áo, thì phần còn lại bỏ không — trả tiền cả căn cho một mẩu dùng. Trang lớn của Linux đúng là những căn hộ đó, cuốn danh bạ là TLB, và bài này đo cả hai mặt.
Phần 10 đo lỗi trang. Có một cách giảm số lỗi trang và trượt TLB xuống 512 lần: dùng trang 2 MB thay vì 4 KB. Ta đo cả hai mặt của việc đó.
Trang lớn giải quyết vấn đề gì
Mỗi lần CPU dịch một địa chỉ ảo sang địa chỉ vật lý, nó tra bảng trang. Bộ đệm cho việc tra đó gọi là TLB, và nó rất nhỏ — vài nghìn mục. Với trang 4 KB, vài nghìn mục TLB phủ được vài chục megabyte. Vùng dữ liệu lớn hơn thế thì mỗi lần chạm vào chỗ mới là một lần trượt TLB: CPU phải đi bộ qua bảng trang bốn tầng, mỗi tầng là một lần đọc bộ nhớ — bảo vệ chạy xuống hầm tra sổ. Trang 2 MB làm một mục TLB phủ 2 MB thay vì 4 KB — gấp 512 lần.
Đo phần lợi
Chương trình duyệt ngẫu nhiên theo trang, mỗi trang chứa chỉ số trang tiếp theo nên các bước phụ thuộc nhau (như phần 9 đã chỉ ra, đây là điều kiện bắt buộc để thấy độ trễ thật). Ba lần đo mỗi cấu hình:
| Vùng nhớ | Trang 4 KB | Trang 2 MB | Lợi |
|---|---|---|---|
| 64 MB | 86,8 / 86,1 / 81,7 ns | 45,1 / 44,6 / 49,4 ns | 1,8× |
| 256 MB | 140,4 / 115,7 / 122,4 ns | 82,3 / 81,6 / 112,8 ns | ~1,4× |
| 1.024 MB | 181,9 / 170,3 / 172,0 ns | 163,5 / 161,4 / 155,6 ns | 1,09× |
Vùng càng lớn, lợi càng nhỏ — ngược với trực giác
Đáng lẽ vùng càng lớn thì áp lực TLB càng cao và trang lớn càng đáng giá. Kết quả ngược lại. Câu trả lời nằm ở dòng AnonHugePages của chính chương trình:
| Vùng yêu cầu | Thực sự được trang lớn |
|---|---|
| 64 MB | 65.536 KB — 100% |
| 256 MB | 262.144 KB — 100% |
| 1.024 MB | 495.616 KB — 47% |
Ở 1 GB, nhân chỉ gom được chưa tới một nửa. Một trang 2 MB cần 2 MB bộ nhớ vật lý liên tục, và trên một máy đã chạy được vài giờ, những khối liên tục như vậy trở nên hiếm. Nhân không báo lỗi. Nó chỉ lặng lẽ cấp trang 4 KB cho phần còn lại. Chương trình xin trang lớn, nhận được một nửa, và không có tín hiệu nào ngoài con số trong smaps.
Đây là điểm hay bị bỏ qua nhất: MADV_HUGEPAGE là lời đề nghị, không phải lời hứa, và tỷ lệ được đáp ứng phụ thuộc vào mức phân mảnh của máy tại thời điểm đó — nghĩa là kết quả đo lúc máy vừa khởi động không dự đoán được kết quả sau ba ngày chạy.
Đo phần giá
Chương trình cấp 512 MB rồi chạm đúng 1 byte trong mỗi vùng 2 MB — tổng cộng 256 byte dữ liệu thật:
| RSS | AnonHugePages |
|
|---|---|---|
MADV_NOHUGEPAGE |
2.088 KB | 0 KB |
MADV_HUGEPAGE |
457.904 KB | 456.704 KB |
219 lần bộ nhớ cho cùng 256 byte dữ liệu. Cơ chế đơn giản: chạm vào 1 byte trong một vùng 2 MB làm nhân cấp cả trang 2 MB — thuê nguyên căn hộ cho một cái áo. Với trang 4 KB, chạm 1 byte chỉ cấp 4 KB.
Đây chính là lý do Redis, MongoDB, và nhiều cơ sở dữ liệu khác in cảnh báo khởi động yêu cầu tắt trang lớn trong suốt. Chúng cấp phát rải rác nhiều vùng nhỏ, và mỗi vùng vài KB kéo theo nguyên một trang 2 MB.
Một cái giá thứ hai, khó thấy hơn: khi bộ nhớ phân mảnh, nhân phải dồn nén để tạo ra khối 2 MB liên tục, và quá trình đó dừng tiến trình đang xin lại. Trong phép đo của tôi lần chạy đầu tiên mất 0,042 giây, lần thứ hai 0,009 giây — chênh gần 5 lần cho cùng một việc.
Ba chế độ
cat /sys/kernel/mm/transparent_hugepage/enabled
| Chế độ | Nghĩa |
|---|---|
always |
Mọi vùng ẩn danh đủ lớn đều được gom thành trang lớn |
madvise |
Chỉ vùng nào gọi madvise(MADV_HUGEPAGE) |
never |
Tắt hẳn |
madvise là lựa chọn mặc định đúng cho máy chủ. Nó giao quyền quyết định cho ứng dụng — thứ duy nhất biết vùng nhớ của nó sẽ được chạm dày hay thưa.
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
Nút defrag quyết định nhân có được chặn tiến trình lại để dồn nén bộ nhớ hay không. Đặt always ở đây là nguồn gốc của những khoảng đình trệ vài trăm mili giây không lý do — tiến trình đang xin bộ nhớ bị treo trong khi nhân đi dọn dẹp.
Trang lớn tĩnh
Ngoài trang lớn trong suốt còn có trang lớn cấp phát sẵn — dành riêng lúc khởi động, không bao giờ bị hoán đổi hay dồn nén:
sysctl -w vm.nr_hugepages=1024 # danh 2 GB
grep HugePages /proc/meminfo
Ứng dụng lấy chúng qua hugetlbfs hoặc cờ MAP_HUGETLB. Đây là cách PostgreSQL (huge_pages = on) và Oracle dùng cho vùng đệm dùng chung. Ưu điểm: không phân mảnh, không đình trệ, hiệu quả ổn định. Nhược điểm: bộ nhớ đó bị khoá cứng — không ai khác dùng được, kể cả khi bộ đệm trang đang thiếu.
Khi nào dùng
Trang lớn đáng dùng khi cả ba điều kiện cùng đúng:
- Vùng nhớ liên tục và lớn (từ vài trăm MB trở lên).
- Được chạm gần hết, không rải rác.
- Sống lâu, để chi phí gom trả góp được.
Vùng đệm của cơ sở dữ liệu quan hệ, mảng số học của tính toán khoa học, heap của JVM khai cố định — cả ba đều khớp. Bộ nhớ của một ứng dụng web thông thường thì không.
Mẫu số chung: gom hạt to giảm chi phí quản lý, nhưng chỉ lời khi lấp gần đầy
Cái đánh đổi ở đây không riêng gì trang bộ nhớ: chọn đơn vị cấp phát lớn luôn cắt được chi phí quản lý (ít mục tra cứu, ít lần cấp phát, ít metadata) — nhưng đổi lại là lãng phí nội bộ khi bạn chỉ lấp nó thưa thớt. Cùng một cây cân này ở mọi tầng.
Trong thế giới Java, đây đúng là chuyện cỡ khởi tạo của HashMap/ArrayList và TLAB: khai new HashMap<>(1_000_000) cho một map cuối cùng chỉ giữ 3 phần tử là trả trước cả một mảng khổng lồ — đúng "thuê căn hộ cho một cái áo"; còn để mặc định thì phải chịu vài lần cấp-lại-và-copy khi map lớn dần. Và -XX:+UseLargePages cho heap JVM chính là huge page ở tầng ứng dụng, với đúng điều kiện "heap lớn, sống lâu, chạm gần hết" như trên.
Trong Go, cùng đánh đổi ở make([]T, 0, n): prealloc capacity lớn tránh được chuỗi grow-và-copy (mỗi lần nhân đôi rồi copy) nhưng phí RAM nếu slice không lấp tới; chọn n là chọn đúng điểm cân giữa số lần copy và lượng bỏ không.
Còn ở cơ sở dữ liệu, đây là extent/segment allocation và prealloc tablespace: cấp theo khối lớn (extent 1 MB, WAL segment 16 MB) giảm số lần xin đĩa và ít phân mảnh, nhưng một bảng bé xíu vẫn chiếm trọn extent đầu; fillfactor là nút chỉnh cùng bản chất — chừa chỗ trống trong trang để update tại chỗ, đổi dung lượng lấy tốc độ.
Mẫu số chung: đơn vị cấp phát càng lớn thì chi phí quản lý trên mỗi byte càng thấp (ít tra cứu, ít lần xin, ít copy) nhưng lãng phí khi lấp thưa càng cao — nên hạt to chỉ thắng khi bạn thật sự lấp nó gần đầy và giữ đủ lâu để trả góp chi phí gom. Nên câu hỏi đúng khi chọn cỡ khối/trang/capacity không phải "to hơn có nhanh hơn không" mà là "vùng này sẽ được lấp bao nhiêu phần trăm và sống bao lâu" — vì cùng một lựa chọn cho nhanh 1,8 lần khi lấp đầy lại phí 219 lần khi lấp thưa.
Muốn tự soi máy mình đang ở chế độ nào và ai đang dùng trang lớn:
echo "che do: $(cat /sys/kernel/mm/transparent_hugepage/enabled)"
echo "defrag: $(cat /sys/kernel/mm/transparent_hugepage/defrag)"
grep -E 'AnonHugePages|HugePages_Total|Hugepagesize' /proc/meminfo
echo "--- tien trinh dung trang lon nhieu nhat ---"
for p in $(ls /proc | grep -E '^[0-9]+$'); do
h=$(awk '/^AnonHugePages:/{print $2}' /proc/$p/smaps_rollup 2>/dev/null)
[ "${h:-0}" -gt 0 ] && printf "%9s KB %s\n" "$h" \
"$(tr -d '\0' < /proc/$p/cmdline | cut -c1-45)"
done 2>/dev/null | sort -rn | head -5
Nếu chế độ là always và một tiến trình có AnonHugePages lớn hơn nhiều so với lượng dữ liệu bạn biết nó thực sự giữ, bạn đang trả đúng cái giá 219 lần ở trên — chỉ là ở tỷ lệ nhẹ hơn.
Phần sau: malloc và cấp phát bộ nhớ — đo phân mảnh.