Đọc một megabyte từ file, dù bạn đọc tuần tự (từ đầu đến cuối) hay ngẫu nhiên (nhảy tới các vị trí rải rác), cũng vẫn là một megabyte — nên trực giác nói chúng tốn như nhau. Sai. Mẫu truy cập (access pattern) là một trong những yếu tố ảnh hưởng hiệu năng I/O mạnh nhất, và tuần tự gần như luôn nhanh hơn ngẫu nhiên — đôi khi rất nhiều. Tôi đo trong container gcc:13, và phải thành thật ngay: file của tôi nằm trong page cache nên tôi không đo được cái phần lớn nhất của khác biệt (seek đĩa vật lý). Nhưng ngay cả trong cache, gap vẫn hiện ra — và đó là một bài học thú vị về vì sao.

I/O ngẫu nhiên vs tuần tự

Vì sao tuần tự nhanh hơn

Có hai cơ chế làm tuần tự thắng, ngay cả khi không có đĩa vật lý:

Readahead (đọc trước). Nhân theo dõi mẫu truy cập file của bạn. Khi nó phát hiện bạn đang đọc tuần tự, nó chủ động đọc trước các trang kế tiếp vào page cache — trước cả khi bạn yêu cầu. Nên tới lúc bạn read trang tiếp theo, nó đã sẵn trong RAM, không phải chờ. Với truy cập ngẫu nhiên, nhân không đoán được bạn sẽ nhảy đi đâu, nên nó không đọc trước (đúng — đọc trước sai chỗ chỉ phí băng thông). Bạn mất hết lợi ích prefetch.

Locality cache/TLB. Đọc tuần tự chạm các trang liên tiếp trong bộ nhớ — hành vi cache CPU và TLB tốt trong đường sao chép dữ liệu. Đọc ngẫu nhiên nhảy khắp nơi, gây nhiều cache/TLB miss hơn. Còn trên đĩa vật lý (thứ tôi không đo được ở đây), khác biệt lớn hơn nhiều: tuần tự là streaming liền mạch, còn ngẫu nhiên bắt đầu đọc/định vị (seek) mỗi lần nhảy.

Tôi đo: tạo file 512 MB, đọc 800 MB (200.000 khối 4 KB) theo hai kiểu, sau khi file đã vào page cache (để cô lập hiệu ứng readahead/locality, không lẫn với I/O đĩa lần đầu), dùng posix_fadvise gợi ý kiểu truy cập.

Đo: 2,15 lần, ngay cả trong cache

Đọc 800 MB (khối 4 KB) từ file 512 MB đang trong page cache:
  TUẦN TỰ  (posix_fadvise SEQUENTIAL): 0,049 s | 16.752 MB/s
  NGẪU NHIÊN (posix_fadvise RANDOM)  : 0,105 s |  7.798 MB/s
  -> tuần tự nhanh 2,15× (chỉ do readahead + cache locality; file trong cache, KHÔNG có seek đĩa)

Đọc tuần tự đạt 16.752 MB/s; đọc ngẫu nhiên chỉ 7.798 MB/s — chậm hơn 2,15 lần, dù đọc cùng một lượng byte từ cùng một file đã nằm sẵn trong RAM. Không có một byte nào phải chờ đĩa; toàn bộ khác biệt đến từ readahead (nhân prefetch trang kế cho đường tuần tự) và locality (đường tuần tự thân thiện cache/TLB hơn). Ngay cả khi dữ liệu đã ở trong bộ nhớ, mẫu bạn chạm tới nó vẫn quyết định tốc độ.

Phải nói thẳng giới hạn: 2,15 lần này chỉ là phần "cache/readahead" của câu chuyện. Trên một đĩa vật lý thật, gap còn khổng lồ hơn nhiều. Đĩa cứng cơ (HDD) phải di chuyển đầu đọc và chờ đĩa quay tới đúng chỗ cho mỗi lần seek — mất ~vài mili-giây mỗi lần, nên đọc ngẫu nhiên có thể chậm hơn tuần tự hàng trăm lần. SSD không có seek cơ học nên gap nhỏ hơn, nhưng vẫn vài lần (băng thông tuần tự vs số IOPS 4 KB ngẫu nhiên). Container của tôi (file trong cache, đĩa ảo) không cho tôi đo con số đĩa đó — tôi chỉ nêu để bạn biết cái tôi đo được (2,15×) là cận dưới của khác biệt thật.

Một lần tôi đo hớ: "cùng byte thì như nhau"

