Đếm số chuyến xe cần để chở 16.000 viên gạch, bạn nhẩm "mỗi chuyến một viên, vậy 16.000 chuyến" — nhưng nếu xe là xe tải chở 500 viên một lượt, đáp án thật là 32 chuyến. Nhẩm sai không phải vì phép tính, mà vì bạn đoán nhầm sức chở mỗi chuyến. Tôi đã nhẩm y hệt vậy với lỗi trang: chờ 16.384, đo ra 32, vì mỗi "chuyến" hóa ra to gấp 512 lần tôi tưởng. Ở các bài trước, ta nhắc tới việc bộ nhớ được cấp lười biếng: fork không sao chép trang, execve không đọc hết file, malloc không chạm bộ nhớ. Cơ chế đứng sau tất cả cái "lười" đó là lỗi trang (page fault). Khi chương trình chạm vào một địa chỉ chưa có ánh xạ trang hợp lệ, phần cứng tạm dừng nó và trao quyền cho kernel. Bài này đo hai loại lỗi trang — và ngay phép đếm đầu tiên đã lệch năm trăm lần so với dự đoán của tôi.

Minor và major: hai loại lỗi trang

Không phải lỗi trang nào cũng đắt như nhau. Có hai loại, khác nhau một trời một vực:

  • Lỗi trang nhẹ (minor fault): dữ liệu đã ở trong RAM rồi, chỉ là chưa được ánh xạ vào bảng trang của tiến trình này. Ví dụ: một trang zero cấp theo nhu cầu (bộ nhớ anonymous mới), một trang trong page cache mà tiến trình chưa map, hay một trang copy-on-write. Kernel chỉ cần dựng một mục bảng trang trỏ tới trang có sẵn — rất rẻ, cỡ vài trăm nano giây.
  • Lỗi trang nặng (major fault): dữ liệu không có trong RAM, phải đọc từ đĩa — file chưa nằm trong page cache, hoặc trang đã bị đẩy ra swap. Kernel phải thực hiện I/O đĩa, chặn tiến trình lại trong lúc chờ — đắt gấp hàng chục tới hàng nghìn lần lỗi nhẹ, vì đĩa chậm hơn RAM rất nhiều.

Ta đếm chúng bằng getrusage (trường ru_minflt và ru_majflt) hoặc /usr/bin/time -v.

Đo: chạm bộ nhớ mới, đếm lỗi trang

Tôi cấp 64 MB bộ nhớ anonymous (mmap với MAP_ANONYMOUS) rồi chạm một byte vào mỗi trang 4 KB — tất cả là 16.384 trang — và đọc ru_minflt trước sau. Dự đoán của tôi thẳng thừng: mỗi trang lần đầu bị chạm sẽ gây một lỗi trang nhẹ, nên tôi chờ khoảng 16.384 lỗi.

getrusage báo: +32.

Ba mươi hai. Không phải mười sáu nghìn. Lệch năm trăm lần. Con số nhỏ đến vô lý này là dấu hiệu tôi đang đo trong một điều kiện mình không kiểm soát — và lần này thủ phạm là trang lớn trong suốt (transparent huge pages, THP). Đọc /sys/kernel/mm/transparent_hugepage/enabled thấy nó đang ở chế độ [always]: kernel tự động lót các vùng bộ nhớ anonymous lớn bằng trang 2 MB thay vì trang 4 KB thông thường. Một trang 2 MB chứa 512 trang nhỏ, nên một lỗi trang ánh xạ trọn 2 MB một lúc. 64 MB chia cho 2 MB đúng bằng 32 — khớp con số đo được.

Trang không phải luôn 4 KB

Để chắc THP là nguyên nhân chứ không phải tôi đoán, tôi tắt nó chỉ cho vùng đó bằng madvise(buf, n, MADV_NOHUGEPAGE) rồi chạm lại 16.384 trang:

THP bật (mặc định): chạm 16.384 trang -> minor +32
THP tắt (NOHUGEPAGE): chạm 16.384 trang -> minor +16.384

Con số nhảy từ 32 lên đúng 16.384 — bằng 64 MB chia cho 4 KB. Vậy là đã rõ ràng và kiểm chứng được: THP bật thì một lỗi trang lo 2 MB; THP tắt thì một lỗi trang lo 4 KB; cùng một đoạn mã chạm cùng lượng bộ nhớ, số lỗi trang chênh nhau 512 lần chỉ vì kích thước trang khác nhau.

Điều rút ra: "một lỗi trang cho mỗi 4 KB" là một giả định sai khi THP đang bật — và kích thước trang là một biến ẩn có thể làm con số lệch hàng trăm lần. Nếu tôi tin cứng "mỗi trang một lỗi" và tính ngược ra lượng bộ nhớ đã chạm từ số lỗi trang, tôi sẽ ước lượng sai 500 lần. Cách chữa là cách đúng của cả sê-ri: đừng giả định, đọc bằng chứng. getrusage cho con số thật; /proc/self/smaps có dòng AnonHugePages cho biết vùng nào được lót bằng trang lớn. Trang là 4 KB hay 2 MB không phải điều để nhớ máy móc, mà là điều để đo trên hệ thống thật.

