Ở phần Copy-on-Write ta gặp một loại page fault: khi con ghi vào trang chia sẻ. Nhưng CoW fault chỉ là một họ hàng của cơ chế lớn hơn — page fault là cách hệ điều hành cấp bộ nhớ lười: mmap/malloc chỉ đặt chỗ vùng địa chỉ ảo, còn trang vật lý chỉ được cấp khi bạn thật sự chạm vào. Câu hỏi: một page fault tốn bao nhiêu, và có phải mọi fault như nhau không? Tôi đo trong container gcc:13 (ARM) — và cái đo hớ lần này nằm ở chính phương pháp đo, được một bộ đếm của nhân vạch trần.
Lazy allocation, minor và major
Khi bạn mmap một vùng lớn hay malloc nhiều bộ nhớ, nhân không cấp trang vật lý ngay — nó chỉ ghi nhận "vùng ảo này hợp lệ". Lần đầu bạn chạm (đọc/ghi) vào một trang chưa có, CPU không tìm thấy ánh xạ và phát một page fault, bẫy vào nhân. Nhân xử lý rồi cho lệnh chạy lại. Có hai loại, chênh nhau về bậc:
Minor fault (nhẹ): trang chưa có nhưng không cần đọc đĩa — nhân chỉ cấp một trang vật lý (với bộ nhớ ẩn danh thì zero sạch nó), ánh xạ vào bảng trang, xong. Tất cả trong RAM.
Major fault (nặng): nội dung trang phải đọc từ đĩa (file mmap chưa nằm trong cache, hoặc trang bị đẩy ra swap). Nhân phải chờ một thao tác I/O đĩa — chậm hơn minor fault bậc lớn (chục đến trăm µs vì tốc độ đĩa). (Thành thật: container tôi đo không có swap và không dễ tạo áp lực đĩa, nên tôi không tạo được major fault thật để đo — con số major dưới đây là bậc độ lớn đã biết, không phải đo trực tiếp.)
Đo: 368ns một fault — nhưng đếm cho đúng
Tôi mmap 30.000 trang (~117 MB) ẩn danh rồi chạm lần đầu mỗi trang, đo thời gian và — quan trọng — đếm số fault thật bằng getrusage (ru_minflt):
MINOR fault (THP tắt, 1 fault/trang) : ~368 ns/fault (rusage: 30002 fault / 30000 trang)
chạm lần 2 (đã có trang, KHÔNG fault) : 6 ns/trang
THP bật (mặc định): per-trang chạm : 58 ns/trang (rusage: CHỈ 364 fault / 30000 trang!)
MAP_POPULATE (pre-fault): chạm sau đó : 5,2 ns/trang
Một minor fault tốn ~368 ns: bẫy vào nhân, cấp một trang, zero nó, ánh xạ. So với một lần chạm đã có trang (6 ns), một fault đắt gấp ~60 lần. MAP_POPULATE bảo nhân cấp sẵn mọi trang lúc mmap, nên các lần chạm sau chỉ 5,2 ns — cái giá được dời về lúc mmap, đúng như pre-fault chống độ trễ đã gợi ý.
Một lần tôi đo hớ: 30000 trang, chỉ 364 fault
Đây là chỗ tôi vấp, và nó nằm ở cách đo. Ban đầu tôi làm cách hiển nhiên: chạm 30.000 trang, chia tổng thời gian cho 30.000, ra 58 ns/trang — và suýt gọi đó là "chi phí một page fault". Nhưng tôi kiểm bộ đếm rusage, và nó báo chỉ 364 minor fault cho 30.000 trang chạm. Không phải 30.000. Nếu mỗi trang một fault, con số phải là 30.000; hụt gần trăm lần nghĩa là nhân không fault từng trang một.
Thủ phạm: Transparent Huge Pages (THP). Nhân Linux, khi thấy một vùng ẩn danh lớn, phục vụ bằng trang khổng lồ 2 MB thay vì trang 4 KB — một huge page chứa 512 trang nhỏ, nên một fault ánh xạ 512 trang cùng lúc. 30.000 trang / 364 fault ≈ 82 trang mỗi fault (một pha trộn huge page và fault-around). Vì vậy "58 ns/trang" không phải chi phí một fault — nó là chi phí một fault chia đều cho hàng trăm trang mà fault đó mang vào.
Để lấy con số thật, tôi tắt THP (madvise(MADV_NOHUGEPAGE)) buộc fault từng trang 4 KB: giờ rusage báo đúng 30.002 fault cho 30.000 trang (1 fault/trang), và chi phí thật hiện ra: ~368 ns mỗi fault. Con số 58 ns trước đó bị THP làm rẻ đi ~6 lần nhờ gom trang.
Bài học đo lường: muốn đo chi phí một sự kiện, phải đếm số sự kiện thật sự xảy ra — đừng giả định nó bằng số thao tác bạn gọi. Tôi tưởng "chạm 30.000 trang = 30.000 fault", nhưng nhân gom chúng lại, và chỉ bộ đếm rusage mới lộ ra sự thật. Nếu tin con số 58 ns, tôi đã báo cáo sai chi phí một fault gần 6 lần — và bỏ lỡ cả một cơ chế quan trọng (THP). Đây cùng tinh thần với đo tác động đúng cách: con số bạn chia ra chỉ đúng khi mẫu số đúng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: malloc/mmap xong chưa có nghĩa bộ nhớ sẵn sàng — lần chạm đầu mới trả tiền. Với một chương trình cấp phát lớn rồi dùng dần, chi phí ~368 ns mỗi minor fault rải ra khắp lần chạm đầu tiên, thường là phần "chậm không rõ vì sao" của lần chạy đầu. Nếu cần độ trễ ổn định (real-time, benchmark), pre-fault bằng MAP_POPULATE hay chạm trước, để trả cái giá đó trước đường nóng.
Hệ quả thứ hai: phân biệt minor và major fault khi gỡ hiệu năng. Một chương trình chậm với nhiều minor fault chỉ tốn CPU (~µs mỗi cái) — thường chấp nhận được. Nhưng nhiều major fault nghĩa là đang đọc đĩa/swap liên tục (chục-trăm µs mỗi cái) — dấu hiệu thiếu RAM hay working set không vừa bộ nhớ, và nó giết hiệu năng bậc lớn. ru_majflt trong getrusage (hay cột maj_flt trong /proc) cho bạn biết mình đang ở loại nào.
Hệ quả thứ ba là tinh thần đo lường: đếm sự kiện thật, đừng giả định. Con số mang theo: bộ nhớ là lazy — mmap/malloc chỉ đặt chỗ, trang vật lý cấp ở lần chạm đầu qua page fault; một MINOR fault (không đĩa) tốn ~368 ns (đo với THP tắt, 1 fault/trang, gấp ~60× một lần chạm thường 6 ns), còn MAJOR fault (đọc đĩa/swap) đắt hơn bậc lớn (chục-trăm µs); và đo hớ quan trọng: chạm 30000 trang chia đều ra 58 ns/trang KHÔNG phải chi phí một fault, vì rusage lộ ra chỉ 364 fault thật — Transparent Huge Pages gom 512 trang vào một fault — nên phải đếm số fault thật (rusage), không giả định một-fault-mỗi-trang. Chia đúng thì phải có mẫu số đúng.
Thử ba mươi giây
Chạy /usr/bin/time -v ./chương-trình và tìm hai dòng: "Minor (reclaiming a frame) page faults" và "Major (requiring I/O) page faults". Con số minor cho bạn biết chương trình chạm bao nhiêu trang mới (mỗi cái ~vài trăm ns) — thường lớn ở lần chạy đầu khi bộ nhớ còn lạnh. Con số major phải gần 0 trên một hệ thống đủ RAM; nếu nó lớn, bạn đang đọc đĩa/swap và cần thêm RAM hoặc giảm working set. Ba mươi giây đọc hai con số đó phân biệt được "chậm vì cấp bộ nhớ lần đầu" (minor, chấp nhận được) với "chậm vì thiếu RAM" (major, phải sửa) — hai vấn đề trông giống nhau từ ngoài nhưng cách chữa khác hẳn.