mmap cho phép truy cập một tệp như thể nó là một mảng trong bộ nhớ. Bài này đo xem việc đó đáng giá bao nhiêu, và đo cả những chỗ tôi không kết luận được.
Nhiều lần đọc nhỏ: 53 lần
Một triệu lần đọc 64 byte ở vị trí ngẫu nhiên trong tệp 512 MB đã nằm sẵn trong bộ đệm trang:
| Cách | Lần 1 | Lần 2 | Lần 3 |
|---|---|---|---|
pread() |
1.019 ns | 1.050 ns | 914 ns |
mmap + đọc bộ nhớ |
19 ns | 20 ns | 16 ns |
53 lần.
Điều quan trọng: không có lần đọc đĩa nào ở cả hai bên. Dữ liệu đã ở trong RAM. Toàn bộ khác biệt đến từ việc pread là một lời gọi hệ thống.
Khoảng 900 ns đó là giá của việc chuyển từ chế độ người dùng sang chế độ nhân và quay lại: lưu ngữ cảnh, kiểm tra tham số, tra bảng mô tả tệp, chép dữ liệu, quay ra. Với các biện pháp giảm nhẹ Spectre và Meltdown còn bật, cái giá này cao hơn so với trước năm 2018.
Với mmap, sau lần chạm đầu tiên, đọc một byte trong tệp là đúng một lệnh máy — không có nhân nào tham gia.
Đây là lý do LMDB, LevelDB, Lucene, và cả ld.so khi nạp thư viện đều dùng mmap.
Đọc tuần tự: 2,6 lần
Đọc hết 512 MB của cùng tệp nóng đó:
| Cách | Ba lần đo (MB/s) |
|---|---|
read, khối 4 KB |
7.004 / 7.513 / 7.565 |
read, khối 64 KB |
8.084 / 8.454 / 8.778 |
read, khối 1 MB |
8.816 / 9.214 / 9.049 |
mmap |
20.788 / 20.999 / 23.991 |
Tăng kích thước khối từ 4 KB lên 1 MB — tức là giảm số lời gọi hệ thống đi 256 lần — chỉ cải thiện 1,2 lần. Đổi sang mmap cải thiện 2,6 lần.
Phần còn lại của khoảng cách là việc chép dữ liệu. read chép từ bộ đệm trang sang bộ đệm của bạn; mmap không chép gì, nó chỉ ánh xạ chính những trang đó vào không gian địa chỉ của tiến trình.
MAP_POPULATE cho 19.318 / 26.685 / 25.215 MB/s — nằm trong dải của mmap thường. Với tệp đã nóng thì không có gì để nạp trước, nên kết quả này đúng như mong đợi.
Hai chỗ tôi không kết luận được
Tệp lạnh. Cùng phép đo read, xoá bộ đệm trước mỗi lần:
995 MB/s 1.969 MB/s 3.081 MB/s
Ba lần chênh nhau ba lần. Như phần 12 đã chỉ ra, drop_caches trong máy ảo không chạm tới bộ đệm của máy chủ bên dưới, nên "lạnh" không thật sự lạnh và mỗi lần lạnh một kiểu.
Với dải rộng như vậy, mọi so sánh mmap – read trên tệp lạnh đều vô nghĩa. Tôi không đưa ra con số nào.
Ghi. So msync với write + fsync, 256 MB:
write 704 741 1.365 MB/s
mmap 696 1.145 1.133 MB/s
Hai dải trùm lên nhau. Không kết luận được.
Tôi ghi lại hai chỗ này thay vì bỏ đi, vì "không đo được" là một kết quả — và nó ngăn người đọc trích một con số vốn không có ý nghĩa.
mmap không phải lựa chọn mặc định
Ba cái giá thật, không hiện ra trong bảng nào ở trên:
Lỗi trang thay cho lời gọi hệ thống. Lần chạm đầu tiên vào mỗi trang tốn khoảng 990 ns (phần 10). Với tệp chỉ đọc một lần, mmap chỉ đổi chi phí read thành chi phí lỗi trang chứ không xoá nó.
Không có cách xử lý lỗi đọc. read trả về -1 và errno. mmap thì lỗi I/O đến dưới dạng SIGBUS — một tín hiệu, giữa một lệnh truy cập bộ nhớ trông vô hại. Muốn xử lý phải bắt tín hiệu, và làm đúng chuyện đó rất khó. Đây là lý do các bên phát triển SQLite và PostgreSQL đưa ra để không dùng mmap cho tệp dữ liệu chính.
Tệp lớn hơn không gian địa chỉ, hoặc bị co lại. Trên hệ 64 bit thì kích thước không còn là vấn đề, nhưng nếu tệp bị cắt ngắn trong khi đang ánh xạ, chạm vào phần đã mất cũng cho SIGBUS.
Thêm một điểm về đo đạc: RSS của tiến trình dùng mmap bao gồm cả trang bộ đệm của tệp, nên nó trông như đang ăn rất nhiều RAM. Phần 11 đã chỉ ra tại sao con số đó không đọc được — và ở đây phải dùng PSS.
Khi nào dùng cái nào
| Tình huống | Nên dùng |
|---|---|
| Nhiều lần truy cập nhỏ, ngẫu nhiên, tệp nóng | mmap |
| Cấu trúc dữ liệu tra cứu trên đĩa (chỉ mục, cây) | mmap |
| Quét một lần từ đầu tới cuối | read khối 64 KB – 1 MB |
| Cần xử lý lỗi I/O tử tế | read |
| Ghi có đảm bảo bền vững | write + fsync |
| Tệp có thể bị bên khác cắt ngắn | read |
Ba gợi ý cho nhân khi đã chọn mmap:
madvise(a, n, MADV_SEQUENTIAL); /* doc truoc manh, bo trang da qua */
madvise(a, n, MADV_RANDOM); /* tat doc truoc */
madvise(a, n, MADV_WILLNEED); /* keo truoc vao bo dem */
Với truy cập ngẫu nhiên, MADV_RANDOM tránh việc nhân kéo về 128 KB cho mỗi lần chạm — đúng cái giá mà phần 20 đã đo ở read_ahead_kb.
Thử ba mươi giây
Đo giá của một lời gọi hệ thống trên máy bạn:
cat > /tmp/sc.c <<'EOF'
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#include <sys/mman.h>
#include <sys/stat.h>
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
return t.tv_sec+t.tv_nsec/1e9;}
int main(int c,char**v){
int fd=open(v[1],O_RDONLY); struct stat st; fstat(fd,&st);
char b[64]; volatile long s=0; int N=500000;
double t=now();
for(int i=0;i<N;i++){ if(pread(fd,b,64,(i*4096)%st.st_size)<0) return 1; s+=b[0]; }
printf("pread : %6.0f ns\n",(now()-t)/N*1e9);
char*a=mmap(0,st.st_size,PROT_READ,MAP_PRIVATE,fd,0);
for(off_t i=0;i<st.st_size;i+=4096) s+=a[i];
t=now();
for(int i=0;i<N;i++) s+=a[(i*4096)%st.st_size];
printf("mmap : %6.0f ns (s=%ld)\n",(now()-t)/N*1e9,s);
return 0;
}
EOF
cc -O2 -o /tmp/sc /tmp/sc.c
dd if=/dev/zero of=/tmp/f.bin bs=1M count=256 2>/dev/null
cat /tmp/f.bin > /dev/null # lam nong bo dem trang
/tmp/sc /tmp/f.bin
rm -f /tmp/f.bin
Dòng đầu là giá của một lời gọi hệ thống trên chính máy bạn. Nếu ứng dụng của bạn gọi read hàng triệu lần mỗi giây cho những mẩu dữ liệu nhỏ, nhân con số đó lên là biết bạn đang trả bao nhiêu cho việc đi vào nhân.
Phần sau: theo dõi I/O trong thực tế — iostat, iotop và cách đọc chúng cho đúng.