Ở bài về page cache ta thấy đọc file lần hai nhanh hơn hẳn vì dữ liệu đã nằm trong bộ đệm của nhân. Nhưng có một cờ đặc biệt bảo nhân đừng cache: O_DIRECT — đọc thẳng từ đĩa vào bộ nhớ chương trình, bỏ qua page cache hoàn toàn. Nghe như một tối ưu: ít lần sao chép hơn, đi thẳng tới đích. Bài này đo O_DIRECT so với đọc đệm thông thường — và phát hiện hai điều: nó không nhanh hơn như tôi tưởng, và môi trường đo suýt cho tôi một kết quả hoàn toàn vô nghĩa.
Buffered và O_DIRECT
Đọc file thông thường (buffered) đi qua page cache: nhân đọc dữ liệu từ đĩa vào bộ đệm của nó, rồi sao chép sang bộ nhớ chương trình — và giữ lại bản sao trong cache để lần đọc sau khỏi chạm đĩa. Đây là lý do đọc lại nhanh, và là hành vi mặc định của mọi lời read.
Mở file với cờ O_DIRECT đảo cách đó: dữ liệu đi thẳng từ đĩa vào bộ nhớ chương trình qua DMA, không qua page cache, và không được cache lại. Đổi lại việc bỏ cache, O_DIRECT có vài tính chất: đọc lại không nhanh hơn (chẳng có cache để hit), nhưng nó không "làm bẩn" page cache bằng dữ liệu dùng một lần, và cho phép chương trình tự quản việc đệm — đúng cái các cơ sở dữ liệu (PostgreSQL, MySQL, Oracle) làm khi chúng có buffer pool riêng và không muốn nhân cache trùng. O_DIRECT cũng đòi hỏi khắt khe: bộ đệm, offset, và kích thước đọc phải căn lề theo khối (thường 512 hay 4096 byte), sai là lỗi ngay. Tôi muốn đo O_DIRECT có thật sự nhanh hơn không.
Đo: và một môi trường nói dối
Phép đo đầu tiên của tôi cho một kết quả vô lý ngay lập tức, nên tôi phải kể nó trước. Tôi tạo một file 256 MB trên thư mục bind-mount (chia sẻ từ máy Mac vào container) rồi đo:
Trên bind-mount (fuseblk): buffered 1261 (lạnh) / 3076 (ấm), direct 3456 MB/s
O_DIRECT (3456 MB/s) không hề chậm hơn buffered, thậm chí nhanh hơn cả bản ấm. Điều đó bất khả nếu O_DIRECT thật sự bỏ cache và chạm đĩa. Số đo bất khả nghĩa là công cụ nói dối: hệ thống file của bind-mount là fuseblk (cầu Mac↔máy ảo qua FUSE), và ở đó máy Mac host cache toàn bộ đĩa ảo trong RAM của nó, còn tầng FUSE bỏ qua luôn cờ O_DIRECT. "Direct" ở đây chẳng chạm một cái đĩa thật nào — nó vẫn được phục vụ từ cache của host. Đo O_DIRECT ở đây là đo một thứ không tồn tại.
Để có phép đo đúng, tôi tự dựng một hệ thống file ext4 thật qua loop device bên trong máy ảo, nơi page cache của guest cư xử đúng. Kết quả lúc này mới có nghĩa:
| Cách đọc | Thông lượng |
|---|---|
| buffered LẠNH (drop_caches trước) | 5.128 MB/s |
| buffered ẤM (đọc lại từ cache) | 31.882 MB/s |
| O_DIRECT | 6.552 MB/s |
Giờ khác biệt lộ ra: buffered ấm — đọc lại khi dữ liệu đã trong page cache — đạt 31.882 MB/s, nhanh gấp gần 5 lần O_DIRECT (6.552 MB/s). Còn buffered lạnh (5.128) thì xấp xỉ O_DIRECT, vì cả hai đều phải đi lấy dữ liệu lần đầu. Và khi tôi thử read với O_DIRECT vào một bộ đệm lệch căn lề, nó thất bại ngay với EINVAL ("Invalid argument"); chỉ bộ đệm căn lề 4096 mới đọc được.
Một lần tôi đo hớ: O_DIRECT là I/O không cache, không phải I/O nhanh
Tôi vào bài với giả định rất phổ biến: O_DIRECT bỏ qua page cache, tức bỏ được một tầng sao chép và một tầng "phí tổn", nên phải nhanh hơn. Nhưng con số bác bỏ thẳng: buffered ấm nhanh gấp 5 lần O_DIRECT. Sai lầm của tôi là hiểu nhầm O_DIRECT để làm gì.
O_DIRECT không phải "I/O nhanh", mà là "I/O không cache". Cái tôi tưởng là "phí tổn" của page cache — việc nhân giữ một bản sao — thực ra là tính năng quý nhất của nó: nó khiến lần đọc sau gần như miễn phí. Bỏ cache đi bằng O_DIRECT, bạn vứt luôn lợi ích đó, nên đọc lại chẳng nhanh hơn lần đầu. O_DIRECT chỉ thắng trong những tình huống mà cache vốn không giúp gì hoặc còn gây hại: một cơ sở dữ liệu đã có buffer pool riêng và không muốn nhân cache trùng (tốn RAM hai lần); hay stream một file khổng lồ đọc đúng một lần rồi bỏ (cache nó chỉ đẩy dữ liệu nóng khác ra khỏi cache mà chẳng bao giờ dùng lại). Với mọi tải mà đọc-lại là chuyện thường, buffered thắng nhờ cache.
Bài học đo lường có hai tầng, và cả hai đều là chủ đề xuyên suốt sê-ri. Thứ nhất: đừng gán "nhanh" cho một cơ chế mà không hỏi nó đánh đổi cái gì — O_DIRECT đánh đổi cache lấy khả năng tự quản, và với phần lớn tải, mất cache là mất tốc độ. Thứ hai, và quan trọng hơn ở đây: môi trường tự dựng là một biến khổng lồ khi đo I/O. Lần đầu tôi đo trên một hệ thống file (fuseblk) mà bản chất của phép đo — O_DIRECT chạm đĩa thật — bị vô hiệu bởi các tầng cache lồng nhau bên dưới (host cache đĩa ảo, FUSE nuốt cờ O_DIRECT). Trong một máy ảo lồng nhau, "direct" không direct chút nào. Phải xác minh môi trường (kiểm loại filesystem, tự dựng ext4 thật) trước khi tin bất cứ con số I/O nào — nếu không, bạn đo đặc tính của tầng ảo hóa chứ không phải của thứ mình nghĩ.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là đừng với tay tới O_DIRECT để "tăng tốc I/O" — gần như luôn sai. Với ứng dụng thông thường, page cache là bạn: nó làm đọc lại nhanh, gộp readahead, và nhân quản lý nó tốt hơn bạn tự làm. O_DIRECT chỉ dành cho một nhóm hẹp — cơ sở dữ liệu và vài hệ thống lưu trữ có buffer pool riêng, cần kiểm soát chính xác cái gì nằm trong RAM và tránh đệm hai lần. Nếu bạn không đang viết một database engine, nhiều khả năng bạn không cần O_DIRECT; và nếu dùng, cái giá alignment cùng sự phức tạp thường lớn hơn lợi ích.
Hệ quả thứ hai là nếu thật sự muốn tránh làm bẩn cache mà không cần độ phức tạp của O_DIRECT, có công cụ nhẹ hơn. Với một lần đọc/ghi tuần tự lớn dùng một lần (sao lưu, xử lý log, copy file khổng lồ), posix_fadvise(POSIX_FADV_DONTNEED) sau khi đọc bảo nhân xả các trang đó khỏi cache, hay POSIX_FADV_SEQUENTIAL để chỉnh readahead — đạt phần lớn lợi ích "không làm bẩn cache" của O_DIRECT mà vẫn dùng đường buffered quen thuộc, không lo alignment.
Hệ quả thứ ba, về đo lường: xác minh môi trường trước khi tin số đo, đặc biệt với I/O trong máy ảo/container. Con số mang theo: O_DIRECT bỏ qua page cache nên đọc lại KHÔNG nhanh hơn — buffered ấm (từ cache) đo được nhanh gấp ~5 lần O_DIRECT; O_DIRECT là I/O không cache (cho CSDL tự quản đệm, hay stream đọc một lần), không phải I/O nhanh, và đòi buffer căn lề (lệch thì EINVAL); nhưng trên hệ thống file ảo (fuseblk/bind-mount) các tầng cache lồng nhau vô hiệu hóa O_DIRECT khiến phép đo vô nghĩa. Một con số I/O đẹp trong container có thể chỉ là đặc tính của lớp ảo hóa, không phải của mã bạn.
Thử ba mươi giây
Xem đọc lại nhờ cache nhanh cỡ nào: dd if=/dev/zero of=/tmp/t bs=1M count=256; sync; echo 3 | sudo tee /proc/sys/vm/drop_caches rồi dd if=/tmp/t of=/dev/null bs=1M (lạnh) và chạy dd đó lần nữa ngay sau (ấm) — lần hai nhanh hơn hẳn, đó là page cache. Kiểm loại filesystem bạn đang đo trên đó: stat -f -c %T <đường-dẫn> — nếu ra fuseblk, overlayfs, hay tương tự trong container/máy ảo, hãy cẩn thận với mọi phép đo I/O vì có tầng cache/ảo hóa xen giữa. Và nếu định thử O_DIRECT, nhớ căn lề: dd iflag=direct chỉ chạy khi khối căn lề, và trên nhiều filesystem ảo nó bị bỏ qua âm thầm — đúng cái bẫy môi trường bài này vấp phải.