Một trong những tối ưu quan trọng nhất của hệ điều hành lại vô hình với hầu hết lập trình viên: bộ đệm trang (page cache). Khi bạn đọc một file, kernel giữ các trang dữ liệu đó trong RAM; lần đọc sau được phục vụ thẳng từ RAM thay vì đọc lại từ đĩa. Đó là lý do "đọc file lần hai nhanh hơn hẳn lần đầu" — một câu ai cũng nghe. Bài này định đo nó trong container, và biến thành một bài học thẳng thắn về việc không đo được nó, cùng vì sao.

Bộ đệm trang

Page cache là gì

RAM của một máy hiếm khi được dùng hết cho chương trình. Phần còn trống, kernel dùng làm bộ đệm trang: một kho giữ lại các trang của những file vừa được đọc hoặc ghi. Lần đầu bạn đọc một file, kernel phải lấy dữ liệu từ đĩa (chậm) rồi đồng thời lưu một bản vào page cache. Những lần đọc sau, nếu trang còn trong cache, kernel trả thẳng từ RAM — nhanh gấp hàng trăm lần đọc đĩa, và không gây một lần I/O nào. Cache này là chung cho cả hệ thống và tự động; bạn không phải làm gì để hưởng lợi.

Ta thấy nó tồn tại qua /proc/meminfo, dòng Cached: trên máy tôi đo, con số này là 5,5 GB — tức 5,5 gigabyte RAM đang giữ dữ liệu file để phục vụ đọc nhanh.

Đo: bằng chứng cache hoạt động, nhưng không có "lần nguội"

Tôi đọc một file 256 MB qua mmap và đếm lỗi trang nặng (major fault — nghĩa là phải đọc từ đĩa) bằng getrusage. Trên hệ thống file của container (overlayfs):

đọc 256MB: ~7 ms (~36 GB/s), major fault +0

Bảy mili giây cho 256 MB là tốc độ RAM, không phải tốc độ đĩa, và không một lỗi trang nặng nào — bằng chứng rõ ràng dữ liệu được phục vụ hoàn toàn từ page cache, không hề chạm đĩa. Đến đây thì đẹp: cache đang làm việc.

Vấn đề nảy sinh khi tôi thử tạo một lần đọc "nguội" để so sánh. Tôi bỏ file khỏi cache bằng posix_fadvise(POSIX_FADV_DONTNEED)madvise(MADV_DONTNEED), rồi đọc lại, mong thấy đọc đĩa chậm và một loạt lỗi trang nặng. Kết quả: vẫn 0 lỗi trang nặng, và thời gian không chậm hơn. Lần "nguội" chẳng nguội chút nào.

Một lần tôi đo hớ: không có đĩa chậm thật bên dưới

Con số bất khả đó — bỏ cache rồi đọc lại mà không có lỗi trang nặng nào — là dấu hiệu công cụ đang nói dối, hoặc đúng hơn, môi trường đang giấu sự thật. Đây là container chạy trong một máy ảo trên máy Mac. Hệ thống file overlayfs của nó, xét cho cùng, được chống lưng bởi ổ SSD của máy chủ page cache của chính máy chủ. Khi tôi bảo kernel của khách bỏ cache, dữ liệu vẫn nằm sẵn trong tầng lưu trữ nhanh bên dưới — nên đọc lại vẫn tức thì, không có "đọc đĩa" thật sự nào để mà chậm. Không có đĩa chậm thật bên dưới, thì "nguội" không thể nguội.

Tôi thử một môi trường khác để chắc: đọc một file trên bind mount — thư mục được gắn từ máy chủ vào container, mà Docker Desktop hiện thực bằng một hệ thống file FUSE. Ở đây kết quả ngược hẳn: đọc 256 MB tốn 158 mili giây, và cả ba lần đọc liên tiếp đều ~158 ms — chẳng lần nào nhanh lên. Lý do: FUSE chuyển mỗi lần đọc qua một kênh tới máy chủ và bỏ qua page cache của khách, nên không có gì được cache lại, mọi lần đọc đều trả giá đầy đủ.

