Skip to content

feat: real-world benchmark suite comparing driven with rclone - #178

Merged
pmaxhogan merged 5 commits into
mainfrom
feat/bench-suite
Jul 25, 2026
Merged

pmaxhogan merged 5 commits into
mainfrom
feat/bench-suite

Conversation

@pmaxhogan

@pmaxhogan pmaxhogan commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Adds a benchmark suite that measures Driven's real backup engine against
rclone on a live Google Drive account, across the two workloads that dominate
real backup sets and both a cold upload and an incremental re-run.

The suite exists to answer one question honestly: is Driven constrained by
hardware (CPU, disk, network, the Drive API) or by its own algorithms? A
competitor doing roughly the same work is the cheapest way to tell those apart,
and it catches regressions a synthetic microbenchmark never would.

One deliberate deviation from the brief, up front

The brief said to drive driven-cli sync. I did not, because reading it
showed it is a debug driver, not the engine: it walks only the top level of the
source folder (crates/driven-cli/src/main.rs:394 - "V1 debug driver: top-level
files only (no recursion)"), reads whole files into memory, keeps no state
database, and uploads sequentially. Pointed at the tiny-deep fixture it would
have uploaded zero files, and on multi-gigabyte files it would exhaust memory.
Benchmarking it would have measured a debugging tool and published the number as
Driven's.

Instead the harness assembles the same stack src-tauri/src/assembly.rs does -
SqliteStateRepo -> DefaultExecutor (adaptive upload pool, AIMD pacer) ->
SyncOrchestrator - and calls run_cycle against a live GoogleDriveStore, so
the real scan -> plan -> execute -> verify pipeline is what gets timed. This is
new wiring: nothing in the repo previously ran the headless core against real
Drive.

What's here

  • crates/driven-bench - a single driven-bench binary with three
    subcommands: run (the matrix), fixture (build/clean trees without
    uploading), and a hidden agent-sync that runs one engine cycle and prints
    a JSON metrics line.
  • bench/README.md - prerequisites, scales, costs, safety rails, and an
    explicit "what is and is not apples-to-apples" section.
  • bench/run.ps1 and just bench / just bench-fixture /
    just bench-fixture-clean.
  • .github/workflows/bench.yml - workflow_dispatch (scale, tools) plus
    v* tag pushes at the smoke scale. Never on pull_request, never on a plain
    push. Time-boxed at 180 minutes; clean skip when secrets are absent.

Design choices worth reviewing

Both tools run as child processes. Driven's engine could have run in-process,
but then its CPU-time and peak-memory columns would silently include fixture
generation and the harness's own Drive calls, and would not be comparable to
rclone's. The harness re-invokes itself with agent-sync, so both tools are
measured by identical OS accounting (GetProcessTimes / GetProcessMemoryInfo
on Windows, getrusage(RUSAGE_CHILDREN) on Unix).

API-call counts come from a RemoteStore decorator, the same seam the
executor already uses for BreakerReportingStore - no core change. rclone
exposes no request counter, so that cell renders as -, meaning "not
measurable", never 0.

rclone auth needs no token-minting request. Its config carries a non-empty
placeholder access token that is already expired, and rclone refreshes it from
the same refresh token Driven uses. (An empty access_token makes rclone treat
the whole token as unparseable and report "there's no refresh token" - verified
empirically before building anything on it.)

The report separates scan time from upload time. A total cannot answer the
question the suite exists for - on the million-tiny-files shape, is a slow cold
pass bound by the local walk and hashing, or by Drive round-trips? The agent
consumes the orchestrator's event stream while the cycle runs (a timestamp
cannot be recovered from a buffered event, and this also stops losing events to
broadcast lag) and reports the boundary. rclone interleaves listing with
transferring, so its cell stays blank rather than invented.

restic was considered and deliberately excluded. It stores a chunked,
deduplicating repository rather than a mirror, so its "upload" is a different
operation, and on a re-run its deduplication would flatter it on exactly the
workload this suite measures. Adding it would produce a bigger table, not a more
honest one. The rationale is in the README, not just here.