Còn một nửa của bài — lỗi trang nặng — thì khó thấy hơn tôi tưởng. Tôi đọc một file 64 MB qua mmap mong thấy hàng nghìn lỗi nặng (đọc đĩa), nhưng đo ra chỉ 1 lỗi nặng và 1.024 lỗi nhẹ. Lý do: file tôi vừa tạo vẫn còn nằm gần trọn trong page cache (ghi file cũng đưa dữ liệu vào cache), và kernel còn đọc trước (readahead) nhiều trang mỗi lần chạm. Muốn thấy lỗi nặng thật, phải xả page cache (echo 3 > /proc/sys/vm/drop_caches) — nhưng trong container thư mục đó chỉ-đọc, nên tôi không ép được. Đây cũng là một biến ẩn: page cache giấu gần hết lỗi nặng, nên đo I/O trên một file "nóng" sẽ cho ấn tượng sai rằng đọc đĩa lúc nào cũng nhanh.

Về đến code hằng ngày

Điều đầu tiên: major fault là kẻ giết hiệu năng, minor fault thì rẻ. Khi một chương trình bỗng chậm hẳn và bạn thấy ru_majflt hay cột lỗi trang nặng trong time -v tăng vọt, đó là dấu hiệu nó đang đọc dữ liệu từ đĩa (hoặc tệ hơn, từ swap) — mỗi lỗi nặng là một lần chờ I/O. Ngược lại, hàng triệu lỗi nhẹ thường vô hại (chỉ là bộ nhớ được cấp dần). Phân biệt hai loại khi chẩn đoán là bước đầu tiên: time -v ./chuong-trinh tách rõ "Minor" và "Major page faults".

Điều thứ hai: trang lớn (huge pages) giảm số lỗi trang và áp lực TLB, đáng cân nhắc cho vùng nhớ lớn. Vì một lỗi trang 2 MB làm việc của 512 lỗi 4 KB, các ứng dụng cấp phát và chạm nhiều bộ nhớ (cơ sở dữ liệu, máy ảo, tính toán lớn) hưởng lợi từ THP hay huge pages tường minh — ít lỗi trang hơn, ít mục TLB hơn (bài về TLB và trang lớn đã đo kỹ). Nhưng cũng phải biết THP đang bật để không bị bất ngờ khi đo, như tôi vừa bị.

Và cả hai bài học — "một con số phụ thuộc những hằng số bạn tưởng cố định (kích thước trang)" và "một lớp cache ẩn giấu mất chi phí (page cache giấu major fault)" — vọng thẳng vào việc benchmark ở tầng cao. Trong Java, đếm số lần GC hay allocation mà tính ngược ra lượng bộ nhớ dựa trên "object cỡ X byte" hay sai, vì header, padding và nén con trỏ (compressed oops) đổi kích thước thật; và một benchmark I/O trên file vừa ghi đo page cache chứ không đo đĩa — y hệt cái bẫy nửa sau bài này. Trong CSDL, EXPLAIN (ANALYZE, BUFFERS) phân biệt shared hit (cache) với read (đĩa) đúng vì lý do đó — một truy vấn "nhanh" lần hai chỉ là buffer đã ấm. Mẫu số chung: trước khi tính ngược một đại lượng từ số đếm (lỗi trang → bộ nhớ, GC → allocation, thời gian → tốc độ đĩa), phải kiểm những hằng số ẩn (kích thước trang/đơn vị) và những lớp cache có thể giấu chi phí — con số nhẩm ra từ giả định lệch với con số đo được có khi hàng trăm lần. Gói lại bằng số: chạm 64 MB bộ nhớ mới gây 32 lỗi trang nhẹ khi THP bật (trang 2 MB) nhưng 16.384 khi THP tắt (trang 4 KB) — lệch 512 lần; và đọc một file đã nằm trong cache gần như không gây lỗi trang nặng nào (1 major, 1.024 minor), vì page cache và readahead giấu chúng. Muốn hiểu một chương trình dùng bộ nhớ và I/O ra sao, đọc getrusage/time -v và /proc/self/smaps — con số thật, không phải con số nhẩm từ "mỗi trang 4 KB".

Muốn tự thấy thì chạy /usr/bin/time -v ./chuong-trinh-cua-ban (hoặc bất cứ lệnh nào) và tìm hai dòng "Minor (reclaiming a frame) page faults" và "Major (requiring I/O) page faults". Một chương trình bình thường sẽ có nhiều lỗi nhẹ (cấp bộ nhớ) và rất ít hoặc không có lỗi nặng — nếu số lỗi nặng lớn, chương trình đang đọc nhiều từ đĩa hoặc swap. Rồi thử cat /sys/kernel/mm/transparent_hugepage/enabled: nếu thấy [always], mọi phép đếm lỗi trang của bạn trên bộ nhớ lớn sẽ tính theo trang 2 MB, không phải 4 KB — đúng cái bẫy bài này đo. Đó là cách nhìn tận mắt vì sao "mỗi trang một lỗi" không đáng tin nếu chưa hỏi trang cỡ nào.