Mỗi lần chương trình đọc một ô nhớ, CPU phải dịch địa chỉ ảo mà chương trình thấy sang địa chỉ vật lý thật trong RAM. Phép dịch đó tra một bảng trang nhiều tầng, và để khỏi tra đi tra lại, CPU giữ một bộ đệm nhỏ tên TLB (Translation Lookaside Buffer) chứa các bản dịch gần đây. TLB rất nhỏ — cỡ vài nghìn mục. Câu hỏi thực tế: khi vùng dữ liệu lớn hơn nhiều so với thứ TLB đệm nổi, cái giá phải trả là bao nhiêu, và trang lớn (huge pages) cắt được bao nhiêu cái giá đó? Bài này đo trực tiếp — và vấp một biến ẩn khiến phép đo đầu tiên của tôi suýt cho kết quả bằng không.

Huge pages

TLB và trang lớn

Bình thường một trang bộ nhớ là 4 KB. Một vùng 1 GB vì thế gồm 256 nghìn trang, mỗi trang cần một bản dịch riêng. TLB chỉ giữ được cỡ 1.500–3.000 mục, nên nó không thể nào đệm hết 256 nghìn bản dịch. Khi ta truy cập ngẫu nhiên khắp 1 GB, hầu như mỗi lần chạm một trang mới là một lỗi TLB (TLB miss): CPU phải dừng lại dò bảng trang trong RAM để tìm bản dịch — một chuyến đi phụ tốn hàng chục chu kỳ, cho mỗi truy cập.

Trang lớn đổi luật chơi. Một huge page 2 MB thay thế cho 512 trang 4 KB, và quan trọng là một mục TLB giờ phủ 2 MB thay vì 4 KB — gấp 512 lần diện tích. Cùng một TLB, nhưng với trang 2 MB nó phủ được 512 lần nhiều bộ nhớ hơn: cả 1 GB chỉ còn 512 trang, TLB ôm trọn, và lỗi TLB gần như biến mất. Linux cấp trang lớn qua THP (Transparent Huge Pages) — nhân tự gộp các trang 4 KB thành 2 MB sau lưng, điều khiển bằng madvise(MADV_HUGEPAGE) hoặc thiết lập toàn cục ở /sys/kernel/mm/transparent_hugepage/enabled.

Đo: 4KB mất 138 ns, 2MB chỉ 114 ns

Tôi mmap một vùng 1 GB, rồi biến nó thành một vòng hoán vị Sattolo — mỗi ô chứa chỉ số của ô kế tiếp, xếp thành đúng một chu trình đi qua toàn bộ vùng theo thứ tự ngẫu nhiên. Sau đó chạy một vòng pointer-chase: p = a[p] lặp 30 triệu lần. Kiểu truy cập này cố tình khắc nghiệt: mỗi bước phụ thuộc bước trước (CPU không thể chạy song song nhiều truy cập để giấu độ trễ), và địa chỉ nhảy ngẫu nhiên khắp 1 GB (vừa trượt cache, vừa thrash TLB). Tôi đo thời gian mỗi truy cập, lấy trung vị của 3 lần:

Chế độ AnonHugePages ns / mỗi truy cập
BASE — 4 KB (MADV_NOHUGEPAGE) 0 MB 138,5 ns
HUGE — 2 MB (MADV_HUGEPAGE) 1024 MB 114,4 ns

Trang 4 KB mất 138,5 ns mỗi truy cập; trang lớn 2 MB chỉ 114,4 ns — nhanh hơn khoảng 17%. Điểm đẹp của phép đo này là nó tách biến sạch: cả hai chế độ chạy y hệt một kiểu truy cập, cùng một số lần trượt cache (vì cùng vùng 1 GB và cùng vòng hoán vị), chỉ khác cỡ trang. Nên phần chênh 24 ns đó không lẫn với chi phí cache — nó chính là chi phí dò bảng trang do lỗi TLB, thứ mà trang lớn cắt bỏ. Với một truy cập ~114 ns đã tốn phần lớn vào việc chờ RAM (trượt cache), thì 24 ns lỗi TLB cộng thêm là một phần rất đáng kể.

Một lần tôi đo hớ: cái "nền 4KB" hóa ra là trang lớn

Kết quả gọn ở trên không phải thứ tôi thấy lần đầu. Ban đầu tôi làm điều tự nhiên nhất: mmap vùng nhớ, chạm cho nó vào RAM, không madvise gì cả, và lấy nó làm "nền 4 KB" để so với phiên bản MADV_HUGEPAGE. Kết quả làm tôi hụt hẫng: nền và bản trang-lớn nhanh y hệt nhau (114 ns). Tôi suýt viết một bài với kết luận "trang lớn chẳng giúp gì trong phép đo của tôi".

