Có hai cách đọc một file lớn: gọi read() để hệ điều hành chép dữ liệu vào một buffer, hoặc mmap() để ánh xạ thẳng file vào không gian địa chỉ rồi truy cập như bộ nhớ thường. Lời khuyên hay nghe là "dùng mmap đi, nó tránh được cú copy nên nhanh hơn". Tôi mở một file 256MB trong container, đo cả hai — và phép đo đầu tiên của tôi suýt biến thành một kết luận sai hoàn toàn.

Đọc file qua mmap

Hai cách đưa file vào chương trình

read() là một syscall: bạn đưa cho nhân một buffer, nhân chép dữ liệu từ page cache (bản sao file đang nằm trong RAM của kernel) sang buffer của bạn. Đọc cả file lớn thì gọi vài lần trong vòng lặp, mỗi lần một lô (ví dụ 1MB), tái dùng đúng một buffer nhỏ. Có một cú copy trong nhân — nhưng nó là memcpy tuần tự, nhanh, và buffer nhỏ đó luôn nóng trong cache CPU.

mmap() không chép gì cả. Nó lập một ánh xạ: từ giờ, vùng địa chỉ ảo [p, p+size) file. Bạn đọc p[i] như đọc một mảng. Lần đầu chạm vào mỗi trang chưa nạp, CPU sinh một lỗi trang (page fault), nhân bắt lấy, gắn trang page cache tương ứng vào bảng trang của bạn, rồi lệnh đọc chạy tiếp. Không syscall read, không copy sang buffer riêng — bạn dùng thẳng trang trong page cache. Nghe như phải nhanh hơn.

Tôi ghim tiến trình vào một lõi bằng taskset để loại bớt biến động, rồi đo tổng thời gian đọc-và-cộng-mọi-byte của một file 256MB theo cả hai cách.

Đo lần đầu: mmap "nhanh gấp 8 lần"

Bản đo đầu tiên của tôi ra một con số rất đẹp cho mmap:

mmap  : ~1,7 ms
read(): ~13  ms   (mmap nhanh gấp ~8 lần)

Tôi đã định lấy ngay con số này làm điểm chính của bài: "thấy chưa, mmap tránh cú copy nên nhanh gấp 8 lần". Nhưng có một chi tiết khiến tôi khựng lại: getrusage báo bản mmap chỉ sinh 2176 lỗi trang phụ (minor fault), trong khi file 256MB chia cho trang 4KB là 65536 trang. Nếu mmap thật sự đọc hết file, nó phải chạm đủ 65536 trang chứ. Con số 2176 tố cáo rằng bản mmap của tôi chưa hề đọc hết file.

Nhìn lại mã, tôi thấy lỗi: bản mmap chỉ chạm một byte mỗi trang (sum += p[i]; i += 4096) để "kích" cho các trang được nạp — tổng cộng nó chỉ đọc 65536 byte. Còn bản read() thì chép và cộng đủ cả 256MB. Hai bên làm hai việc khác hẳn nhau: một bên sờ vào 65536 byte, bên kia xử lý 268 triệu byte. "Nhanh gấp 8 lần" không so sánh hai cách đọc file — nó chỉ đo được rằng đọc 65536 byte thì nhanh hơn đọc 256MB. Đúng, nhưng vô nghĩa.

Một lần tôi đo hớ: cho hai bên làm cùng một việc

Tôi viết lại cho công bằng: cả hai đều cộng mọi byte của 256MB. Bản read() giữ nguyên. Bản mmap giờ duyệt tuần tự từng byte của vùng ánh xạ (for i in 0..size: sum += p[i]), không nhảy trang nữa. Vẫn ghim một lõi. Kết quả lật ngược:

Cách đọc + xử lý toàn bộ 256MB Thời gian Lỗi trang phụ
read() lô 1MB + cộng mọi byte ~87 ms vài chục
mmap + cộng mọi byte ~430 ms +2176
mmap chỉ chạm 1 byte/trang (bản cũ, bất công) 1,7 ms +2176

Khi cả hai cùng xử lý 256MB, read() nhanh gấp 5 lần mmap (87 so với 430 ms) — ngược hẳn với "nhanh gấp 8 lần" mà tôi suýt công bố. Lời khuyên "mmap tránh copy nên nhanh hơn" hóa ra là một huyền thoại, ít nhất cho việc đọc tuần tự toàn bộ file.

Vì sao read() thắng? Cú copy của read() rẻ hơn tưởng: nó là memcpy tuần tự trong nhân, phần cứng làm rất nhanh. Đổi lại, bản read() cộng byte trên một buffer nhỏ 1MB tái dùng — buffer đó vừa được chép vào nên nằm sẵn trong cache CPU, và 256 lần cộng đều đập vào đúng vùng nóng ấy. Còn mmap bắt bạn duyệt qua cả 256MB rải khắp không gian địa chỉ: mỗi trang mới là một lỗi trang (nhân phải dừng, cập nhật bảng trang), dữ liệu thì lạnh cache và bảng trang lớn gây áp lực lên TLB. Cái "tiết kiệm" được một cú copy bị nuốt sạch bởi phí lỗi trang, cache lạnh và TLB.

