Skip to content

perf(upload): skip suite objects the bucket already holds - #302

Merged
qu0b merged 2 commits into
masterfrom
qu0b/skip-unchanged-suite-objects
Aug 11, 2026
Merged

perf(upload): skip suite objects the bucket already holds#302
qu0b merged 2 commits into
masterfrom
qu0b/skip-unchanged-suite-objects

Conversation

@qu0b

@qu0b qu0b commented Aug 11, 2026

Copy link
Copy Markdown
Member

Follow-up to #301. That PR made the suite upload succeed; this one stops it re-sending the same bytes on every run.

The waste

A suite directory is content-addressed — ComputeSuiteHash digests the pre-run and test step file contents — so a given key under suites/<hash>/ always holds the same bytes. UploadSuiteDir re-sent all of them on every completed run.

For 0d93b5bf3b970403 (jochemnet stateful) that is ~12.4 GB per run: a 9.4 GiB pre-run bundle (pre-run.request, bloatnet setup with BLOATNET_RECEIVER_CONTRACT_COUNT=100000) plus 2.96 GB of fixtures across 2733 files. That suite alone has completed 12.8 runs/day over the last two days, so roughly 158 GB/day of PUTs overwriting byte-identical objects — and every other suite pays the same tax in proportion to its size.

It went unnoticed because the bundle was failing with EntityTooLarge before it could ever be sent a second time. #301 fixed the failure, which turns the waste on.

The change

  • List the destination prefix once and skip anything already there at the same size. One paginated ListObjectsV2 covers 2733 keys in 3 requests, versus a HEAD per file.
  • Where the ETag is a plain MD5 — objects small enough to have gone up in a single part — verify that too. The skip is then content-based, not size-based, for every file except the one whose identity the suite hash already pins.
  • summary.json is never skipped. Metadata labels can change between runs without affecting the suite hash, which is exactly why CreateSuiteOutput rewrites it every time.
  • A listing failure logs a warning and uploads everything, so losing the optimisation can never lose the suite.

On ETags

Worth stating the limit plainly: an ETag is only an MD5 for single-part objects. For a multipart upload it is a digest of the part digests with a -N suffix, and reproducing it means re-reading the whole file with the same part size — the exact I/O this skip avoids, on the exact file that motivated it. So the 9.4 GiB bundle is matched on size alone, which is sound here because its key is content-addressed. Everything else gets the stronger check.

Tests

TestUploadSuiteDirSkipsUnchangedObjects uploads a suite twice against a fake S3 that serves ListObjectsV2 with real sizes and MD5 ETags:

  • first pass sends all three files
  • second pass sends only summary.json
  • rewriting a file to different content of the same length re-sends it — the ETag catches what a size check would not

Verification

make build-core, make lint-core (0 issues), full go test ./pkg/... ./cmd/... all pass locally.

A suite directory is content-addressed: the hash is a digest of its pre-run
and test step file contents, so a given key under suites/<hash>/ always holds
the same bytes. UploadSuiteDir re-sent all of them on every completed run.

For the jochemnet bloatnet suite that is ~12.4 GB a run — a 9.4 GiB pre-run
bundle plus 2.96 GB of fixtures — and that suite alone completes 12.8 runs a
day, so roughly 158 GB/day of PUTs that overwrite identical objects. It only
went unnoticed because the pre-run bundle was failing with EntityTooLarge
before it could be sent twice.

List the destination prefix once (3 requests for 2733 keys, versus a HEAD per
file) and skip anything already there at the same size. Where the ETag is a
plain MD5 — objects small enough to have gone up in a single part — verify
that too, so the decision is content-based rather than size-based for all but
the one file whose identity the suite hash already pins. A multipart ETag is a
digest of part digests, so checking it would mean re-reading the whole bundle,
the exact cost this skip exists to avoid.

summary.json is never skipped: metadata labels can change between runs without
affecting the suite hash, which is why CreateSuiteOutput rewrites it every
time.
The ETag check was there to catch same-size-different-bytes. With the pre-run
cap in #304 every remaining suite file is under the part size, so that branch
covers all of them — meaning a full read and MD5 of the whole suite, 1-3 GB,
on every run, to avoid re-sending the same 1-3 GB.

It was insurance against something the design already rules out: a suite hash
is a digest of exactly these step files, and only benchmarkoor writes under
that prefix, so a key cannot hold different bytes at the same size without the
hash changing too. Drop it and compare size, which the listing already gives
us for free.

summary.json stays exempt. It is the one file that is rewritten rather than
content-addressed, so a label edit that happens to preserve its length is
plausible in a way it is not for the rest.

