You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
bench/BenchCore.cpp (entry point bench/main.cpp) is a benchmark with no third-party dependencies. It compares baselines that call the C / C++ / OS file APIs directly with VFS backends and layered combinations, measuring throughput and latency of reads, writes, and metadata operations.
Build and run
It is not built by default; enable it with VFS_BUILD_BENCH. Always use a Release build. Numbers from Debug are not meaningful (the program prints the build type at startup).
On Windows (MSVC) use cmake --build build-bench --config Release. The executable is build-bench/bench/Release/vfs_bench.exe.
Option
Description
--scale <x>
Data-size multiplier, default 1: 10000 small files x 4 KiB (in one directory), 200 medium files x 256 KiB, one 64 MiB file; random-read count and written file count scale the same way
--threads <list>
Thread-count list. N is the hardware thread count. Default 1,2,4,8,N (duplicates are dropped)
--iterations <n>
Repetitions per combination; the run with the median elapsed time is kept. Default 3
--filter <list>
Comma-separated substrings matched against scenario/target; a hit on any one runs it
--csv <file> / --md <file>
Also write a CSV / Markdown table
--dir <path>
Working directory, default the system temp directory; the dataset is about 0.5 GB (scale=1: 154 MiB of raw files plus three archives)
--keep
Keep the dataset when finished
--cold
Before each iteration, drop data files from the page cache with posix_fadvise(DONTNEED) (Linux only, no root required); the default is one warmup run then timing (warm cache)
--list
List scenarios and targets
Scenarios
Each thread does the same amount of work (weak scaling). Aggregate throughput = operations across all threads / wall-clock time.
Scenario
One operation
read.small
Open + read all 4 KiB + close; each thread reads every small file, with staggered start points
read.medium
Open + read all 256 KiB in 64 KiB chunks + close
read.large.seq
Sequentially read the 64 MiB file in 1 MiB chunks, once per thread; latency is per chunk
read.large.rand
Open the large file once, then read 4 KiB at random offsets (seek + read; the posix baseline uses pread); open counts toward total time but not per-operation latency
meta.open
Open and close a small file, nothing else
meta.stat
stat an existing small file
meta.miss
exists query for a name that is not in a large directory
meta.list
List a directory of 10000 files, 10 times per thread
write.create
Each thread creates and writes 64 KiB files in its own directory
write.churn
Create and write a 4 KiB file, then delete it (one operation = create + delete)
write.large.seq
Each thread creates its own 64 MiB file and writes it sequentially in 1 MiB chunks (one operation = one chunk; MiB/s is the headline number)
write.overwrite
Each thread opens its own existing 16 MiB file read/write without truncation (VFS: OVERLAPPED; stdio: r+b; posix/win32: O_RDWR / OPEN_EXISTING) once, then writes 4 KiB at random 4 KiB-aligned offsets
write.append
Each thread repeatedly opens its own log file in append mode, appends 16 x 4 KiB, and closes it (one operation = one 4 KiB append; open/close is amortised over the batch)
The three handle-based write scenarios run on base:stdio, base:posix / base:win32, and the VFS write targets; base:fstream and the read-only archive targets do not take part.
Targets
Target
Description
base:stdio
fopen / fread / fseeko; the baseline for ratios. meta.stat/miss/list use std::filesystem (status / exists / directory_iterator)
base:fstream
std::ifstream / std::ofstream
base:posix / base:win32
POSIX open/read/pread; on Windows, CreateFileW/ReadFile (OVERLAPPED offsets)
vfs:native
NativeBackend mounted alone
vfs:memory
MemoryBackend, with all data preloaded
vfs:archive / -xor / -lz4
Three archives of the same dataset: plaintext, fully XOR-encrypted, and fully LZ4-compressed (44 MiB, about 3.5:1, 64 KiB blocks)
vfs:archive+native
Archive below + an empty native override directory above (the development "native files override the archive" layout), same mount point
vfs:memory+archive
Archive below + an empty writable memory layer above
vfs:3layer
Three layers: archive + native override + memory (WriteTarget)
vfs:nested8
The data directory is mounted 8 levels deep, with an empty memory mount on every ancestor (lookup crosses 9 layers), plus 32 unrelated mounts
Write scenarios use the three baselines, vfs:native, vfs:memory, and vfs:memory+archive (writes land in the memory layer; the VFS creates parent directories in the write layer).
Metrics
ops/s: operations / wall-clock time; MiB/s: bytes read or written / wall-clock time (- for metadata scenarios).
p50 / p99: per-operation latency in microseconds after merging all threads.
vs stdio: throughput relative to base:stdio for the same scenario and thread count. > 1 means faster than stdio.
Warm cache and cold cache
The default is a warm cache: one warmup run first, so the data is in the page cache. What is measured is the cost of the API and the VFS itself (system calls, lookup, copies, decryption, decompression). This is closer to a game repeatedly reading the same set of assets.
--cold drops data files from the page cache before each iteration, measuring a first load. Results depend mainly on the storage device and readahead. Directory-entry / inode caches are not cleared (that needs root), so meta.* scenarios change little under a cold cache. --cold has no effect on macOS / Windows (a reboot, or purge on macOS, can stand in).
VM disks usually have another host cache, so cold-cache numbers are more optimistic than a physical SSD/HDD.
Throughput is in ops/s, higher is better; the number in parentheses is the multiple of base:stdio at the same thread count. > 1 means faster than calling the C file API directly.
Numbers on the shared VM (Linux) fluctuate by about ±20%. Compare orders of magnitude and trends.
Disk: built-in UFS flash, f2fs (working directory /data/local/tmp, run from adb shell)
Note: a command-line process started from adb shell gets no foreground-app CPU boost, so the scheduler often keeps it on slower cores at lower clocks. In-memory paths (memory, archive index) are therefore far below what the same SoC reaches in a foreground app, and multi-thread results vary more than on desktop systems
Scenario
Threads
base:stdio
native
memory
archive
archive-lz4
archive+native
3layer
read.small
1
96.0k
110.6k (1.15)
326.3k (3.40)
144.7k (1.51)
105.1k (1.09)
109.6k (1.14)
121.3k (1.26)
read.small
8
679.4k
594.7k (0.88)
753.0k (1.11)
799.4k (1.18)
462.1k (0.68)
550.5k (0.81)
532.7k (0.78)
read.large.rand
1
275.7k
324.0k (1.18)
1.49M (5.39)
574.7k (2.08)
41.4k (0.15)
459.6k (1.67)
589.7k (2.14)
read.large.rand
8
1.97M
1.44M (0.73)
2.43M (1.24)
2.34M (1.19)
172.0k (0.09)
2.15M (1.09)
2.35M (1.20)
meta.open
1
240.7k
192.8k (0.80)
908.9k (3.78)
1.36M (5.63)
1.27M (5.28)
357.0k (1.48)
271.2k (1.13)
meta.open
8
1.03M
710.7k (0.69)
769.7k (0.75)
795.1k (0.77)
802.2k (0.78)
575.4k (0.56)
494.3k (0.48)
meta.stat
1
97.8k
135.1k (1.38)
270.2k (2.76)
358.3k (3.66)
353.8k (3.62)
152.9k (1.56)
151.6k (1.55)
meta.stat
8
1.03M
872.2k (0.85)
1.19M (1.15)
1.26M (1.22)
1.23M (1.19)
730.6k (0.71)
643.1k (0.62)
meta.miss
1
533.6k
160.9k (0.30)
261.3k (0.49)
280.0k (0.52)
272.0k (0.51)
144.5k (0.27)
129.0k (0.24)
meta.miss
8
1.78M
565.3k (0.32)
711.1k (0.40)
734.1k (0.41)
725.2k (0.41)
512.7k (0.29)
444.8k (0.25)
write.churn
1
18.4k
11.3k (0.61)
86.4k (4.70)
-
-
-
-
write.churn
8
99.1k
82.3k (0.83)
302.5k (3.05)
-
-
-
-
write.large.seq
1
930
1.3k (1.40)
922 (0.99)
-
-
-
-
write.large.seq
8
9.4k
7.7k (0.82)
4.6k (0.49)
-
-
-
-
write.overwrite
1
99.8k
104.4k (1.05)
724.5k (7.26)
-
-
-
-
write.overwrite
8
776.3k
847.6k (1.09)
2.04M (2.63)
-
-
-
-
write.append
1
72.8k
71.8k (0.99)
80.3k (1.10)
-
-
-
-
write.append
8
570.8k
570.9k (1.00)
738.8k (1.29)
-
-
-
-
Android: assets inside the APK
The asset backends need an AAssetManager, so they run in a separate NativeActivity APK (bench/android, same benchmark core, same dataset; started with am start, screen on). base:stdio and vfs:archive read copies extracted to internal storage; base:aasset calls AAssetManager directly; vfs:asset is AndroidAssetBackend without a manifest, vfs:asset+lst with one; vfs:archive-apk reads plain.vfs in place from the APK (stored uncompressed). Ratios are against base:stdio of the same APK run; full tables in bench/android.md. Loose files stored uncompressed:
Scenario
Threads
base:stdio
base:aasset
asset
asset+lst
archive-apk
archive
read.small
1
135.4k
110.6k (0.82)
74.2k (0.55)
78.8k (0.58)
464.6k (3.43)
558.8k (4.13)
read.small
8
646.8k
72.5k (0.11)
49.9k (0.077)
49.8k (0.077)
1.93M (2.99)
1.90M (2.94)
read.medium
1
25.0k
24.0k (0.96)
14.9k (0.60)
10.8k (0.43)
15.4k (0.62)
31.6k (1.26)
read.medium
8
83.0k
33.2k (0.40)
35.4k (0.43)
36.5k (0.44)
73.3k (0.88)
97.6k (1.18)
read.large.seq
1
6.5k
8.6k (1.34)
4.2k (0.65)
4.3k (0.66)
4.0k (0.61)
8.7k (1.35)
read.large.seq
8
24.2k
17.9k (0.74)
18.5k (0.76)
18.5k (0.76)
21.2k (0.88)
25.0k (1.04)
read.large.rand
1
459.8k
625.8k (1.36)
565.4k (1.23)
372.3k (0.81)
553.4k (1.20)
635.3k (1.38)
read.large.rand
8
1.56M
1.71M (1.09)
1.80M (1.15)
1.76M (1.12)
1.90M (1.22)
2.08M (1.33)
meta.open
1
154.5k
191.1k (1.24)
108.3k (0.70)
107.0k (0.69)
1.46M (9.45)
1.41M (9.13)
meta.open
8
989.6k
54.6k (0.055)
39.6k (0.04)
42.2k (0.043)
3.93M (3.97)
3.83M (3.87)
meta.stat
1
141.2k
140.2k (0.99)
4.0k (0.029)
137.9k (0.98)
1.09M (7.70)
1.51M (10.69)
meta.stat
8
1.03M
61.6k (0.059)
2.5k (0.0024)
92.8k (0.09)
6.73M (6.50)
6.75M (6.52)
meta.miss
1
419.7k
863.2k (2.06)
4.2k (0.01)
982.8k (2.34)
706.7k (1.68)
1.01M (2.41)
meta.miss
8
2.17M
405.5k (0.19)
2.6k (0.0012)
4.41M (2.03)
4.64M (2.14)
4.68M (2.16)
meta.list
1
161
0.7 (0.0044)
0.3 (0.0019)
311 (1.94)
363 (2.26)
171 (1.06)
meta.list
8
1.2k
0.6 (0.00049)
0.3 (0.00024)
1.1k (0.89)
1.1k (0.91)
1.1k (0.87)
With the loose files deflated in the APK (COMPRESS_DATA=1; plain.vfs stays stored):
Scenario
Threads
base:stdio
base:aasset
asset
asset+lst
archive-apk
archive
read.small
1
132.5k
43.6k (0.33)
42.5k (0.32)
37.9k (0.29)
434.3k (3.28)
379.7k (2.87)
read.small
8
575.0k
42.2k (0.073)
43.3k (0.075)
43.1k (0.075)
1.98M (3.44)
1.60M (2.78)
read.medium
1
19.1k
1.9k (0.099)
1.6k (0.084)
1.5k (0.079)
14.5k (0.76)
20.5k (1.07)
read.medium
8
62.4k
8.9k (0.14)
8.6k (0.14)
8.7k (0.14)
77.1k (1.24)
67.2k (1.08)
read.large.seq
1
4.5k
484 (0.11)
486 (0.11)
483 (0.11)
7.8k (1.73)
5.4k (1.20)
read.large.seq
8
16.9k
2.2k (0.13)
2.2k (0.13)
2.2k (0.13)
18.5k (1.09)
18.6k (1.10)
read.large.rand
1
308.3k
-
-
-
342.3k (1.11)
465.7k (1.51)
read.large.rand
8
1.18M
-
-
-
1.87M (1.58)
1.83M (1.55)
meta.open
1
144.8k
139.1k (0.96)
119.6k (0.83)
114.7k (0.79)
779.0k (5.38)
725.2k (5.01)
meta.open
8
935.5k
61.8k (0.066)
44.0k (0.047)
37.6k (0.04)
3.52M (3.76)
3.60M (3.85)
meta.stat
1
154.4k
108.2k (0.70)
3.3k (0.021)
100.1k (0.65)
823.5k (5.33)
1.34M (8.68)
meta.stat
8
1.12M
57.9k (0.052)
2.0k (0.0018)
34.6k (0.031)
4.98M (4.45)
5.98M (5.34)
meta.miss
1
270.5k
739.0k (2.73)
3.5k (0.013)
982.8k (3.63)
1.04M (3.84)
946.0k (3.50)
meta.miss
8
1.92M
253.1k (0.13)
1.9k (0.00099)
3.83M (1.99)
4.13M (2.15)
4.49M (2.34)
WebAssembly
Host: the Linux box above (Intel Xeon KVM, 8 vCPU), node v24.21.0
Note: base and native calls go through Emscripten's syscall layer into node's fs API, so system calls cost far more than on native Linux while in-memory paths run as compiled code. Ratios against stdio are therefore much larger than native Linux for 1-thread metadata, and smaller at 8 threads, where proxied file system calls serialize. Details: WebAssembly
Scenario
Threads
base:stdio
native
memory
archive
archive-lz4
archive+native
3layer
read.small
1
14.6k
11.1k (0.76)
803.0k (55.00)
56.6k (3.88)
29.7k (2.03)
24.8k (1.70)
24.4k (1.67)
read.small
8
62.0k
49.5k (0.80)
139.4k (2.25)
141.6k (2.28)
77.5k (1.25)
86.9k (1.40)
76.3k (1.23)
read.large.rand
1
32.8k
33.6k (1.02)
1.39M (42.38)
61.4k (1.87)
27.0k (0.82)
61.1k (1.86)
63.0k (1.92)
read.large.rand
8
135.7k
138.6k (1.02)
1.34M (9.87)
283.4k (2.09)
172.1k (1.27)
296.3k (2.18)
303.8k (2.24)
meta.open
1
28.4k
18.7k (0.66)
1.04M (36.62)
1.27M (44.72)
1.33M (46.83)
42.2k (1.49)
42.7k (1.50)
meta.open
8
114.9k
73.6k (0.64)
143.5k (1.25)
151.0k (1.31)
143.8k (1.25)
110.7k (0.96)
109.4k (0.95)
meta.stat
1
25.8k
51.2k (1.98)
1.07M (41.47)
1.50M (58.14)
1.54M (59.69)
41.5k (1.61)
41.1k (1.59)
meta.stat
8
96.8k
151.9k (1.57)
182.4k (1.88)
196.9k (2.03)
195.6k (2.02)
113.1k (1.17)
114.6k (1.18)
meta.miss
1
42.9k
41.6k (0.97)
1.15M (26.81)
1.17M (27.27)
1.18M (27.51)
42.9k (1.00)
42.5k (0.99)
meta.miss
8
112.0k
108.2k (0.97)
156.0k (1.39)
164.1k (1.47)
161.6k (1.44)
111.9k (1.00)
111.0k (0.99)
write.churn
1
6.7k
2.8k (0.42)
208.6k (31.13)
-
-
-
-
write.churn
8
19.8k
8.5k (0.43)
32.6k (1.65)
-
-
-
-
write.large.seq
1
1.7k
2.1k (1.24)
3.9k (2.29)
-
-
-
-
write.large.seq
8
1.7k
2.2k (1.29)
15.7k (9.24)
-
-
-
-
write.overwrite
1
21.0k
21.7k (1.03)
693.3k (33.01)
-
-
-
-
write.overwrite
8
82.3k
83.3k (1.01)
4.20M (51.03)
-
-
-
-
write.append
1
32.4k
30.2k (0.93)
833.3k (25.72)
-
-
-
-
write.append
8
111.5k
103.3k (0.93)
1.38M (12.38)
-
-
-
-
Choosing backends and combinations
Use
Recommendation
Basis (tables above)
Shipping read-only assets
Plaintext or XOR archive (vfs:archive)
Open, stat, and miss queries are served from the in-memory index. Versus stdio: small-file reads are 2.2-2.5x on Linux, 10-18x on macOS, 4.4-7.3x on Windows; meta.open is 3.1-4.3x, 29-46x, and 21-26x respectively
Compression needed
Compress small text such as scripts and config (--compress "*.lua"); keep large binary assets uncompressed
LZ4 entries are stored in blocks (64 KiB by default) and opening does no decompression, but each read decodes the whole block it touches: sequential reads of a 64 MiB LZ4 file are 0.22-0.58x of stdio on native platforms, and 1-thread 4 KiB random reads 0.06-0.21x. A smaller --block-size reduces the work per random read
Native override of the archive during development
archive + native, mounted only in development builds
Every lookup checks the native directory first: miss queries (meta.miss) are 0.27-1.76x; files that hit the archive read at 0.81-8.9x. Do not mount an empty native directory in a shipping build
Runtime-generated / temporary data
Memory backend (vfs:memory), optionally stacked on an archive as a writable layer
No system calls: write.churn is 1.7-449x; large sequential writes 0.34-9.2x; an 8-thread miss query on Linux is about 0.6x
Android assets in the APK
A .vfs archive stored uncompressed in the APK (noCompress += "vfs"), mounted through AssetBackend::archiveSource; loose assets only with an assets.lst manifest
APK run, vs stdio on extracted files: vfs:archive-apk small reads 3.4x / 3.0x (1 / 8 threads), stat 7.7x / 6.5x. Through AAssetManager (raw or vfs:asset) small reads are 0.55-0.82x / 0.08-0.11x, deflated 0.3x / 0.07x. Without the manifest stat is 0.03x / 0.002x, misses 0.01x / 0.001x and a 10000-file listing takes about 3 s; with it stat is 0.98x, misses 2.3x / 2.0x and listing 1.9x / 0.9x
Data that must hit disk, such as saves and logs
Native directory (vfs:native), its own mount point
A single layer is close to a direct call: small reads 0.76-1.29x, opens 0.64-1.15x, stat 0.85-2.0x, in-place overwrites 0.87-1.13x, appends 0.71-1.26x, create + delete 0.42-1.13x (lowest under WebAssembly)
Stacking layers
As few layers as possible; do not stack needless empty layers on the same path
Lookup walks layers top-down. nested8 (9 layers) opens are 0.26-1.25x and misses 0.18-1.53x
Other:
Directory listing (meta.list): archive and memory backends are about 1.0-21x; the native backend must merge, deduplicate, sort, and convert names, about 0.55-1.04x.
Threads: reads and metadata queries are concurrent on every backend, and mount-table lookup takes no global lock; each archive thread reads by offset on its own handle.
Write scenarios settle the disk first: before each timed write iteration (outside the timing) every file in the write area and the generated dataset is flushed (FlushFileBuffers / fsync) and the previous iteration's files are removed, so no target starts behind another target's dirty-page backlog. Under WebAssembly the flush is a no-op. The write rows on every platform come from a --filter write run with this step.
vfs:memory large sequential writes (0.34-0.56x on Linux and Windows; 3.3-5.3x on macOS) are bound by page faults on freshly allocated memory and by copying when the buffer doubles; file data is kept contiguous.