Có hai cách đọc nội dung một file: read() — cách quen thuộc — và mmap() — ánh xạ file thẳng vào không gian địa chỉ. Cùng phục vụ từ page cache, nhưng khác nhau về cơ chế, và câu hỏi kinh điển là "cái nào nhanh hơn". Trực giác phổ biến: "mmap nhanh hơn nhiều vì tránh được một lần copy". Tôi đo trong container gcc:13 (ARM) — và vấp đúng một cái bẫy đo lường trước khi ra được câu trả lời thật, một lời nhắc rằng benchmark không công bằng còn nguy hiểm hơn không benchmark.
Hai cơ chế, hai cái giá
read() bảo nhân copy dữ liệu từ page cache sang buffer của bạn, mỗi lần gọi. Có một lần chép "thừa" (page cache đã có dữ liệu, giờ chép thêm sang buffer user), nhưng đổi lại nhân biết bạn đọc tuần tự nên readahead — nạp trước các trang sắp tới.
mmap() ánh xạ file vào không gian địa chỉ của bạn. Không có copy explicit: khi bạn chạm một trang, một page fault nạp trang đó vào (nếu chưa có), rồi bạn đọc thẳng từ trang cache đã ánh xạ. Cái giá là một page fault (minor) cho mỗi trang lần đầu chạm. mmap còn tiện cho truy cập ngẫu nhiên (không cần seek+read, chỉ là một phép đọc bộ nhớ) và chia sẻ file giữa nhiều tiến trình.
Một lần tôi đo hớ: benchmark không công bằng
Đây là chỗ tôi vấp trước khi có câu trả lời — và nó nằm ở chính cách đo. Lần đầu, tôi đo đọc tuần tự một file 128 MB: read() ra 38 GB/s, còn mmap ra 200 GB/s — mmap nhanh hơn 5 lần! Tôi suýt viết "mmap thắng áp đảo".
May là tôi nhìn lại code. read() của tôi chép toàn bộ 128 MB vào buffer rồi xử lý. Nhưng vòng mmap của tôi chỉ chạm một byte mỗi trang 4 KB — tức nó chỉ thật sự đọc 1/4096 lượng dữ liệu! Con số "200 GB/s" là giả: tôi chia 128 MB cho thời gian, nhưng chỉ thực sự đọc ~32 KB. Hai bên không làm cùng một việc — một bên chép hết dữ liệu, một bên chạm lướt qua. Benchmark của tôi đã thổi phồng bên "thắng".
Tôi sửa cho công bằng: cả hai chạm một byte mỗi cache line 64 byte trên toàn bộ file (tức cả hai thật sự stream hết 128 MB qua CPU). Con số thật hiện ra:
TUẦN TỰ (công bằng, cả hai chạm hết dữ liệu):
read() : 30,4 GB/s
mmap : 50,6 GB/s -> mmap ~1,7×
mmap + MADV_SEQUENTIAL: 50,4 GB/s (cache ấm, không giúp thêm)
mmap tuần tự nhanh hơn 1,7 lần, không phải 5 lần. Nó có nhanh hơn — vì né được một lần chép kernel→user (bạn đọc thẳng từ trang cache) — nhưng khiêm tốn hơn nhiều con số ảo ban đầu. (MADV_SEQUENTIAL không giúp thêm vì dữ liệu đã ấm trong cache, không cần readahead.)
Bài học đo lường: khi so hai phương pháp, phải bắt chúng làm chính xác cùng một việc — một benchmark không công bằng thổi phồng kẻ thắng và cho kết luận sai. Nếu tôi tin con số 5 lần, tôi đã khuyên "luôn dùng mmap để đọc tuần tự" dựa trên một artifact đo lường. Đây cùng tinh thần với đo tác động đúng cách: con số chỉ có nghĩa khi hai bên so sánh cùng điều kiện.
Đo: mmap thắng lớn ở truy cập ngẫu nhiên
Câu chuyện đảo chiều với truy cập ngẫu nhiên — và đây là chỗ mmap tỏa sáng thật:
NGẪU NHIÊN (ns mỗi truy cập 64 byte):
pread() : 294 ns
mmap : 23 ns -> mmap 12,8×
Truy cập ngẫu nhiên một vị trí trong file: pread() tốn 294 ns vì mỗi truy cập là một system call (chuyển vào nhân, copy 64 byte, quay ra). mmap chỉ 23 ns — vì sau khi trang đã được ánh xạ và cache ấm, một truy cập chỉ là một phép đọc bộ nhớ thường, không syscall. mmap nhanh hơn 12,8 lần. Đây là lý do thật để chọn mmap: khi bạn nhảy quanh file ngẫu nhiên (chỉ mục cơ sở dữ liệu, cấu trúc dữ liệu trên đĩa), mmap biến mỗi truy cập từ một syscall thành một phép đọc bộ nhớ. Chênh lệch 271 ns mỗi truy cập đó chính là chi phí vượt ranh giới user↔kernel mà phần đầu sê-ri đã đo — nhân với hàng triệu truy cập ngẫu nhiên, nó là khác biệt giữa một chỉ mục dùng được và một chỉ mục ì ạch.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chọn mmap hay read theo mẫu truy cập, không theo huyền thoại "mmap luôn nhanh". Đọc tuần tự một lần: read() đơn giản, readahead tốt, và chỉ chậm hơn mmap khiêm tốn (~1,7×) — thường không đáng đổi lấy sự phức tạp của mmap. Truy cập ngẫu nhiên nhiều lần, hoặc cần chia sẻ file giữa tiến trình: mmap thắng lớn (12,8×) vì loại bỏ syscall mỗi truy cập. Đo mẫu của bạn, đừng theo quy tắc chung.
Hệ quả thứ hai: mmap có cái giá riêng — page fault và độ trễ đột biến. Mỗi trang lần đầu chạm là một minor fault (~vài trăm ns, phần 6); với file lớn truy cập rải rác, các fault này cộng lại. mmap cũng khó xử lý lỗi I/O hơn (một lỗi đọc biến thành SIGBUS giữa chương trình thay vì một mã lỗi từ read). Không phải lúc nào cũng đáng.
Hệ quả thứ ba là tinh thần đo lường: benchmark phải công bằng — bắt hai bên làm cùng việc, nếu không con số nói dối. Con số mang theo: read() copy dữ liệu kernel→user mỗi lần (readahead tốt cho tuần tự), mmap ánh xạ file rồi đọc thẳng từ trang cache (không chép explicit, nhưng trả page fault mỗi trang); đo CÔNG BẰNG (cả hai chạm hết dữ liệu) cho tuần tự mmap ~1,7× read (50,6 vs 30,4 GB/s) — KHÔNG phải 5× như phép đo ẩu ban đầu (200 GB/s là artifact do mmap chỉ chạm 1 byte/trang); nhưng ở truy cập NGẪU NHIÊN mmap thắng 12,8× (23 vs 294 ns) vì không syscall mỗi lần. Chọn theo mẫu truy cập, và luôn đo cho hai bên làm cùng việc.
Thử ba mươi giây
Lần tới khi bạn thấy một benchmark so hai cách làm (mmap vs read, thư viện A vs B, thuật toán X vs Y) với kết luận "cái này nhanh gấp N lần", hỏi một câu: hai bên có làm chính xác cùng một lượng việc không? Rất thường, "kẻ thắng" nhanh hơn chỉ vì nó làm ít hơn — đọc ít dữ liệu, bỏ qua một bước, đo trên input khác. Trong ca của tôi, mmap "nhanh 5 lần" chỉ vì nó chạm 1/4096 dữ liệu so với read. Ba mươi giây kiểm "hai bên có công bằng không" đó bắt được một lớp kết luận sai mà con số trông rất thuyết phục — vì với đo lường, một phép so lệch còn dẫn bạn đi lạc xa hơn cả không đo.