Bộ nhớ ảo có một mẹo cốt lõi: khi bạn xin bộ nhớ, nhân không cấp trang vật lý ngay — nó chỉ ghi vào bản đồ địa chỉ rằng vùng đó "tồn tại". Chỉ khi bạn thật sự chạm vào một trang, CPU mới phát hiện nó chưa có trang vật lý và kích hoạt một lỗi trang (page fault), buộc nhân cấp trang ngay lúc đó. Lỗi trang có hai loại với chi phí khác nhau một trời một vực, và bài này đo cả hai — rồi phát hiện chính hai bộ đếm lỗi trang mà ai cũng tin lại nói dối tôi hai lần.

Lỗi trang

Minor và major

Không phải lỗi trang nào cũng như nhau:

  • Minor fault (lỗi nhẹ): nhân giải quyết được mà không cần đĩa. Ví dụ: bạn chạm lần đầu vào một trang bộ nhớ ẩn danh (anonymous, như malloc) — nhân chỉ cần cấp một trang trắng đã có sẵn trong RAM. Hoặc bạn chạm một trang của một tệp đã nằm trong page cache. Rất nhanh, cỡ micro giây.
  • Major fault (lỗi nặng): trang phải được lấy từ đĩa. Ví dụ: một trang đã bị đẩy ra swap, hoặc một trang của tệp chưa từng được đọc nên chưa có trong cache. Chậm hơn nhiều bậc vì phải chờ I/O đĩa.

Linux đếm hai loại này riêng, và getrusage() cho bạn đọc chúng qua ru_minfltru_majflt. Tôi dùng đúng hai bộ đếm đó để đo.

Đo: minor nhanh, "major" từ đĩa chậm 27 lần

Tôi chạm vào 200 MB bộ nhớ (51.200 trang 4 KB) trong ba tình huống, đếm lỗi trang và đo thời gian:

Phép minor major µs/trang Ghi chú
ANON (chạm lần đầu) 102 1 1,00 bộ nhớ ẩn danh
FILE LẠNH (đã xóa cache) 2986 1 0,99 từ đĩa: 50,7 ms
FILE ẤM (trong cache) 3058 0 0,04 từ cache: 1,8 ms

Con số cốt lõi nằm ở hai dòng cuối. Khi tôi mmap một tệp 200 MB rồi chạm từng trang: nếu tệp lạnh (tôi đã xóa page cache trước đó), việc chạm mất tổng 50,7 ms — dữ liệu phải đọc từ đĩa. Nếu tệp ấm (vừa đọc xong nên còn trong cache), chạm chỉ mất 1,8 msnhanh gấp 27 lần, vì mọi trang đã sẵn trong RAM. Đó chính là khác biệt giữa "lấy từ đĩa" và "lấy từ cache", đo được rõ ràng. Nhưng nhìn hai cột minor/major thì... chúng gần như không phản ánh khác biệt đó. Và đấy là lúc tôi nhận ra mình đang bị bộ đếm lừa.

Một lần tôi đo hớ: bộ đếm lỗi trang nói dối hai lần

Tôi vào bài với niềm tin đơn giản: mỗi trang 4 KB được chạm sẽ sinh đúng một lỗi trang, và cột major sẽ vọt lên khi đọc từ đĩa. Cả hai đều sai.

Lần một — trang lớn trong suốt. Chạm 200 MB bộ nhớ ẩn danh, tôi chờ ~51.200 lỗi minor (một cho mỗi trang 4 KB). Bộ đếm báo 102. Chỉ 102 lỗi cho 51.200 trang? Lý do: nhân đã cấp bộ nhớ này bằng trang lớn trong suốt (Transparent Huge Pages — THP), mỗi trang lớn 2 MB thay vì 4 KB. Một lỗi trang lớn phủ 512 trang thường một lúc, nên 200 MB ÷ 2 MB ≈ 100 lỗi. Tệ hơn, tôi đã chia thời gian cho số trang (51.200) để ra "1,00 µs mỗi lỗi" — nhưng thực ra chỉ có 102 lỗi, nên con số per-fault đó sai hoàn toàn; mỗi lỗi trang lớn thật ra tốn hàng trăm micro giây (cấp và xóa trắng 2 MB).

