Phần 9 đo chi phí trượt bộ nhớ đệm. Bài này lùi ra một tầng: chi phí để một trang nhớ có mặt lần đầu tiên.
malloc không cấp bộ nhớ
mmap 512 MB rồi đo ngay:
mmap 512 MB: 0.0000 s, 2 lỗi trang
Không tới 0,1 ms và không có trang nào được cấp. Nhân chỉ ghi vào bảng "vùng này hợp lệ" rồi trả về. Bộ nhớ thật chỉ xuất hiện khi có ai chạm vào.
Chạm lần lượt vào 131.072 trang:
chạm hết : 0.1297 s, 131081 lỗi trang, 990 ns mỗi trang
chạm lại : 0.0014 s, 0 lỗi trang, 11 ns mỗi trang
990 ns so với 11 ns — 90 lần. Đó là giá của một lỗi trang nhẹ: nhân phải tìm một trang vật lý trống, xoá sạch nó, rồi cập nhật bảng trang.
Nó giải thích một hiện tượng quen thuộc: tiến trình vừa khởi động luôn chậm hơn tiến trình đã chạy được vài phút, dù mã nguồn giống hệt. Lần chạm đầu tiên vào mỗi trang đắt gấp 90 lần.
Nạp sẵn hoá ra nhanh hơn nạp lười
Đây là kết quả tôi không đoán trước. Chạy lại cùng chương trình với cờ MAP_POPULATE — bảo nhân cấp hết mọi trang ngay lúc mmap:
| Nạp lười (mặc định) | MAP_POPULATE |
|
|---|---|---|
mmap |
0,0000 s | 0,0501 s |
| chạm hết | 0,1297 s | 0,0014 s |
| tổng | 0,1297 s | 0,0515 s |
Nạp sẵn nhanh hơn 2,5 lần dù làm đúng lượng việc như nhau.
Lý do là chi phí vào nhân rồi ra. Nạp lười gây 131.081 lần bẫy vào nhân, mỗi lần lưu ngữ cảnh, dò bảng trang, quay ra. MAP_POPULATE làm cả 131.072 trang trong một lời gọi hệ thống, không có bẫy nào.
Nói lại lần thứ hai vì nó ngược với trực giác thông thường: lười biếng không phải lúc nào cũng rẻ hơn. Lười biếng rẻ khi bạn không dùng hết. Nếu chắc chắn sẽ chạm vào toàn bộ vùng nhớ, nạp sẵn là lựa chọn đúng.
Ba nơi dùng được:
mmap(0, n, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE, -1, 0);
# JVM: cham het heap luc khoi dong
java -XX:+AlwaysPreTouch -Xms4g -Xmx4g ...
madvise(a, n, MADV_WILLNEED); /* goi y mem hon, khong dam bao */
Đổi lại là thời gian khởi động dài hơn và bộ nhớ bị giữ ngay từ đầu. Đó là đánh đổi có ý thức, không phải tối ưu miễn phí.
Lỗi trang nặng
Lỗi trang nhẹ là trang đã có sẵn trong RAM ở đâu đó. Lỗi trang nặng là phải đọc từ ổ đĩa.
Tạo một tệp 512 MB, xoá sạch bộ đệm trang, rồi mmap và đọc:
sync; echo 3 > /proc/sys/vm/drop_caches
| Lần | Cache lạnh | Cache nóng |
|---|---|---|
| 1 | 0,1497 s | |
| 2 | 0,1426 s | |
| 3 | 0,1420 s | |
| 0,0140 s |
Gấp 10 lần giữa đọc từ đĩa và đọc từ bộ đệm trang, trên NVMe. Trên đĩa quay, tỷ lệ này là hàng trăm lần.
Rồi bộ đếm lỗi trang nặng nói dối
Đây là phần đáng nhớ nhất của phép đo. getrusage báo:
0.1497 s | lỗi NẶNG 1 | lỗi NHẸ 8208 | 131072 trang thực sự chạm
Đọc 512 MB từ ổ đĩa lạnh mà ru_majflt chỉ đếm một.
Hai cơ chế làm lệch con số:
Đọc trước. Nhân thấy kiểu truy cập tuần tự nên kéo sẵn cả vùng tệp vào bộ đệm trang. Khi chương trình chạm tới, dữ liệu đã nằm trong RAM — nhân ghi nhận đó là lỗi nhẹ. Việc đọc đĩa vẫn xảy ra, chỉ là nó không mang tên "lỗi trang nặng".
Lỗi vòng quanh. Một lần lỗi trang, nhân ánh xạ luôn 16 trang lân cận. 131.072 chia 16 đúng bằng 8.192, khớp con số 8.208 đo được (phần dư là mã và ngăn xếp của chương trình).
Hệ quả thực dụng: đừng dùng ru_majflt hay cột majflt của ps để kết luận hệ thống có đọc đĩa hay không. Nó gần như luôn bằng 0 kể cả khi ổ đĩa đang bận. Nhìn thời gian, nhìn iowait (xem phần 3), nhìn iostat.
Chỗ ru_majflt thực sự có ý nghĩa là khi nó lớn — lúc đó gần như chắc chắn là tráo đổi (swap) hoặc truy cập ngẫu nhiên vào tệp lớn hơn RAM, và đó là vấn đề thật.
Xem trang của một tiến trình
grep -E 'VmRSS|VmSize|VmSwap' /proc/<pid>/status
awk '{print "loi nhe:",$10," loi nang:",$12}' /proc/<pid>/stat
cat /proc/<pid>/smaps_rollup | grep -E 'Rss|Pss|Anonymous'
VmSize là vùng địa chỉ đã đăng ký; VmRSS là trang thật đang giữ. Chênh nhau rất nhiều là chuyện bình thường, không phải rò rỉ — đó chính là điều phần 2 đã đo với VIRT và RES.
Thử ba mươi giây
Đo giá của lần chạm đầu tiên trên máy bạn:
cat > /tmp/p.c <<'EOF'
#include <stdio.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <time.h>
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
return t.tv_sec+t.tv_nsec/1e9;}
static long mf(void){struct rusage r;getrusage(RUSAGE_SELF,&r);return r.ru_minflt;}
int main(void){
size_t n=512UL*1024*1024;
char*a=mmap(0,n,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);
long f=mf(); double t=now();
for(size_t i=0;i<n;i+=4096) a[i]=1;
printf("lan dau : %.4f s, %ld loi trang\n",now()-t,mf()-f);
f=mf(); t=now();
for(size_t i=0;i<n;i+=4096) a[i]=2;
printf("lan hai : %.4f s, %ld loi trang\n",now()-t,mf()-f);
return 0;
}
EOF
cc -O2 -o /tmp/p /tmp/p.c && /tmp/p
Hai dòng ra chênh nhau vài chục lần. Nếu ứng dụng của bạn cấp phát rồi giải phóng những vùng lớn trong vòng lặp, mỗi vòng đang trả lại đúng cái giá đó — và cách chữa thường là tái sử dụng vùng nhớ chứ không phải cấp lại.
Phần sau: bộ đệm trang — cách nhân dùng RAM rảnh, và vì sao free báo hết bộ nhớ mà máy vẫn chạy tốt.