Tôi vào đo với hai niềm tin. Thứ nhất: "đọc cùng một lượng byte thì tuần tự hay ngẫu nhiên cũng tốn như nhau". Sai — cùng 800 MB, tuần tự nhanh 2,15 lần. Thứ hai, tinh vi hơn: "một khi dữ liệu đã trong page cache thì hết khác biệt, vì không còn seek đĩa nữa". Cũng sai — đo cho thấy vẫn còn 2,15 lần trong cache, đến từ readahead và cache locality, hai thứ hoạt động độc lập với đĩa.

Bài học đo lường: mẫu truy cập ảnh hưởng hiệu năng I/O ngay cả khi không chạm đĩa — readahead và locality thưởng cho tuần tự; và trên đĩa vật lý, seek còn phóng đại khác biệt lên hàng trăm lần. Nếu tôi tin "cùng byte thì như nhau", tôi đã bỏ qua một trong những đòn bẩy hiệu năng lớn nhất của hệ thống lưu trữ. Và nếu tôi chỉ đo trong cache rồi kết luận "gap chỉ 2 lần, không đáng lo", tôi đã hiểu sai — vì trên phần cứng thật gap lớn hơn nhiều, và phép đo cache của tôi chỉ thấy phần nhỏ nhất của nó.

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

Hệ quả đầu tiên: thiết kế để truy cập tuần tự khi có thể. Đây là lý do sâu xa vì sao cơ sở dữ liệu và hệ thống log thích ghi tuần tự (append-only): thêm vào cuối là streaming, nhanh; ghi rải rác giữa file là random, chậm (đặc biệt trên HDD). Log-structured storage, LSM-tree, WAL (write-ahead log) — tất cả xoay quanh việc biến ghi ngẫu nhiên thành ghi tuần tự. Khi đọc, sắp xếp yêu cầu theo offset (hoặc dùng index để gom) để chạm đĩa tuần tự nhất có thể.

Hệ quả thứ hai: gợi ý cho nhân biết mẫu của bạn. posix_fadvise(POSIX_FADV_SEQUENTIAL) bảo nhân mở rộng cửa sổ readahead (prefetch mạnh hơn) khi bạn sắp quét tuần tự một file lớn; POSIX_FADV_RANDOM tắt readahead khi bạn sẽ nhảy lung tung (để nhân không phí băng thông đọc trước những trang bạn không dùng). Một gợi ý đúng có thể cải thiện đáng kể, nhất là với file lớn hơn cache.

Hệ quả thứ ba là tinh thần đo lường: đo ở đúng điều kiện — và biết điều kiện của bạn giấu đi cái gì. Con số mang theo: cùng một lượng byte, đọc tuần tự nhanh hơn ngẫu nhiên vì (1) readahead — nhân prefetch trang kế khi phát hiện tuần tự, và (2) cache/TLB locality; đo trong page cache đã thấy 2,15× (800MB: tuần tự 16.752 MB/s vs ngẫu nhiên 7.798 MB/s) DÙ không có seek đĩa; trên đĩa vật lý thật gap còn khổng lồ hơn — HDD random chậm 100×+ vì seek ~ms mỗi lần, SSD vài lần (không đo được ở container này). Nên thiết kế I/O tuần tự (append, index để bớt seek) và dùng posix_fadvise gợi ý. Cùng byte không có nghĩa cùng tốc độ — mẫu mới quyết định.

Thử ba mươi giây

Viết một chương trình đọc một file lớn (vài trăm MB) theo hai kiểu và bấm giờ: một vòng đọc tuần tự từng khối 4 KB từ đầu đến cuối, và một vòng pread 4 KB tại các offset ngẫu nhiên (cùng số lần đọc). Chạy trên máy bạn. Nếu file đã trong cache (chạy lần hai), bạn sẽ thấy tuần tự nhanh gấp đôi hay hơn — chỉ do readahead và locality. Rồi nếu bạn có cách xóa cache (echo 3 > /proc/sys/vm/drop_caches với quyền root) và file nằm trên đĩa thật, đo lại lần đầu (cold): trên HDD bạn sẽ thấy ngẫu nhiên chậm hàng chục tới hàng trăm lần — cú seek lộ nguyên hình. Ba mươi giây (hoặc một phút) đó cho bạn thấy một sự thật mà "cùng byte thì như nhau" che giấu: với lưu trữ, cách bạn chạm dữ liệu quan trọng ngang, có khi hơn, lượng dữ liệu bạn chạm.