Lần hai, nặng hơn — readahead. Đọc tệp lạnh sau khi xóa cache, tôi chắc mẩm sẽ thấy hàng nghìn major fault (mỗi trang một lần đọc đĩa). Bộ đếm báo major = 1. Nhưng thời gian là 50,7 ms — rõ ràng dữ liệu đến từ đĩa. Mâu thuẫn này giải ở cơ chế readahead: khi tôi chạm trang đầu, nhân đoán tôi đọc tuần tự nên nạp trước cả một vùng lớn từ đĩa một cách bất đồng bộ. Đến khi tôi chạm các trang sau, chúng đã (hoặc gần) sẵn trong cache, nên luồng của tôi hầu như không phải chặn chờ đĩa. Mà một "major fault" chỉ được đếm khi luồng thật sự bị chặn chờ I/O. Readahead biến hàng nghìn major fault tiềm năng thành minor fault — dù đĩa vẫn phải làm việc, và thời gian vẫn phản ánh điều đó.

Bài học đo lường: một bộ đếm đôi khi đo một thứ khác với thứ bạn nghĩ nó đo. ru_minflt/ru_majflt không đếm "một lỗi mỗi trang 4 KB" như trực giác — chúng bị THP gộp lại và bị readahead phân loại lại. Hai con số (major = 1 nhưng thời gian 50,7 ms) mâu thuẫn nhau, và như các bài trước đã nhắc, mâu thuẫn đó nghĩa là tôi đang tin nhầm một đại lượng. Cái nói thật ở đây là đồng hồ: 50,7 ms so với 1,8 ms cho biết đĩa-so-với-cache chính xác, trong khi bộ đếm lỗi thì không. Khi nghi ngờ, tin thời gian hơn bộ đếm.

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

Hệ quả đầu tiên là hiểu demand paging: xin bộ nhớ không tốn RAM, chạm mới tốn. malloc hay mmap một vùng lớn gần như tức thì và không tốn bộ nhớ vật lý — chi phí thật bị hoãn tới lần chạm đầu tiên của mỗi trang, dưới dạng một minor fault. Đây là lý do một chương trình có thể "cấp" vài GB mà VmRSS vẫn nhỏ (đã đo ở bài copy-on-write), và là lý do đôi khi một vòng lặp khởi tạo mảng lớn chạy chậm bất ngờ ở lần đầu — nó đang trả thuế lỗi trang cho từng trang. Muốn tránh cú giật đó ở đường nóng, người ta "chạm trước" (pre-fault) bộ nhớ, hoặc dùng MAP_POPULATE.

Hệ quả thứ hai là major fault là tín hiệu của I/O, và cần bị săn lùng trong đường nóng. Một minor fault ~1 µs thì vô hại; một major fault thật (chờ đĩa) có thể là hàng trăm micro giây tới hàng mili giây. Nếu chương trình của bạn liên tục sinh major fault, nghĩa là nó đang liên tục lấy trang từ đĩa — có thể vì bộ nhớ bị đẩy ra swap (RAM không đủ), hoặc vì đang quét một tệp lớn hơn cache. getrusage và cột MAJFL trong top (nhấn f để bật) cho bạn thấy điều này; major fault cao là dấu hiệu thiếu RAM hoặc mẫu truy cập tệ.

Hệ quả thứ ba là một nguyên tắc đo lường lặp lại: đừng để một bộ đếm hệ thống quyết định thay cho phép đo thời gian. Con số mang theo: minor fault (không cần đĩa) ~1 µs, major fault (từ đĩa) chậm hơn nhiều — đọc tệp lạnh từ đĩa chậm gấp 27 lần đọc từ cache (50,7 so 1,8 ms); nhưng bộ đếm lỗi trang bị THP gộp (200MB anon chỉ 102 lỗi, không phải 51200) và bị readahead phân loại lại (đọc đĩa chỉ đếm 1 major), nên hãy đo bằng thời gian, không chỉ bằng bộ đếm. Lỗi trang là nơi bộ nhớ ảo trả giá thật; đọc đúng nó cần biết cả THP và readahead đang âm thầm làm gì.

Thử ba mươi giây

Chạy /usr/bin/time -v <chương-trình> (chú ý đường dẫn đầy đủ, không phải time của shell) trên một lệnh bất kỳ — ví dụ /usr/bin/time -v cat mot-file-lon > /dev/null. Trong bảng kết quả, tìm hai dòng Major (requiring I/O) page faultsMinor (reclaiming a frame) page faults. Chạy lần đầu (tệp lạnh) rồi chạy lại ngay (tệp đã ấm trong cache): bạn sẽ thấy số major fault ở lần đầu cao hơn lần hai — hoặc, đúng như bài này đo, có thể không cao như bạn tưởng vì readahead đã che. Dù sao, so thời gian hai lần chạy: lần ấm nhanh hơn hẳn. Đó là page cache và lỗi trang đang quyết định tốc độ, hiện ra ngay trên máy bạn.