Có hai cách đọc nội dung một tệp: read() — gọi syscall để nhân chép dữ liệu vào một vùng đệm bạn cấp; hoặc mmap() — ánh xạ tệp thẳng vào không gian địa chỉ rồi truy cập như bộ nhớ thường. Người ta hay nói "mmap nhanh hơn vì zero-copy". Câu đó đúng hay chỉ là huyền thoại? Bài này đo cả hai — và trong lúc đo, chính vòng lặp kiểm chứng của tôi suýt che mất câu trả lời.
Hai cách đọc, hai chi phí
read(fd, buf, n) là một lời gọi hệ thống: mỗi lần gọi, nhân sao chép n byte từ page cache vào vùng đệm buf của bạn. Chi phí gồm hai phần: syscall (mỗi lần gọi là một lần bẫy vào nhân, như bài chi phí syscall đã đo) — nên đệm càng nhỏ, càng nhiều lần gọi, càng tốn; và một bản memcpy từ cache sang buffer của bạn.
mmap() ánh xạ các trang của tệp vào không gian địa chỉ của tiến trình. Sau đó bạn đọc dữ liệu trực tiếp từ các trang page cache đã được ánh xạ vào — không có bản sao vào một buffer riêng, và không có syscall cho mỗi khối. Đổi lại, lần đầu chạm mỗi trang sinh một lỗi trang để nhân gắn trang cache vào bảng trang của bạn.
Trên lý thuyết, mmap tránh được cái memcpy — "zero-copy". Câu hỏi là: điều đó thực sự nhanh hơn bao nhiêu, và trong tình huống nào?
Đo: bằng nhau khi chạm mọi byte, mmap thắng khi chạm thưa
Tôi đọc một tệp 500 MB đã nằm sẵn trong page cache (warm), theo nhiều cách, rồi cộng byte lại để xác nhận đã đọc thật. Ban đầu tôi cộng mọi byte:
read (đệm 1MB) : 3705 MB/s (141,5 ms)
mmap : 3728 MB/s (140,6 ms)
Gần như y hệt nhau. Nhìn con số này, tôi suýt kết luận "mmap và read chẳng khác gì, zero-copy chỉ là chuyện thần thoại". Nhưng có gì đó không ổn, nên tôi thử một cách tiêu thụ dữ liệu rẻ hơn: chỉ cộng một byte mỗi trang 4 KB thay vì mọi byte. Và cơ chế lộ ra ngay:
| Cách (chạm 1 byte/trang) | MB/s | Thời gian |
|---|---|---|
| read (đệm 1MB) | 39.859 | 13,2 ms |
| mmap | 189.667 | 2,8 ms |
| read (đệm 4KB) | 15.268 | 34,3 ms |
Bây giờ mmap nhanh gần 5 lần read (2,8 so với 13,2 ms). Vì sao? Vì read() bắt buộc chép cả 500 MB từ cache vào buffer của tôi — bản memcpy đó xảy ra bất kể tôi chỉ đụng một byte mỗi trang hay mọi byte; đó là cái copy ẩn của read. Còn mmap chỉ ánh xạ các trang cache vào không gian địa chỉ và tôi chạm thưa — không có bản sao dữ liệu nào. Và dòng cuối cho thấy chi phí syscall của đệm nhỏ: đọc với đệm 4 KB (128 nghìn lần gọi) chậm hơn đệm 1 MB gấp hơn hai lần.
Một lần tôi đo hớ: chính vòng đo của tôi là nút thắt
Sai lầm của tôi rõ khi nhìn lại hai phép đo. Ở phép đầu (cộng mọi byte), cả bốn cách đọc ra ~3.700 MB/s như nhau — không phải vì chúng thật sự bằng nhau, mà vì chính vòng cộng byte của tôi là nút thắt. Cộng 500 triệu byte một cách tuần tự chỉ đạt ~3.700 MB/s; cả read lẫn mmap đều cấp dữ liệu cho tôi nhanh hơn tốc độ tôi cộng kịp, nên bất kể cơ chế nào, tôi cũng bị nghẽn ở khâu cộng. Khác biệt cơ chế bị vòng đo của tôi che kín.
Chỉ khi tôi làm khâu tiêu thụ rẻ đi (một byte mỗi trang), cái nghẽn giả tạo biến mất, và cơ chế thật lộ ra: mmap 190 GB/s so với read 40 GB/s. Zero-copy là thật — nhưng nó chỉ hiện ra khi việc bạn làm với dữ liệu rẻ hơn cái bản sao mà read phải trả. Nếu bạn dù sao cũng phải xử lý nặng từng byte (nghẽn ở khâu xử lý), thì tránh được một bản memcpy chẳng giúp gì.
Bài học đo lường, lặp lại xuyên suốt: khi so sánh hai cơ chế, hãy chắc rằng chính công cụ đo của bạn không phải là nút thắt che mất khác biệt. Vòng cộng byte của tôi đóng vai một "biến ẩn" — nó chậm hơn cả hai cơ chế, nên làm chúng trông bằng nhau. Đây cũng là lý do các benchmark "đọc file" hay gây tranh cãi: kết quả phụ thuộc hoàn toàn vào việc bạn làm gì với dữ liệu sau khi đọc, chứ không chỉ vào cách đọc.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là chọn mmap khi truy cập thưa hoặc ngẫu nhiên, chọn read khi đọc tuần tự và xử lý hết. Nếu bạn chỉ đọc một phần của một tệp lớn — nhảy tới vài offset, tìm kiếm nhị phân trong một chỉ mục, ánh xạ một cơ sở dữ liệu và chỉ chạm các trang cần — mmap thắng đậm vì nó chỉ nạp đúng trang bạn đụng, không chép cả tệp. Nhưng nếu bạn đọc tuần tự từ đầu tới cuối và xử lý mọi byte (giải nén, phân tích), thì read với đệm lớn (64 KB–1 MB) đơn giản hơn và nhanh ngang mmap — cái copy của read bị lấn át bởi công việc xử lý.
Hệ quả thứ hai là đừng dùng đệm read quá nhỏ. Con số 4 KB so với 1 MB (34 so với 13 ms) cho thấy đệm nhỏ trả thuế syscall nặng: đọc từng 4 KB nghĩa là hàng trăm nghìn lần bẫy vào nhân. Một đệm 64 KB trở lên gần như xóa sạch chi phí đó. Đây là lỗi hiệu năng phổ biến và dễ sửa: chỉ cần tăng kích thước đệm.
Hệ quả thứ ba là mmap không phải luôn thắng, và có cái giá riêng. mmap khiến lỗi đọc tệp biến thành tín hiệu SIGBUS khó xử lý (nếu tệp bị cắt ngắn dưới chân bạn), không dùng được cho pipe hay socket, và tiêu tốn không gian địa chỉ ảo cùng các mục ánh xạ. Với tệp nhỏ, chi phí dựng ánh xạ có khi còn lớn hơn lợi ích. Con số mang theo: đọc file chạm mọi byte thì mmap ≈ read (~3700 MB/s, nghẽn ở khâu xử lý); chạm thưa thì mmap nhanh gấp ~5 lần read (190 so 40 GB/s) vì read bắt buộc chép cả file vào buffer còn mmap thì không; và đệm read nhỏ (4KB) chậm hơn đệm lớn vì thuế syscall. Zero-copy của mmap là thật nhưng chỉ đáng khi bạn không chạm hết dữ liệu — chọn công cụ theo cách bạn dùng dữ liệu, không theo khẩu hiệu.
Thử ba mươi giây
Nếu bạn có một chương trình đọc tệp lớn, thử đo nó với hai kích thước đệm: strace -c -e read your-program sẽ đếm số lần gọi read và tổng thời gian — nếu bạn thấy hàng trăm nghìn lời gọi read, chương trình đang dùng đệm quá nhỏ và trả thuế syscall. Tăng đệm lên 64 KB hay 1 MB và đo lại; số lời gọi tụt xuống và thường nhanh hơn hẳn. Còn muốn thấy sức mạnh của mmap, nghĩ về mẫu truy cập của bạn: nếu bạn nhảy quanh trong một tệp lớn thay vì đọc tuần tự, mmap để nhân chỉ nạp đúng trang bạn chạm — đúng cái lợi 5 lần mà bài này đo. Cách bạn chạm dữ liệu quyết định cơ chế nào thắng.