Vậy là hai môi trường, không cái nào cho tôi đường cong sách giáo khoa "lần đầu chậm, lần sau nhanh": overlayfs thì mọi lần đều nhanh (đã ở RAM, không đĩa chậm để mà nguội), FUSE thì mọi lần đều chậm (không dùng page cache khách). Bài học đo lường: đo I/O đĩa và page cache bên trong một container hay máy ảo là đo tầng ảo hóa, không phải đĩa thật. Tầng lưu trữ là một biến ẩn có thể giấu hiệu ứng (overlayfs, không có lần nguội) hoặc giả nó (FUSE, không có lần nóng). Page cache là thật và tôi có bằng chứng nó chạy (meminfo Cached 5,5 GB, đọc ở tốc độ RAM, 0 lỗi trang nặng); nhưng để thấy cú nhảy từ "nguội" (đĩa) sang "nóng" (cache), tôi cần một hệ thống file thật nằm trên một block device thật, đủ chậm — điều mà container trên Mac này không có.

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

Hệ quả đầu tiên: đừng benchmark I/O đĩa bên trong Docker rồi tin con số. Đây là một cái bẫy cực phổ biến: người ta đo tốc độ đọc/ghi file trong container và kết luận về hiệu năng đĩa, trong khi con số thật ra phản ánh overlayfs, FUSE bind mount, hay page cache của máy chủ — không phải block device. Muốn đo hiệu năng lưu trữ thật, phải đo trên một volume gắn thẳng vào một đĩa thật, và phải kiểm soát page cache (xả cache giữa các lần, hoặc dùng O_DIRECT, đo trên máy vật lý). Con số I/O trong container gần như luôn là con số của tầng ảo hóa.

Hệ quả thứ hai: page cache là bạn, nhưng nó cũng làm benchmark nói dối theo chiều ngược lại. Trên máy thật, một benchmark đọc file mà không xả cache giữa các lần sẽ đo tốc độ RAM (lần đọc thứ hai trở đi), không phải tốc độ đĩa — cho ấn tượng sai rằng lưu trữ nhanh khủng khiếp. Đây là lý do các công cụ đo đĩa nghiêm túc (như fio) có tùy chọn xả cache và đọc trực tiếp bỏ qua cache: để đo đúng cái mình muốn. Khi thấy một con số I/O đẹp bất thường, câu hỏi đầu tiên là "có phải mình đang đo page cache không?".

Hệ quả thứ ba là bài học đo lường mang theo: môi trường đo là một biến ẩn — công cụ có thể giấu hoặc giả một hiệu ứng có thật. Con số mang theo: page cache giữ dữ liệu file trong RAM để đọc lại tức thì (bằng chứng: /proc/meminfo Cached 5,5 GB, đọc 256MB ở ~36 GB/s với 0 lỗi trang nặng), nhưng trong container/VM tôi không tạo được lần đọc "nguội" — overlayfs không có đĩa chậm thật bên dưới nên bỏ cache vẫn nhanh, còn bind mount FUSE bỏ qua page cache khách nên luôn chậm; đo I/O trong container là đo tầng ảo hóa, không phải đĩa. Một hiệu ứng "không xuất hiện" chưa chắc là không tồn tại — có thể chỉ là môi trường đo đang che nó đi.

Thử ba mươi giây

Trên một máy Linux thật (không phải container/VM), tạo một file lớn, rồi chạy echo 3 | sudo tee /proc/sys/vm/drop_caches để xả sạch page cache, và đo time cat file-lon > /dev/null — đây là lần đọc "nguội", từ đĩa. Chạy time cat lần nữa ngay sau đó không xả cache — đây là lần "nóng", từ page cache. Bạn sẽ thấy lần hai nhanh hơn hàng chục tới hàng trăm lần. Rồi thử đúng thí nghiệm này bên trong một container Docker: rất có thể drop_caches bị chặn (chỉ-đọc), và ngay cả khi xả được, hai lần đọc gần như bằng nhau — vì bên dưới không có đĩa chậm thật. Sự khác biệt giữa hai môi trường chính là bài học: bạn chỉ thấy page cache nhảy khi có một đĩa thật đủ chậm để so.