Fixtures are seeded and incompressible (SplitMix64), so both tools see
byte-identical input and no tool scores on test-data entropy it would never see
on photos and archives. The mutation step changes content only - no creates, no
deletes - which keeps rclone copy a fair match rather than requiring the
riskier rclone sync.

Safety rails

  • The destination folder id must be explicit (--dest or
    DRIVEN_E2E_DEST_FOLDER_ID); no default, no discovery. Checked before a byte
    is generated.
  • All writes go under one driven-bench-<uuid> folder, with a subfolder per
    scenario. Cleanup trashes that folder by the id it was created with - the
    suite never lists the destination and never matches by name, so it cannot touch
    anything it did not create. A cleanup failure prints the id to trash by hand.
  • Uploads are capped at 2 GiB by default; exceeding it is an error telling you to
    pass --full or lower --scale.
  • Credentials are read from the environment only (never the keychain) and never
    printed.

Smoke run (real Drive, dedicated automation account)

Release build, Windows, 20 logical CPUs, rclone v1.74.4. Both tools, both shapes,
both phases; run folder trashed afterwards.

huge - 2 files, 16.0 MiB

Tool Phase Wall s MiB/s Files API calls CPU s Peak RSS Conc
driven cold 3.4 4.72 2 6 0.6 49.8 MiB 16
driven incremental 2.4 3.37 1 4 0.6 37.3 MiB 16
rclone cold 4.4 3.63 2 - 0.8 83.3 MiB 4
rclone incremental 1.8 4.48 1 - 0.4 67.0 MiB 4

tiny-deep - 300 files, 591.7 KiB, nested 5 deep

Tool Phase Wall s files/s Files API calls CPU s Peak RSS Conc
driven cold 78.6 3.8 300 489 2.0 28.4 MiB 16
driven incremental 2.4 0.4 1 7 0.4 26.1 MiB 16
rclone cold 152.5 2.0 300 - 1.3 59.9 MiB 4
rclone incremental 10.6 0.1 1 - 0.6 56.6 MiB 4

Reading it: the incremental rows are the point. Driven re-detects a single
changed file in 2.4 s and 7 API calls; rclone takes 10.6 s because it
re-lists the remote every time. The state database is doing its job. The
concurrency columns differ because each tool runs at its stock settings -
--rclone-transfers equalises them if you want to isolate the algorithms.

These numbers are a pipeline proof, not a verdict: 300 files is far too small to
conclude anything from, and cross-host comparisons are meaningless. Run
just bench (~610 MiB per tool) for numbers worth quoting.

Gates

  • cargo fmt --all -- --check - clean
  • cargo clippy --workspace --all-targets -- -D warnings - clean (exit 0)
  • cargo test --workspace - clean (exit 0); every crate green, including driven-bench's 49 tests
  • driven-bench ships 49 unit tests: fixture determinism and depth, mutate /
    restore round-trips, crash-left-mutated recovery, rclone stats parsing, the
    metrics marker line, upload-cap arithmetic, dest-id refusal, dotenv precedence,
    and report rendering (including that an unmeasurable cell is a dash, not a
    zero).

Notes for the reviewer

  • The coverage gate auto-includes any new crate via --workspace --exclude, so
    driven-bench is added to the exclusion list alongside src-tauri and
    driven-chaos in coverage.yml, scripts/coverage.sh and the just coverage
    recipe. A harness that mostly spawns processes and needs live credentials would
    otherwise drag the gate down for no signal.
  • cargo test --workspace does build and test the new crate. Its tests are fast
    and need no credentials or network; the credential-dependent paths simply are
    not exercised there.
  • Nothing here runs on PR CI or dev-branch builds, by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X

Adds crates/driven-bench: a harness that measures Driven's REAL backup
engine (SqliteStateRepo -> DefaultExecutor -> SyncOrchestrator against a
live GoogleDriveStore, the same stack src-tauri/assembly.rs builds)
against rclone, on a huge-files fixture and a million-tiny-files fixture,
across a cold upload and an incremental re-run after a 0.1% mutation.

