Hình dung bạn đo tốc độ đọc của hai người bằng "số chữ mỗi phút". Người thứ nhất đọc từng chữ của cả cuốn sách. Người thứ hai lật nhanh, mỗi trang liếc đúng một chữ — nhưng bạn vẫn lấy tổng số chữ của cả cuốn chia cho thời gian anh ta lật. Người thứ hai sẽ ra "nhanh gấp bốn nghìn lần" — dù anh ta có đọc gì đâu. Nghe thật lố, nhưng tôi vừa mắc đúng cái lỗi đó khi đo mmap so với read, và suýt viết ra một kết luận sai đầy tự tin.
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.
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.
Cái bẫy đo lường tôi suýt sa và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. Đúng cái người-lật-sách-một-chữ-mỗi-trang. 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 — nên phép đo thổi phồng kẻ "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.)
Rồi câu chuyện đảo chiều với truy cập ngẫu nhiên — và đây mới 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. 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.
(Môi trường đo: container gcc:13 trên host ARM AArch64, file 128 MB trong page cache, mỗi số lấy sau warmup. Con số tuyệt đối đổi theo máy; cái bền vững là hình dạng: tuần tự mmap hơn khiêm tốn, ngẫu nhiên mmap thắng lớn — và con số "5×" ban đầu là artifact đo ẩu.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin phổ biến: "mmap luôn nhanh hơn read nhiều lần vì tránh copy". Phép đo ẩu ban đầu xác nhận nó (5×) — rồi hóa ra đó là ảo giác vì hai bên làm khác lượng việc. Đo công bằng cho tuần tự chỉ 1,7×: mmap không phải viên đạn bạc "luôn nhanh gấp mấy lần".
Nhưng đừng lật sang niềm tin ngược: "vậy mmap chỉ hơn tí (1,7×), phức tạp thêm làm gì, cứ read cho lành". Cũng sai — nhìn cột ngẫu nhiên: mmap thắng 12,8 lần, vì nó loại bỏ một syscall cho mỗi truy cập. Sự thật nằm ở mẫu truy cập, không ở một con số duy nhất: đọc tuần tự một lần thì mmap hơn khiêm tốn (thường không đáng đổi lấy phức tạp), còn truy cập ngẫu nhiên nhiều lần thì mmap thắng áp đảo. Và cái khiến tôi suýt sai không phải mmap hay read, mà là phép đo: 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 còn tai hại hơn không đo, vì con số trông thuyết phục. Đây cùng tinh thần đo tác động đúng cách: con số chỉ có nghĩa khi hai bên so cùng điều kiện.
Về đến code hằng ngày
Điều đầ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.
Điều 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.
Chỗ này cả hai mặt — mmap thắng lớn ở truy cập ngẫu nhiên và cái giá SIGBUS/page-fault của nó — hiện rõ ở tầng database, nơi cuộc tranh luận vẫn nóng. LMDB được xây hoàn toàn trên mmap và nhanh khủng khiếp cho đọc ngẫu nhiên (chỉ mục B+tree đọc thẳng từ trang ánh xạ, đúng cái 12,8× ở trên); SQLite có chế độ mmap, Lucene dùng MMapDirectory cho chỉ mục tìm kiếm. Java phơi nó qua FileChannel.map()/MappedByteBuffer. Nhưng MongoDB engine cũ (MMAPv1) đã bị bỏ một phần vì các vấn đề mmap ở quy mô lớn, và có hẳn một bài báo nổi tiếng "Are You Sure You Want to Use MMAP in Your Database?" mổ xẻ đúng những cái giá tôi nêu (page fault khó kiểm soát, SIGBUS, khó quản lý cache). Mẫu số chung: mmap là công cụ tuyệt cho đúng mẫu truy cập ngẫu nhiên, nhưng không miễn phí — chọn bằng đo, không bằng niềm tin.
Muốn tự thấy — và đây là bài học lớn nhất — thì 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. Và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.