disk-speed
v0.0.2
Published
Sequential disk read/write throughput for Node, with a Blackmagic-style terminal dashboard
Downloads
274
Readme
disk-speed
Sequential disk read/write throughput for Node, with a Blackmagic-style terminal dashboard. Usable as a library or as a CLI.
write read
Blackmagic Disk Speed 2635.6 3083.3
disk-speed 2865.1 3083.4Like Blackmagic Disk Speed Test, it alternates write and read on a loop rather
than measuring once. A drive's figures drift as it warms up and its write cache
fills, so one round is a snapshot. Rounds idle for five seconds between cycles
(--rest) instead of hammering the drive back to back.
The loop stops after 10 rounds, always. Blackmagic runs until you close it; this does not, so a dashboard left open overnight cannot quietly wear the drive down. Ten rounds at the default 2 GiB writes 20 GiB — negligible against a modern SSD's endurance rating, which an unbounded loop is not. The ceiling is enforced in the engine, so the library is capped too.
It ends with a report of the aggregate and of every round, so the drift is visible at a glance:
╭───────┬───────────┬──────┬──────┬──────┬───────╮
│ │ sustained │ p50 │ min │ max │ total │
├───────┼───────────┼──────┼──────┼──────┼───────┤
│ write │ 2328.4 │ 8790 │ 765 │ 9633 │ 0.35s │
│ read │ 4763.7 │ 4872 │ 1534 │ 5235 │ 0.17s │
╰───────┴───────────┴──────┴──────┴──────┴───────╯
MB/s
sustained MB/s per round
╭───────┬──────┬──────┬──────╮
│ │ r1 │ r2 │ r3 │
├───────┼──────┼──────┼──────┤
│ write │ 2324 │ 2418 │ 2250 │
│ read │ 4797 │ 4664 │ 4833 │
╰───────┴──────┴──────┴──────╯Why this needs a native call
A disk benchmark written in plain Node measures the wrong thing. The file it just wrote is still resident in the OS page cache, so reading it back reports memory bandwidth:
naive Node, cache on : write 3183 MB/s read 19004 MB/s <- RAM, not disk
with F_NOCACHE : write 2970 MB/s read 3215 MB/smacOS has no O_DIRECT, purge needs root, and evicting the cache by writing
past its size is impractical (a 60 GiB write probe had not finished after four
and a half minutes). The only workable escape is fcntl(fd, F_NOCACHE, 1) —
one syscall, which node:fs does not expose. Hence koffi.
This is not a language limitation. C and Rust hit exactly the same page cache;
they just get fcntl for free because it lives in libc. Copying the file and
reading the copy — a common workaround — does not help either: the copy is
written through the same cache and reads back at 6531 MB/s.
Note that fcntl is variadic. On arm64 variadic arguments are passed on the
stack rather than in registers, so declaring it with fixed arity fails silently:
the call returns 0 as though it succeeded while caching stays on.
Install
npx disk-speed # run it once, without installing
npm install -g disk-speed # install the `disk-speed` command
npm install disk-speed # as a librarykoffi is the only runtime dependency — everything else (citty, solid-js,
@uniview/tui-*) is bundled into dist, so an install pulls three packages and
about 4 MB rather than a dependency tree. koffi has to stay external because a
.node binary cannot be inlined by a bundler.
CLI
disk-speed # test the current directory
disk-speed /Volumes/SSD # test somewhere else
disk-speed -s 8g -b 1m # 8 GiB in 1 MiB blocks
disk-speed --plain # no TUI
disk-speed --json > out.json # machine-readable| Flag | Meaning |
| --- | --- |
| -s, --size | Bytes per pass, e.g. 2g, 512m. Default 2g. |
| -b, --block | Bytes per I/O call. Default 4m. |
| -d, --depth | Requests in flight. 1 matches Blackmagic's sequential figure. |
| -r, --rounds | Write/read cycles, 1–10. Default 10 in the dashboard, 1 otherwise. |
| --rest | Seconds idled between rounds, so a long run does not sit on the drive. Default 5, 0 to disable. |
| --write / --read | Run only one pass. |
| --no-cache-bypass | Leave the page cache on, to see how wrong that is. |
| --plain / --json | Text or JSON instead of the dashboard. |
| --samples | Include per-block samples in --json. |
q quits; Ctrl-C interrupts and exits 130 — including mid-rest, which aborts
immediately rather than waiting the interval out. The scratch file is removed on
every exit path that the process can observe: normal exit, q, SIGINT, SIGTERM
and SIGHUP. SIGKILL cannot be caught, so kill -9 (or a power cut) will leave
the scratch file behind.
Without the dashboard
The TUI is skipped automatically whenever stdout is not a terminal, so piping or
redirecting just works. --plain forces text even on a terminal, and --json
implies it.
The report goes to stdout, progress and headers to stderr, so both of these are clean. Colour is emitted only when stdout is a terminal, so a redirected report stays plain text:
disk-speed --plain > report.txt # just the summary tables
disk-speed --json | jq .read.mbps # nothing but JSON on stdoutLibrary
import { runDiskSpeedTest } from "disk-speed";
const result = await runDiskSpeedTest(
{ dir: process.cwd(), fileSize: 2 * 1024 ** 3 },
{ onSample: (s) => console.log(s.phase, s.averageMbps.toFixed(0)) },
);
console.log(result.write?.mbps, result.read?.mbps);Check result.cacheBypassed. When it is false the platform could not keep the
page cache out of the way and the read figure is memory bandwidth.
The engine is async on purpose. A blocking writeSync/readSync loop measured
2958 / 3193 MB/s while starving the event loop completely; the async form
measured 2928 / 3156 — inside run-to-run noise — and leaves the loop free to
repaint a TUI and answer Ctrl-C.
Reading the numbers
The headline is sustained throughput: total bytes over total seconds, with the closing flush counted. It is the only figure buffering cannot inflate, and it is the same quantity Blackmagic Disk Speed Test reports.
write 2994.0 MB/s p50 4400 · min 20 · max 9752- p50 / min / max — per-block quantiles. Useful on the read side, where they
are stable. On the write side, expect them to disagree loudly with the
headline and to swing between rounds.
F_NOCACHEkeeps the OS page cache out of the way, but the drive's own write cache still absorbs individual blocks and defers the real cost to the closingF_FULLFSYNC— which only the sustained figure accounts for. Three 4 GiB write rounds on one SSD measured a median of 5189 → 6131 → 2377 MB/s against a steady sustained 3727 / 3737 / 2146. - min — nearly always the first block of a pass.
- max — the luckiest single block. An upper bound, not a result. The dial's full scale comes from the 95th percentile instead, so one outlier cannot flatten the needle.
Watch the figures move across rounds; that drift is the point. In the run above, write dropped from ~3730 to 2146 MB/s on the third round as the drive's SLC cache filled.
Sizing the test
Pick a --size well past the drive's SLC write cache, or the write figure
reports burst rather than sustained speed. 2 GiB is the default; external SSDs
often need more before the number settles.
--depth 1 is the sequential figure. Raising it measures something real but
different — the same drive read 3156 MB/s at depth 1 and 3661 MB/s at depth 8.
Platform support
| | Mechanism | Status |
| --- | --- | --- |
| macOS | fcntl(F_NOCACHE), F_FULLFSYNC | verified against Blackmagic |
| Linux | posix_fadvise(POSIX_FADV_DONTNEED) | implemented, untested |
| Windows | needs FILE_FLAG_NO_BUFFERING | not implemented; reports cacheBypassed: false |
Windows would require CreateFileW, which yields a HANDLE rather than an fd, so
the whole I/O path would have to move to FFI.
koffi ships one prebuilt binary per platform as optional dependencies, so a
consumer installs a single package and their package manager picks the matching
1.2 MB native module. Note that a .node module cannot be inlined by a bundler,
so this does not build into a single-file executable — and it is why koffi is
the one dependency that cannot be bundled away.
Development
nub install
nub run dev # build, then run
nub run dev /tmp --plain -s 1g # arguments pass straight through
nub run build # dist/index.js (library) + dist/cli.js (bin) + types
nub run check-types
nub run snapshot # render the dashboard to text without a terminal
nub run snapshot:svg # regenerate docs/dashboard.svgsnapshot paints the real component tree onto a MemoryCellSurface (or
SvgCellSurface), so the dashboard above is the actual layout and palette
rather than a screenshot.
Colours must be RgbColor objects, never #hex strings: the ANSI surface
resolves named tokens and rgb triples but silently drops hex, so
color="#3fc7f4" emits SGR 0 and the whole UI renders monochrome. See
src/cli/theme.ts.
Pass arguments to dev without a -- separator. Unlike npm, nub run
forwards -- literally, and citty reads it as end-of-options — so
nub run dev -- /tmp -s 1g silently drops the -s instead of applying it.
The TUI is Solid, via @uniview/tui-solid. Solid's fine-grained reactivity
comes from a compile-time transform, so the build runs through Vite with the
univiewSolid() plugin rather than plain tsc. dev therefore builds first —
running the sources directly under nub or tsx fails with
does not provide an export named 'jsx', because esbuild cannot lower Solid's
JSX for the terminal renderer.
Every dependency except koffi is inlined by ssr.noExternal, so they live in
devDependencies. This is not only an install-size choice: solid-js must
be bundled. A solid-js resolved at runtime under Node picks its SSR build,
whose signals are inert stubs — writes appear to work, no effect ever re-runs,
and the dashboard renders exactly one frame and then freezes.
Declarations are emitted only for the library entry
(tsconfig.build.json). A .d.ts for the CLI would import
types from solid-js and @uniview/tui-core, which are bundled into dist and
therefore absent from a consumer's node_modules.