Deliberately NOT built on `driven-cli sync`: that subcommand is a debug
driver that walks only the top level of a folder and keeps no state, so
the deep fixture would have uploaded zero files.

Both tools run as child processes so CPU time and peak RSS come from the
same OS accounting. Drive request counts come from a RemoteStore
decorator (the BreakerReportingStore seam) - no core change.

Safety rails: explicit destination folder id required, all writes under
one run-UUID folder, cleanup trashes only that folder by carried id, and
a 2 GiB upload cap unless --full.

Runs on demand (just bench / bench/run.ps1) or from bench.yml on
workflow_dispatch and v* tags - never on PR CI or plain pushes.
driven-bench is excluded from the coverage gate alongside driven-chaos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X
Comment thread crates/driven-bench/src/fixture.rs Fixed
Comment thread crates/driven-bench/src/fixture.rs Fixed
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Coverage

Area main this PR delta
Rust (lib crates) 80.94% 80.94% +0.00 (OK)
UI (vue/ts) 91.40% 91.40% +0.00 (OK)

Gate: passed - no coverage regression (epsilon 0.1 pp).

pmaxhogan and others added 4 commits July 25, 2026 17:44
CodeQL flagged rust/path-injection on the recursive walk in the fixture
tests: it discovers paths from the filesystem and then stats and reads
them. Take the directory type from the DirEntry instead of re-statting a
reconstructed path, and canonicalise every discovered path against the
fixture root before touching it, so a symlink or a `..` component cannot
lead the helper outside the tree it is meant to verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X
CodeQL's rust/path-injection still flagged the fixture test helper: it
discovered paths by walking the tree and then read them, and a
canonicalise-and-contain guard is not recognised as a sanitiser.

Split the helper in two, which is the better test anyway. `read_expected`
reads each file at the path the SPEC predicts, so it asserts the thing
the fixture actually promises - that the layout is a pure function of the
spec - and fails loudly on a missing file, where a walk would have passed
even if `file_path` and the writer drifted together. `count_files` walks
without reading anything, purely to catch a leftover file from an earlier
spec, and now asserts that explicitly in the rebuild test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X
The backlog item asked for scan time and the suite only reported a
total, which cannot answer the question the suite exists for: on the
million-tiny-files shape, is a slow cold pass bound by the local walk and
hashing, or by Drive round-trips? A total says "slow"; the split says
which half to go fix.

The agent now consumes the orchestrator's event stream WHILE the cycle
runs rather than draining it afterwards - a timestamp cannot be recovered
from a buffered event, and on a large run the earlier approach also lost
events to broadcast lag before they were ever read. The first planning
event fixes the scan boundary; a later one cannot move it. A cycle that
ends before the planner reports leaves the column blank rather than
claiming a zero-second scan.

rclone interleaves listing with transferring and exposes no equivalent
boundary, so its cell stays blank instead of being invented.

Verified against real Drive: the cold pass scans in ~0s (everything is
new, nothing to compare against) while the incremental pass spends 0.5s
hashing to find the one changed file - exactly the signal the column is
for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X
A report is written every time anyone runs `just bench`, so the previous
commit swept two of them in by accident. Ignore them by default; a
deliberate durable record can still be kept with `git add -f`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X
@pmaxhogan
pmaxhogan merged commit 85f6d6a into main Jul 25, 2026
19 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Driven Jul 25, 2026
@pmaxhogan
pmaxhogan deleted the feat/bench-suite branch July 25, 2026 23:37
pmaxhogan added a commit that referenced this pull request Jul 26, 2026
Six new rows in "How Driven compares" for what landed since v2.3.0, plus
a
re-verification of every competitor cell I touched against current
upstream
docs. Docs-only; no code or `site-landing/` changes (the landing page
has no
mirror of this table).

## Rows added

