|
1 | | -# FUSE mount option benchmarks |
| 1 | +# Store and FUSE mount option benchmarks |
| 2 | + |
| 3 | +## In-memory store versus file-backed store |
| 4 | + |
| 5 | +Numbers from `script/store-compare.mjs`, which |
| 6 | +drives the dofs filesystem directly against both storage backends. |
| 7 | +It deliberately skips FUSE so a difference here is the store and |
| 8 | +nothing else. 2,000 files in one directory, Node 24 on a Linux |
| 9 | +container. |
| 10 | + |
| 11 | +| Store | create 2000 | stat cold | stat warm | readdir x50 | |
| 12 | +|---|---:|---:|---:|---:| |
| 13 | +| memory | 180.9 ms | 1580.4 ms | 12.1 ms | 155.4 ms | |
| 14 | +| file, 64 MiB cache | 659.1 ms (3.64x) | 1558.7 ms (0.99x) | 15.1 ms (1.25x) | 143.1 ms (0.92x) | |
| 15 | +| file, 256 MiB cache | 650.0 ms (3.59x) | 1509.8 ms (0.96x) | 13.8 ms (1.14x) | 146.9 ms (0.95x) | |
| 16 | + |
| 17 | +Two findings, one of which contradicts what we assumed when writing |
| 18 | +the plan. |
| 19 | + |
| 20 | +**Metadata reads do not regress.** Cold `stat` of 2,000 paths is a |
| 21 | +wash (0.96x to 0.99x), and `readdir` is if anything slightly faster |
| 22 | +on the file store. The plan predicted this was where a file-backed |
| 23 | +store would hurt. It does not, because the working set here is about |
| 24 | +1.4 MiB — small enough to sit entirely in SQLite's page cache, so the |
| 25 | +reads never reach the disk. Warm `stat` is 1.14x to 1.25x slower, |
| 26 | +which is the resolve cache doing its job in both cases and the |
| 27 | +remaining difference being page-cache lookup overhead rather than |
| 28 | +input or output. |
| 29 | + |
| 30 | +**Writes are the real cost, and the cause is fsync.** Creating 2,000 |
| 31 | +files is 3.6x slower on the file store. Varying `synchronous` isolates |
| 32 | +it: |
| 33 | + |
| 34 | +| `synchronous` | create 1000 files | |
| 35 | +|---|---:| |
| 36 | +| `full` | 444.6 ms | |
| 37 | +| `normal` | 291.9 ms | |
| 38 | +| `off` | 148.1 ms | |
| 39 | + |
| 40 | +`off` matches the in-memory store, so the gap is entirely the cost of |
| 41 | +flushing to disk. `normal` is the shipped default and already buys |
| 42 | +back a third of `full`. Anything faster trades durability for speed, |
| 43 | +which is defensible here because the durable object is the source of |
| 44 | +truth, but `off` risks a corrupt database on host loss rather than |
| 45 | +merely losing recent transactions, so it stays off the table. |
| 46 | + |
| 47 | +Sweeping the cache budget changes almost nothing. At 6,000 files the |
| 48 | +database is 3.8 MiB; squeezing the cache to 2 MiB, so the working set |
| 49 | +genuinely cannot fit, still leaves cold `stat` at 0.99x: |
| 50 | + |
| 51 | +| Store | create 6000 | stat cold | stat warm | readdir x50 | |
| 52 | +|---|---:|---:|---:|---:| |
| 53 | +| memory | 391.9 ms | 14389.1 ms | 34.4 ms | 437.1 ms | |
| 54 | +| file, 2 MiB cache | 1682.0 ms (4.29x) | 14177.7 ms (0.99x) | 55.4 ms (1.61x) | 474.4 ms (1.09x) | |
| 55 | + |
| 56 | +That is the interesting result. The prediction was that a cache too |
| 57 | +small for the tree would turn every resolve into a `pread` and wreck |
| 58 | +the metadata numbers. It does not, because cold `stat` is dominated by |
| 59 | +the resolve walk itself rather than by fetching pages, and the |
| 60 | +operating system's own page cache absorbs what SQLite evicts. Warm |
| 61 | +`stat` is where the difference shows, and it is 20 microseconds per |
| 62 | +operation on a path that is already cheap. |
| 63 | + |
| 64 | +## Through a real FUSE mount |
| 65 | + |
| 66 | +The numbers above isolate the storage layer. These run the same |
| 67 | +comparison through `script/fs-bench.sh` against a real kernel FUSE |
| 68 | +mount, with `computerd` started on the host (`FUSE_MOUNT=fuse`), and |
| 69 | +`/tmp` as the baseline. REPS=2, WARMUP=1. |
| 70 | + |
| 71 | +| Scenario | memory store | file store | baseline | |
| 72 | +|---|---:|---:|---:| |
| 73 | +| stat 1000 files | 2777.3 ms (1.10x) | 3114.9 ms (1.22x) | ~2540 ms | |
| 74 | +| create 1000 files | 989.4 ms (0.98x) | 1178.7 ms (1.17x) | ~1010 ms | |
| 75 | +| write 64 MiB | 238.1 ms (11.14x) | 221.9 ms (12.69x) | ~19 ms | |
| 76 | +| overwrite 64 MiB | 294.3 ms (26.16x) | 304.6 ms (29.30x) | ~11 ms | |
| 77 | + |
| 78 | +Large-file input and output is unchanged between the two stores, which |
| 79 | +is what the storage-layer numbers predicted: those paths are dominated |
| 80 | +by chunking and the FUSE round trip, so the store barely registers. |
| 81 | +The small-file scenarios cost 10 to 20 percent more on disk. That is a |
| 82 | +real regression, and smaller than the 3.6x the storage-layer create |
| 83 | +number would suggest on its own, because FUSE overhead dilutes it. |
| 84 | + |
| 85 | +## Restore time |
| 86 | + |
| 87 | +What the on-disk store buys, measured by `script/restore-time.mjs`. It |
| 88 | +times the interval a host actually waits: from a healthy daemon to a |
| 89 | +workspace the peer agrees is current, meaning connect, reconcile |
| 90 | +watermarks, and push whatever the peer believes is missing. |
| 91 | + |
| 92 | +| Tree | store | first boot | restart | |
| 93 | +|---|---|---:|---:| |
| 94 | +| 500 files | memory | 454 ms (502 pushed) | 480 ms (502 pushed) | |
| 95 | +| 500 files | file | 451 ms (502 pushed) | **26 ms (0 pushed)** | |
| 96 | +| 3,000 files | memory | 3725 ms (3002 pushed) | 3749 ms (3002 pushed) | |
| 97 | +| 3,000 files | file | 4063 ms (3002 pushed) | **23 ms (0 pushed)** | |
| 98 | + |
| 99 | +An in-memory store re-ships the whole tree on every restart, so its |
| 100 | +restart cost tracks the tree size. A file store ships nothing, because |
| 101 | +the sync cursors came back with the files and the peer can see there |
| 102 | +is no difference to send. The saving is 18x at 500 files and 161x at |
| 103 | +3,000, and it keeps growing: the restore side stays flat at roughly |
| 104 | +25 ms while the memory side climbs with the workspace. |
| 105 | + |
| 106 | +This is the trade in one line. Small-file work costs 10 to 20 percent |
| 107 | +more, and a restart costs a fixed 25 ms instead of a full replay. |
| 108 | + |
| 109 | +Caveats. These run on one Linux container, not on Cloudflare |
| 110 | +Containers hardware. The restore measurement drives the sync protocol |
| 111 | +directly rather than through a real durable object over a real |
| 112 | +network, so it captures the work avoided but not the round-trip |
| 113 | +latency a real host would also save. The full `cloudflare/sandbox-sdk` |
| 114 | +`npm install` comparison has not been run. |
| 115 | + |
| 116 | +## FUSE mount option benchmarks |
2 | 117 |
|
3 | 118 | Numbers from running `script/run-fs-bench.sh` against the linux-x64 |
4 | 119 | `computerd` binary in a privileged docker container, with the bench's pure |
|
0 commit comments