Bài học đo lường quan trọng nhất ở đây không phải về mmap, mà về benchmark: luôn chắc chắn hai bên đang so làm đúng cùng một việc. Bản mmap "1,7 ms" và bản read() "13 ms" trông như một phép so hợp lệ vì cùng mở một file, cùng in ra một tổng — nhưng một bên đọc 65536 byte, bên kia đọc 256MB. Con số "nhanh gấp 8 lần" không đo tốc độ đọc; nó đo sự chênh lệch về khối lượng công việc mà tôi vô tình tạo ra. Một phép so lệch khối lượng luôn cho ra một con số gọn gàng và sai.

Con số kỳ lạ: 2176 lỗi trang, không phải 65536

Ngay cả khi mmap duyệt đủ 256MB, getrusage vẫn chỉ báo 2176 lỗi trang phụ, không phải 65536. Đây không phải lỗi đo — nó là một cơ chế thật của nhân gọi là fault-around. Khi bạn gây một lỗi trang cho một file được ánh xạ, nhân đoán rằng bạn sắp cần cả các trang lân cận, nên nó gắn luôn một cụm trang kề bên (mặc định tới 16 trang, và với dữ liệu đã nằm sẵn trong page cache thì còn gộp mạnh hơn) chỉ trong một lần bắt lỗi. Nhờ vậy một lỗi trang phục vụ hàng chục trang: 65536 trang chia cho ~2176 lần bắt lỗi ra khoảng 30 trang mỗi lần.

Đây là lý do đọc tuần tự qua mmap không "chết" vì lỗi trang: nhân đã bó chúng lại. Nhưng "ít lần bắt lỗi hơn" không có nghĩa "rẻ hơn read()" — vì mỗi lần bắt lỗi vẫn tốn công chuyển vào nhân và cập nhật bảng trang cho cả cụm, và phần duyệt-256MB-lạnh-cache mới là chỗ read() bỏ xa mmap. Con số 2176 giải thích vì sao mmap không tệ hơn nữa, chứ không cứu được nó khỏi thua read() ở việc đọc tuần tự.

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

Hệ quả đầu tiên: đừng chọn mmap chỉ vì "nó tránh copy". Cho việc đọc tuần tự một lượt qua cả file — quét log, tính tổng, băm nội dung, nạp một lần rồi bỏ — read() (hoặc bạn của nó là pread/readahead) với một buffer vừa phải thường nhanh hơn, đúng như phép đo trên. Cú copy không phải kẻ thù; cache lạnh và lỗi trang mới là.

Hệ quả thứ hai: mmap thắng ở đúng những chỗ read() vụng. Khi bạn cần truy cập ngẫu nhiên một file lớn mà không muốn nạp hết vào RAM (một chỉ mục cơ sở dữ liệu, một file 50GB mà mỗi lần chỉ đọc vài vùng), mmap để bạn chạm đúng những trang cần, phần còn lại không bao giờ bị nạp — chính cái "đọc lười" mà bản đo hớ đầu tiên của tôi vô tình khai thác. Khi nhiều tiến trình cùng đọc một file (thư viện chia sẻ, file dữ liệu chung), mmap cho chúng dùng chung một bản trong page cache thay vì mỗi tiến trình một buffer riêng. Và khi bạn muốn coi file như một mảng để chuyền cho mã khác mà không quản lý buffer, mmap gọn hơn hẳn.

Hệ quả thứ ba, và là bài học rộng nhất: một con số benchmark chỉ có nghĩa khi hai bên làm cùng một việc. Trước khi tin "A nhanh gấp N lần B", hãy hỏi: A và B có xử lý cùng lượng dữ liệu không? Có cùng cache nóng/lạnh không? Cùng cách đo thời gian không? Trong ca này, một cái bẫy nhỏ (mmap chỉ chạm một byte mỗi trang) tạo ra chênh lệch khối lượng 4000 lần và sinh ra con số "nhanh 8 lần" trông rất thuyết phục. Con số mang theo: khi cùng đọc và cộng 256MB, read() nhanh gấp 5 lần mmap (87 so với 430 ms) — vì cú copy của read() rẻ còn buffer nhỏ của nó nóng trong cache, trong khi mmap phải duyệt cả vùng lạnh và trả phí lỗi trang; "mmap tránh copy nên nhanh hơn" chỉ đúng khi bạn đọc một phần file, không phải khi đọc hết.

Thử ba mươi giây

Lần tới khi bạn thấy một benchmark "X nhanh gấp N lần Y", đừng vội tin con số — hãy đọc kỹ vòng lặp đo của cả hai và tự hỏi chúng có làm đúng cùng một việc không. Đếm xem mỗi bên đọc/ghi bao nhiêu byte, chạm bao nhiêu phần tử, gọi bao nhiêu lần syscall. Nếu bạn có sẵn một chương trình, thêm một dòng getrusage(RUSAGE_SELF, &r) trước và sau, rồi in r.ru_minflt (số lỗi trang phụ): nó cho bạn biết bên mmap thật sự chạm bao nhiêu trang, và nếu con số đó nhỏ hơn nhiều so với kích thước dữ liệu chia cho 4KB, thì bên đó đang đọc ít hơn bạn tưởng — đúng cái bẫy đã suýt lừa tôi trong bài này.