Net 63 lines and one crypto import gone.
@qu0b

qu0b commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Simplified — the ETag check is gone, size alone now.

It existed to catch same-size-different-bytes. With the pre-run cap in #304 every remaining suite file is under the 64 MiB part size, so that branch would have covered all of them: a full read and MD5 of the whole suite, 1–3 GB, on every run, to avoid re-sending the same 1–3 GB. And it guarded something the design already rules out — a suite hash is a digest of exactly these step files, and only benchmarkoor writes under that prefix, so a key cannot hold different bytes at the same size without the hash changing too.

The skip is now one ListObjectsV2 and a size comparison. 63 lines and a crypto import lighter.

summary.json stays exempt: it is rewritten rather than content-addressed, so a label edit that happens to preserve its length is plausible in a way it is not for the rest.

Why this is still worth having alongside #304

#304 removes the 9.4 GiB pre-run bundle, but only one of the five active suites has one. The other four re-send their full fixture payload on every run and #304 does nothing for them. Measured from payload_sizes in each suite's summary.json against the last 5 days of the runs index (591 runs, ~119/day):

suite size runs/day redundant/day
state-actor-gd7-compute 1.50 GB 32.8 49.4 GB
state-actor-gd7-stateful 2.87 GB 13.2 37.9 GB
state-actor-gd7-full-compute 0.87 GB 37.2 32.4 GB
state-actor-gd7-full-stateful 0.78 GB 29.8 23.2 GB
jochemnet-gd7-stateful 2.96 GB 5.2 15.4 GB

~158 GB/day and ~273k PUTs/day — about 8.2M class-A ops a month. #304 removes ~52 GB/day on top of that, all of it from the one suite.

qu0b added a commit that referenced this pull request Aug 11, 2026
Implements @skylenet's suggestion: a size limit on pre-run bundles,
512MB default, anything over it skipped.

## Why

The jochemnet bloatnet pre-run bundle is a single **9.4 GiB**
`pre-run.request` — roughly 8k blocks of setup. `CreateSuiteOutput`
copies it into the suite directory on every job, and the runner then
uploads it. Nothing consumes it: the runner replays pre-runs from the
fixtures cache, not from the suite, and the UI has no use for a bundle
that size.

## What it does

A pre-run step over `runner.benchmark.tests.max_pre_run_step_size`
(default `512MB`) is recorded in `summary.json` and not copied, so it
never reaches the bucket. `0` disables the limit.

```json
"pre_run_steps": [
  { "og_path": "pre_run/pre-run.request", "size_bytes": 10062313486, "omitted": true }
]
```

Two details worth flagging for review:

**Checked with a `stat` before the copy, not at upload time.** Skipping
it at the upload layer would still leave a 9.4 GiB write into
`RUNNER_TEMP` on every job. Doing it here costs neither the write nor
the transfer, and keeps the uploader generic — it has no business
knowing which files are pre-run steps.

**The entry is recorded as `omitted`, not dropped.** The suite stays
honest about what the run replayed, and the UI gets something to check
before offering the file for viewing — `TestFilesList.tsx:147` currently
links `suites/<hash>/<og_path>/pre_run.request` unconditionally, which
would 404 for an omitted bundle. That UI guard is a separate change;
this PR gives it the flag to read.

## Tests

- `TestCreateSuiteOutput_OmitsOversizedPreRunSteps` — over the limit: no
file written, summary records `omitted` and the true size, test steps
unaffected.
- `TestCreateSuiteOutput_KeepsPreRunStepsWithinLimit` — under the limit
and with the limit disabled, the bundle is stored exactly as before.

## How this relates to the other PRs

- **#301** (merged) makes the upload of any large file work at all.
Still wanted — it is what stops a >5 GiB file failing the whole suite
upload, including the `summary.json` that the UI actually needs — but
with this cap the 9.4 GiB path stops being exercised.
- **#302** (skip unchanged objects) is orthogonal: it stops re-sending
the ~2.96 GB of fixture payloads on every run, which this PR does not
touch. Together the steady-state suite upload goes from ~12.4 GB a run
to ~5 MB.
- **#303** (merge the stored summary) is a different bug and independent
of both. Note it also adds a parameter to `CreateSuiteOutput`, so
whichever of #303/#304 merges second needs a trivial rebase — happy to
fold both into an options struct if you'd rather.

## Verification

`make build-core`, `make lint-core` (0 issues), full `go test ./pkg/...
./cmd/...` all pass locally.
@qu0b
qu0b merged commit bcb21fc into master Aug 11, 2026
8 checks passed
@qu0b
qu0b deleted the qu0b/skip-unchanged-suite-objects branch August 11, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant