Bạn có 78 MB dữ liệu cần đọc. Nó nằm trong một file 78 MB, hay trong 20.000 file nhỏ mỗi cái 4 KB, thì thời gian đọc có khác nhau không? Trực giác nói không — cùng số byte mà. Nhưng có một chi phí ẩn mà mỗi file phải trả, độc lập với việc file to hay nhỏ: chi phí mở file (open). Tôi đo trong container gcc:13, và không chỉ thấy nhiều file nhỏ chậm hơn, mà còn chứng minh được chi phí đó là per-file, không phải per-byte — bằng một phép so sánh mà con số nói thẳng.
Cái giá của mỗi lần mở file
Mỗi khi bạn open một file, hệ điều hành làm nhiều việc bất kể file lớn nhỏ: nó phải phân giải đường dẫn (path lookup — đi qua từng thành phần thư mục, tra bảng dentry), nạp inode của file (metadata: quyền, chủ sở hữu, kích thước, danh sách block trên đĩa), rồi cấp một file descriptor. Đó là một lời gọi hệ thống — một lần vượt ranh giới user/kernel — cộng công tra cứu metadata. Khi xong, close là một syscall nữa. Toàn bộ cái đó là chi phí cố định mỗi file, chẳng liên quan gì tới việc bạn sắp đọc 4 KB hay 4 MB từ nó.
Một file lớn trả cái giá đó một lần: một open, rồi bạn stream toàn bộ dữ liệu bằng nhiều read nối tiếp (được readahead của phần 40 giúp sức). Nhưng N file nhỏ trả cái giá đó N lần: N × (open + read + close + tra metadata). Nếu chi phí per-file lớn so với lượng byte mỗi file, tổng thời gian bị nó chi phối. Tôi đo để xem, và để tách bạch per-file khỏi per-byte.
Đo: 4× chậm hơn, và bằng chứng per-file
Đọc bằng open+read+close mỗi file (file đã trong page cache):
20.000 file 4 KB (78 MB) : 0,022 s | 1,10 µs/file
20.000 file 512 B (9,8 MB, ÍT hơn 8 lần): 0,023 s | 1,14 µs/file <- gần BẰNG!
1 file 78 MB (stream khối 64 KB) : 0,0052 s (một open)
-> 20.000 file nhỏ chậm ~4× một file lớn cùng byte
Đọc 78 MB dưới dạng 20.000 file 4 KB mất 0,022 giây; đọc cùng 78 MB đó dưới dạng một file chỉ mất 0,0052 giây — nhanh 4 lần. Cùng dữ liệu, cùng số byte, nhưng cách chia thành file quyết định tốc độ. Mỗi file nhỏ tốn ~1,10 µs chỉ cho khâu mở/đóng/metadata; nhân với 20.000 thành ~22 ms, trong khi stream 78 MB từ một file chỉ ~5 ms. Phần lớn thời gian không phải đọc dữ liệu, mà là mở file.
Đây là chỗ tôi chứng minh chi phí là per-file chứ không phải per-byte: tôi đọc 20.000 file 512 byte — cùng số file nhưng dữ liệu ít đi 8 lần (9,8 MB thay vì 78 MB). Nếu chi phí theo byte, nó phải nhanh hơn ~8 lần. Nhưng đo ra: 0,023 giây — gần như bằng với 20.000 file 4 KB. Giảm dữ liệu 8 lần không làm nó nhanh hơn, vì thời gian bị chi phối bởi 20.000 lần open, không phải bởi lượng byte. Chi phí per-file (~1,1 µs) là cố định, dù file 512 byte hay 4 KB.
Một điều thành thật về phép đo: các file đều đã trong page cache (tôi warmup trước), nên tôi không đo phần I/O đĩa. Chi phí per-file tôi đo được là open/metadata/syscall — thứ có thật bất kể cache. Trên một đĩa thật (nhất là HDD), mỗi lần mở một file nhỏ mới có thể phải seek để đọc inode rồi seek lần nữa để đọc dữ liệu — nên gap giữa "nhiều file nhỏ" và "một file lớn" trên đĩa thật còn tệ hơn nhiều con số 4× tôi đo trong cache.
Một lần tôi đo hớ: "cùng byte thì như nhau"
Tôi vào đo với niềm tin thẳng thắn: "đọc cùng một lượng byte thì chia thành bao nhiêu file cũng vậy". Đo phá tan: 20.000 file nhỏ chậm 4 lần một file lớn cùng byte — và cái test 512 byte cho thấy vì sao: chi phí gắn với số file, không phải số byte. Mỗi open là một khoản phí cố định (path lookup + inode + syscall), nên hàng vạn file nhỏ nghĩa là hàng vạn lần trả phí đó, lấn át việc đọc dữ liệu thật.
Bài học đo lường: chi phí I/O của "một tập dữ liệu" thường là per-file (mở/metadata), không per-byte — nên gộp nhiều thứ nhỏ vào ít file lớn nhanh hơn nhiều dù tổng byte không đổi. Đây chính là lý do tồn tại của vô số kỹ thuật đóng gói: tar/zip gói cả cây thư mục thành một file; cơ sở dữ liệu một-file như SQLite giữ hàng nghìn "bản ghi" trong một file thay vì một file mỗi bản ghi; git packfile gộp hàng nghìn object nhỏ vào một file; các engine game gói hàng nghìn texture/asset vào một "bundle". Tất cả trả cái giá open một lần rồi định vị bên trong, thay vì mở hàng nghìn file. Nếu tôi tin "cùng byte thì như nhau", tôi đã thiết kế một hệ lưu hàng triệu file nhỏ (một file mỗi mẩu) và ngạc nhiên vì sao nó chậm rề khi quét.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: tránh "một file cho mỗi mẩu dữ liệu nhỏ". Nếu bạn có hàng nghìn/triệu mẩu nhỏ (bản ghi, ảnh thumbnail, mảnh dữ liệu), đừng lưu mỗi mẩu một file — chi phí mở sẽ giết bạn khi đọc/liệt kê hàng loạt, và còn phí cả inode lẫn không gian (mỗi file chiếm tối thiểu một block). Gộp chúng: một file dữ liệu có index, một database, một archive. "Nhiều file nhỏ" là một trong những phản mẫu hiệu năng phổ biến nhất, đặc biệt trên mạng lưu trữ và filesystem phân tán.
Hệ quả thứ hai: khi phải xử lý nhiều file, giảm số lần chạm metadata. Nếu buộc phải đọc nhiều file, đọc chúng tuần tự (để filesystem tối ưu), tránh stat thừa (mỗi stat cũng là một syscall + metadata), và cân nhắc readdir + đọc theo lô. Với web/asset, gộp (bundling) nhiều file tĩnh thành ít file lớn là tối ưu kinh điển — cùng lý do.
Hệ quả thứ ba là tinh thần đo lường: hỏi chi phí gắn với cái gì — số byte, hay số lần thao tác — rồi giảm cái đắt. Con số mang theo: chi phí đọc một tập dữ liệu thường là PER-FILE (open + path lookup + nạp inode + close ~1,1 µs/file), KHÔNG per-byte: đo 20.000 file nhỏ chậm ~4× một file lớn cùng 78MB; và 20.000 file 4KB với 20.000 file 512B (ít hơn 8 lần dữ liệu) tốn NGANG nhau (0,022 vs 0,023 s) — chứng tỏ thời gian theo SỐ FILE không theo byte. Vì vậy tar/zip, SQLite, git packfile, asset bundle gộp nhiều thứ nhỏ vào một file để trả open một lần. (Trên đĩa vật lý thật gap còn tệ hơn vì seek đọc inode.) Cùng byte không có nghĩa cùng chi phí — số lần mở mới là thứ đắt.
Thử ba mươi giây
Tạo một thư mục với vài nghìn file nhỏ (for i in $(seq 10000); do echo x > f$i; done) và một file lớn cùng tổng dung lượng, rồi bấm giờ đọc từng cái: time cat dir/* > /dev/null so với time cat bigfile > /dev/null. Bạn sẽ thấy cái nhiều-file-nhỏ chậm hơn hẳn — dù cat đọc cùng lượng byte. Hoặc trong code: một vòng lặp open/read/close vài nghìn file nhỏ, và một open/stream một file lớn cùng byte; đo và so. Nếu muốn thấy bằng chứng per-file, làm hai bộ file cùng số lượng nhưng khác kích thước 8 lần — chúng sẽ tốn thời gian gần bằng nhau. Ba mươi giây đó cho bạn thấy một chi phí mà "cùng byte thì như nhau" giấu đi: mở một file có giá, và khi bạn mở hàng nghìn, cái giá đó — không phải dữ liệu — mới là thứ bạn đang trả.