| Row | Why |
| --- | --- |
| Re-uploads backup copies deleted at the destination | #171 (audit) +
#168 (live self-heal) |
| Parallel, multi-threaded local scan | #169 |
| OS-level CPU / disk I/O priority for backup work | #170, #173, #176,
#179 |
| Live preview of which files a rule keeps or drops | #172, #177 |
| Rolling local logs plus a one-click diagnostics bundle | #167 |
| Reproducible end-to-end benchmark suite in the repo | #178 |

Also: a `:grey_question:` legend entry ("not documented"), so a
closed-source
client whose behaviour Google or Backblaze simply does not publish is
marked
honestly instead of being guessed at; the intro paragraph now mentions
the
priority work; the Features list gains the parallel scanner, the
preview, the
priority setting, the remote audit, and rolling logs; and `just bench`
is in
the recipe list with a pointer to `bench/README.md`.

## On the benchmark numbers

I deliberately quoted **no** upload throughput from #178. That was a 16
MiB /
300-file smoke run - a pipeline proof, not a verdict - and a headline
MiB/s
from it would not survive scrutiny. The two numbers that did make it in
are
locally measured and honest at their scale: the exclusion-preview
re-classification (536 ms cached vs 868 ms fresh walk on a 63k-entry
tree,
from #177) and the scan thread clamp. The bench suite appears as a row
on its
own merits, with the caveats left in `bench/README.md`.

## Competitor claims and where each was verified

Versions checked: rclone 1.74.4, restic 0.19.1, Duplicati 2.3.0.4,
Backblaze Personal Backup 10.0.2, Drive for desktop 128.0.

**CPU / I/O priority - nobody else has it.** rclone has no priority code
and
closed both requests pointing at `ionice`
(rclone/rclone#864). restic's FAQ answers "How
to
prioritize restic's IO and CPU time" entirely with `ionice`/`nice`
recipes
(https://restic.readthedocs.io/en/stable/faq.html). Duplicati still
accepts
`--thread-priority` but the shipping string is "has no effect, use the
operating system controls to set the process priority"
(`Duplicati/Library/Main/Options.cs` at the `v2.3.0.4_stable_2026-07-09`
tag)
- note its published docs page still lists the old text with no
deprecation
notice, so I cited the source, not the doc. Drive for desktop's entire
preference surface is bandwidth rate limits plus pause
(https://support.google.com/drive/answer/13470231), with no priority key
in
the admin policy list. Backblaze offers an automatic/manual bandwidth
throttle
and an upload-thread count

(https://www.backblaze.com/computer-backup/docs/configure-performance-settings-windows).
Bandwidth limiting is not I/O priority and the note says so.

**Parallel scan.** rclone walks at `--checkers` (8) and transfers at
`--transfers` (4) (https://rclone.org/docs/). Duplicati's
`FileEnumerationProcess` is a single serial task with the concurrency
downstream of it, so its walk is a genuine `:x:` (source at the stable
tag).
restic reads at `--read-concurrency`, default 2
(https://restic.readthedocs.io/en/stable/manual_rest.html). Google and
Backblaze document nothing about scan concurrency - hence the new
"not documented" marker rather than a guessed `:x:`.

**Destination-side deletion.** rclone gets a `:white_check_mark:` here,
not an
`:x:`: it keeps no state, so `copy`/`sync` re-list the destination every
run
and re-transfer anything missing
(https://rclone.org/commands/rclone_sync/).
The note says so plainly, including that this is why it is slower on the
incremental case. Drive for desktop is the one that is worse than absent
-
"any files you put in the trash are put in the trash everywhere"
(https://support.google.com/drive/answer/2375102). Duplicati and restic
detect
damage but recovery is operator-driven (Duplicati's `RepairHandler.cs`
refuses
missing dblock files without `--rebuild-missing-dblock-files`; restic's
troubleshooting doc says re-run `backup` to heal,
https://restic.readthedocs.io/en/stable/077_troubleshooting.html).
Backblaze
documents nothing either way, so it is marked not-documented rather than
`:x:`.

**Live rule preview - and one claim I had to walk back.** Duplicati
shipped
server-evaluated inclusion state in its tree UI in 2.3.0.4 on 2026-07-09
(duplicati/duplicati#6955, closing a
six-year-old
request, duplicati/duplicati#4194). Six weeks
ago
"nobody else has this" would have been true; it is not any more, so
Duplicati
gets a `:white_check_mark:` and the note records the remaining
difference
(it marks the nodes you expand, with no whole-source counts). rclone and
restic are `--dry-run` only.

**Logs and diagnostics.** Driven is not unique here and the row shows
that:
Drive for desktop and Backblaze both get `:white_check_mark:`

(https://knowledge.workspace.google.com/admin/drive/capture-google-drive-for-desktop-logs-for-support,

https://www.backblaze.com/computer-backup/docs/send-logs-to-backblaze-windows).
Duplicati is partial - its "Create bug report" bundle is real, but file
logging is opt-in, warnings-only by default, and unrotated. restic has
no
log-file option at all.

**Benchmark suite.** Negative evidence for all five (repo tree listings
plus
code search); the note names Duplicati's unreleased AutoTune harness and
Backblaze's B2-not-client benchmark rather than pretending there is
nothing
adjacent.

No existing rows were removed and none had become false. The trailing
line
now names the exact versions checked instead of just the month.

## Checks

- 40 notes, sequential, every table marker resolves to one and every
note is
  cited (validated by script).
- All 27 table rows have 7 cells.
- Zero em/en dashes or other dash-like non-ASCII; only the superscript
digits
  the file already used. `git ls-files --eol` reports `w/lf`.
- README is not covered by prettier or any markdown linter in CI
(prettier
  runs against `ui/src` only), so there is no formatting gate to run.
- `deploy-landing.yml`'s `TAGLINE_MARKER` is the README's first line,
which is
  untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pmaxhogan added a commit that referenced this pull request Jul 26, 2026
🤖 I have created a release *beep* *boop*
---


## [2.4.0](v2.3.0...v2.4.0)
(2026-07-26)


### Features

* **cli:** add dump-client-creds to print an account's stored BYO OAuth
client ([#166](#166))
([4b6070b](4b6070b))
* **core:** parallel scan with negation-aware directory pruning
([#169](#169))
([3792e47](3792e47))
* **core:** remote-existence audit heals files whose Drive objects
vanished ([#171](#171))
([5cb8e3a](5cb8e3a))
* **core:** run the scan walk at the configured io_priority
([#173](#173))
([8f39961](8f39961))
* **core:** shape bundle-build file reads with the io_priority setting
([#179](#179))
([af8f048](af8f048))
* **core:** shape upload I/O with the io_priority setting
([#176](#176))
([badd9c9](badd9c9))
* **core:** wire the ioPriority setting to real OS thread priorities
([#170](#170))
([51bb1f3](51bb1f3))
* persist rolling backend logs and capture frontend console into
diagnostics ([#167](#167))
([292e221](292e221))
* real-world benchmark suite comparing driven with rclone
([#178](#178))
([85f6d6a](85f6d6a))
* **ui:** instant exclusion-preview re-evaluation from an in-memory tree
([#177](#177))
([8f49570](8f49570))
* **ui:** sticky shell chrome, indeterminate scan progress and
navigation cleanup
([#163](#163))
([4896336](4896336))
* **ui:** transient in-app toast notifications
([#164](#164))
([0104a8e](0104a8e))
* **ui:** warn when include patterns defeat directory pruning
([#162](#162))
([6d3b4b2](6d3b4b2))


### Bug Fixes

* **core:** self-heal a stale drive_file_id when an update hits a
definitive 404 ([#168](#168))
([8b983b3](8b983b3))


### Performance Improvements

* **core:** per-directory decision cursor for the exclusion preview +
NFC fast path ([#172](#172))
([a587fed](a587fed))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants