Ở 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ả những 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 để xử lý. Bài này đo hai loại lỗi trang trong container — 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, vì một tính năng của kernel mà tôi quên mất đang bật.

Lỗi trang

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_minfltru_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.

Một lần tôi đo hớ: 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.

Bài học đo lường: "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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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".

Hệ quả 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 sẽ đ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ị.

Hệ quả thứ ba là bài học đo lường mang theo: số lỗi trang phụ thuộc những biến ẩn — kích thước trang và trạng thái page cache — nên phải đo, đừng suy ra từ giả định. Con số mang theo: 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, 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, hãy đọc getrusage/time -v/proc/self/smaps — con số thật, không phải con số bạn nhẩm ra từ "mỗi trang 4 KB".

Thử ba mươi giây

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.