Ai cũng biết "đọc tuần tự nhanh hơn đọc ngẫu nhiên". Nhưng vì sao? Câu trả lời quen thuộc — "vì đĩa không phải di chuyển đầu đọc" — là di sản của thời đĩa cứng quay, và trên SSD nó sai. Bài này đo trực tiếp tuần tự so với ngẫu nhiên, rồi tách biến để tìm ra thủ phạm thật: một cơ chế tên là readahead (đọc trước), thứ mà tôi suýt bỏ qua vì bám vào trực giác cũ.
Readahead: nhân đoán bạn sẽ đọc tiếp
Khi nhân thấy một tiến trình đọc các trang tệp liên tiếp nhau, nó suy ra bạn đang đọc tuần tự và sẽ cần các trang kế tiếp. Thay vì đợi bạn xin, nó nạp trước (readahead) cả một vùng lớn từ đĩa vào page cache. Đến khi bạn thật sự đọc tới đó, dữ liệu đã sẵn trong RAM — lần đọc của bạn là một cache hit nhanh thay vì một lần chờ đĩa. (Chính cơ chế này đã "che" các major fault ở bài lỗi trang: readahead nạp trước bất đồng bộ nên luồng ít khi phải chặn.)
Ngược lại, khi bạn đọc ngẫu nhiên — nhảy tới các vị trí rải rác — nhân không đoán được đâu là trang kế, nên readahead vô dụng: mỗi lần đọc là một lần chờ đĩa lấy đúng một khối. Bạn có thể chỉnh readahead: posix_fadvise() gợi ý cho nhân mẫu truy cập của bạn, hoặc blockdev --setra đổi cỡ readahead của thiết bị.
Đo: tuần tự 6701, ngẫu nhiên 295, tắt readahead 134
Tôi tạo một tệp 200 MB, đọc trọn 51.200 khối 4 KB của nó theo ba kiểu, mỗi lần đều xóa cache trước (để đọc lạnh từ đĩa), và giữ cùng số khối để chỉ so thứ tự truy cập:
| Kiểu đọc | MB/s | µs/khối | So với tuần tự |
|---|---|---|---|
| Tuần tự (readahead BẬT) | 6701 | 0,6 | 1× (nhanh) |
| Ngẫu nhiên (readahead bật) | 295 | 13,9 | 23× chậm hơn |
| Tuần tự NHƯNG readahead TẮT | 134 | 30,6 | 50× chậm hơn |
Hai dòng đầu xác nhận điều ai cũng biết: đọc tuần tự (6701 MB/s) nhanh hơn ngẫu nhiên (295 MB/s) khoảng 23 lần. Nhưng dòng thứ ba mới là chỗ tôi vấp — và nó lật ngược lời giải thích quen thuộc.
Không phải seek, mà là readahead
Trực giác đầu tiên của tôi cho khác biệt tuần-tự-so-với-ngẫu-nhiên là seek: đĩa cứng quay phải di chuyển đầu đọc cơ học tới đúng vị trí, và di chuyển ngẫu nhiên khắp đĩa tốn thời gian hơn di chuyển tuần tự. Tôi đã định viết giải thích đó vào bài. Nhưng khựng lại: phép đo này chạy trên SSD — nó không có đầu đọc cơ học nào để mà di chuyển. Seek trên SSD gần như miễn phí. Vậy nếu không phải seek, cái gì làm tuần tự nhanh hơn ngẫu nhiên tới 23 lần?
Để tìm ra, tôi tách biến: đọc tuần tự y hệt (cùng thứ tự khối như bản nhanh), nhưng tắt readahead đi bằng posix_fadvise(fd, 0, 0, POSIX_FADV_RANDOM). Kết quả: tốc độ sụp từ 6701 xuống 134 MB/s — chậm 50 lần, thậm chí còn chậm hơn cả đọc ngẫu nhiên. Cùng thứ tự đọc, không đổi một khối nào, không có cú seek nào (SSD mà), chỉ khác readahead bật hay tắt — mà chênh nhau 50 lần.
Vậy thủ phạm thật đã rõ: cái làm đọc tuần tự nhanh không phải vì không seek, mà vì readahead — nhân nạp trước dữ liệu theo lô lớn nên các lần đọc của bạn hầu hết là cache hit. Và đọc ngẫu nhiên chậm không phải vì seek (SSD seek rẻ), mà vì nó vô hiệu hóa dự đoán của readahead: nhân không biết nạp trước gì, nên mỗi khối phải lấy riêng từ đĩa.
Điều rút ra: đừng bê nguyên trực giác của phần cứng cũ sang phần cứng mới. "Tuần tự nhanh vì đĩa không seek" đúng cho ổ cứng quay, nhưng trên SSD nguyên nhân đã đổi hoàn toàn sang một cơ chế phần mềm (readahead). Cách duy nhất để biết chắc là tách biến — giữ mọi thứ như nhau (kể cả thứ tự đọc) và chỉ đổi đúng một yếu tố (readahead) — để xem yếu tố nào thật sự gây ra khác biệt. Nếu tôi tin trực giác cũ, tôi đã dạy người đọc một nguyên nhân sai và một cách tối ưu sai.
Về đến code hằng ngày
Điều đầu tiên là thiết kế truy cập dữ liệu để thân thiện với readahead. Khi có thể, hãy đọc tuần tự — quét một tệp từ đầu tới cuối, lưu dữ liệu theo thứ tự bạn sẽ đọc lại, gom các bản ghi liên quan gần nhau. Đọc tuần tự để nhân nạp trước hộ bạn là một trong những "bữa trưa miễn phí" hiếm hoi của hiệu năng — bạn không viết thêm dòng code nào mà vẫn nhanh hơn nhiều lần, chỉ nhờ sắp xếp truy cập cho có thứ tự. Ngược lại, một mẫu truy cập ngẫu nhiên (nhảy khắp một tệp lớn, đọc các bản ghi rải rác) phá vỡ readahead và có thể chậm hàng chục lần — đây là lý do các cơ sở dữ liệu tối ưu cách bố trí dữ liệu trên đĩa (clustered index, sắp xếp theo khóa) để biến truy cập logic thành đọc tuần tự vật lý.
Điều thứ hai là gợi ý cho nhân khi bạn biết mẫu truy cập. Nếu bạn thật sự đọc ngẫu nhiên (một cơ sở dữ liệu, một cache trên đĩa), posix_fadvise(POSIX_FADV_RANDOM) bảo nhân đừng phí công readahead — tránh nạp trước những trang bạn sẽ không dùng, đỡ lãng phí băng thông và bộ nhớ. Nếu bạn sắp quét tuần tự một tệp lớn, POSIX_FADV_SEQUENTIAL bảo nhân đọc trước hung hăng hơn. Nói cho nhân biết ý định của bạn thường tốt hơn để nó đoán.
Điều cuối là một nguyên tắc chẩn đoán: cùng một trực giác có thể đúng hiện tượng nhưng sai cơ chế khi nền tảng đổi. Gói lại bằng số: đọc tuần tự (6701 MB/s) nhanh hơn ngẫu nhiên (295 MB/s) ~23 lần, nhưng nguyên nhân trên SSD là READAHEAD chứ không phải seek — tắt readahead thì đọc tuần tự cũng sụp còn 134 MB/s (chậm 50 lần) dù không seek. Tuần tự nhanh vì nhân nạp trước hộ bạn; ngẫu nhiên chậm vì nó làm nhân không đoán được. Khi tối ưu I/O, hãy hỏi "mẫu truy cập của tôi có để readahead giúp được không?", chứ đừng chỉ nghĩ tới seek như thời ổ cứng quay.
Cái nguyên tắc "đọc tuần tự để hệ thống nạp trước hộ, và tránh mẫu truy cập ngẫu nhiên phá dự đoán" này chi phối hiệu năng ở mọi tầng lưu trữ, không riêng file. Trong Java, một BufferedInputStream đọc theo lô lớn cộng hưởng với readahead của OS, còn nhảy RandomAccessFile.seek khắp một file lớn thì phá cả hai; và ở tầng cao hơn, đây chính là lý do quét bảng tuần tự trong một cơ sở dữ liệu thường nhanh hơn mỗi byte so với dùng index nhảy lung tung — sequential scan để storage engine đọc trước, còn index scan trên bảng lớn có thể biến thành hàng loạt random read. Cơ sở dữ liệu thấm bài học này tới tận thiết kế: clustered index của SQL Server/InnoDB sắp dữ liệu vật lý theo khóa chính để truy vấn theo khoảng biến thành đọc tuần tự; PostgreSQL có tham số effective_io_concurrency và readahead của OS để tối ưu quét; các LSM-tree (RocksDB, Cassandra) cố tình ghi tuần tự và gộp (compaction) để đọc về sau cũng tuần tự. Go, Python — mọi thứ đọc file — đều hưởng readahead của OS khi truy cập có thứ tự. Mẫu số chung, đúng như bài này: hầu hết mọi tầng lưu trữ đều có một cơ chế đoán trước và nạp sẵn thưởng cho truy cập tuần tự và phạt truy cập ngẫu nhiên; nên đòn bẩy hiệu năng lớn nhất thường không phải "đọc nhanh hơn" mà "sắp xếp dữ liệu và truy cập theo thứ tự" để cơ chế đoán-trước đó làm việc hộ bạn — và khi một thứ chậm bất ngờ, hỏi "mình có đang phá vỡ dự đoán tuần tự của tầng dưới không", chứ đừng đổ cho một cơ chế phần cứng có thể đã lỗi thời.
Muốn tự đo thì trên máy Linux có fio (công cụ đo I/O), so hai lệnh: fio --name=seq --rw=read --bs=4k --size=200m --filename=/tmp/f (đọc tuần tự) và đổi --rw=randread (đọc ngẫu nhiên) — bạn sẽ thấy thông lượng tuần tự cao hơn hẳn. Không có fio? Xem cỡ readahead hiện tại của một thiết bị bằng blockdev --getra /dev/sda (đơn vị 512-byte, thường 256 = 128 KB), rồi thử blockdev --setra 0 /dev/sda để tắt và đo lại một lần quét tuần tự — nó sẽ chậm đi rõ rệt, đúng như bài này đo. Cái tăng tốc đó, mà bạn không hề viết một dòng code nào, chính là readahead đang lặng lẽ đoán trước bước đi của bạn — một món quà của nhân dành riêng cho những ai chịu đọc theo thứ tự.