May là tôi có in ra AnonHugePages đọc từ /proc/self/smaps_rollup — trường cho biết bao nhiêu bộ nhớ của tiến trình đang thực sự nằm trên trang lớn. Con số làm lộ tất cả: cái "nền" của tôi có AnonHugePages = 1024 MB, y như bản MADV_HUGEPAGE. Nói cách khác, "nền 4 KB" của tôi hoàn toàn không phải 4 KB — nó đã được cấp trang 2 MB từ đầu. Tôi đang so trang lớn với... trang lớn, nên tất nhiên chúng bằng nhau.

Thủ phạm là một biến ẩn của môi trường: máy đặt transparent_hugepage/enabled = [always], nghĩa là nhân tự động gộp mọi vùng ẩn danh đủ lớn thành trang 2 MB, dù tôi có xin hay không. Muốn có một nền 4 KB thật, tôi phải chủ động từ chối bằng madvise(MADV_NOHUGEPAGE) — chỉ khi đó AnonHugePages mới về 0 MB và phép đo mới ra con số 138,5 ns của trang thường.

Bài học đo lường: môi trường tự dựng cũng là một biến, và THP=always là một biến ẩn kinh điển. Tôi tưởng mình "đang dùng 4 KB" chỉ vì tôi không xin trang lớn, nhưng mặc định của hệ thống đã âm thầm quyết định thay tôi. Và như mọi khi, một khác biệt bằng không giữa hai thứ đáng lẽ phải khác nhau là một tín hiệu báo động: gần như chắc chắn tôi đang đo nhầm — ở đây là so hai thứ hóa ra giống hệt. Đừng tin mình đang ở điều kiện nào; hãy xác minh nó bằng một dụng cụ đo trực tiếp (AnonHugePages), đừng suy từ ý định.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là trang lớn giúp đúng một loại tải: vùng dữ liệu lớn, truy cập rải rác. Cái giá của lỗi TLB chỉ lộ ra khi vùng làm việc lớn hơn nhiều những gì TLB phủ nổi kiểu truy cập nhảy lung tung (bảng băm khổng lồ, cơ sở dữ liệu trong bộ nhớ, đồ thị lớn, mô phỏng khoa học). Với dữ liệu nhỏ vừa TLB, hoặc truy cập tuần tự (bộ tiền nạp và tính cục bộ trang che gần hết lỗi TLB), trang lớn gần như không đổi gì. Nên trước khi bật huge page, hãy hỏi tải của mình có TLB-bound không — nếu không, nó chỉ thêm phức tạp mà chẳng nhanh hơn.

Hệ quả thứ hai là THP có mặt trái, và biết chế độ của hệ thống là quan trọng. Việc nhân gộp trang lớn không miễn phí: nó cần các khối 2 MB liền mạch về vật lý, nên khi bộ nhớ phân mảnh, tiến trình gộp (khugepaged) tốn CPU đi dồn trang, và một số tải nhạy độ trễ từng gặp khựng vì chuyện này. Nhiều cơ sở dữ liệu lớn thậm chí khuyên tắt THP always và chỉ dùng madvise (chỉ vùng nào xin mới được trang lớn). Biết máy mình đang ở always, madvise hay never — và vùng nào thực sự nằm trên trang lớn — là điều kiện để không bị bất ngờ.

Hệ quả thứ ba là một bài học đo lường lặp lại suốt sê-ri: luôn xác minh điều kiện thí nghiệm bằng một dụng cụ trực tiếp, đừng suy từ ý định. Tôi "định" đo 4 KB, nhưng hệ thống cho tôi 2 MB; chỉ AnonHugePages mới nói ra sự thật. Con số mang theo: trên 1 GB truy cập ngẫu nhiên, trang 4 KB mất 138,5 ns mỗi lần còn trang lớn 2 MB chỉ 114,4 ns (~17% nhanh hơn) — phần chênh là chi phí lỗi TLB mà trang lớn cắt; nhưng THP=always có thể âm thầm cấp trang lớn cho cả "nền" của bạn, nên phải kiểm AnonHugePages để biết mình thật sự đang đo cỡ trang nào. Trang lớn là một công cụ sắc, nhưng chỉ khi bạn biết chắc mình đang cầm nó.

Thử ba mươi giây

Xem chế độ THP của máy bạn: cat /sys/kernel/mm/transparent_hugepage/enabled — dấu ngoặc vuông chỉ chế độ đang bật (always, madvise, hay never). Rồi xem một tiến trình nặng bộ nhớ đang dùng bao nhiêu trang lớn: tìm PID của nó và chạy grep AnonHugePages /proc/<pid>/smaps_rollup — nếu con số lớn, phần lớn bộ nhớ ẩn danh của nó đang nằm trên trang 2 MB (thường là điều tốt cho tải TLB-bound). Muốn thấy khác biệt tận tay, viết một vòng pointer-chase ngẫu nhiên trên một mảng lớn hơn RAM-đệm-TLB nhiều lần, chạy một lần với MADV_HUGEPAGE và một lần với MADV_NOHUGEPAGE, và luôn in kèm AnonHugePages để chắc chắn mỗi lần bạn đo đúng cỡ trang mình nghĩ — đúng cái bẫy bài này vấp phải.