Skip to content

feat: add storage concurrency benchmark - #319

Open
kisernl wants to merge 7 commits into
masterfrom
storage-concurrency-bench
Open

feat: add storage concurrency benchmark#319
kisernl wants to merge 7 commits into
masterfrom
storage-concurrency-bench

Conversation

@kisernl

@kisernl kisernl commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Purpose

This benchmark measures how storage providers behave as the number of simultaneous GET clients increases. It is intended to identify:

  • Throughput scaling and diminishing returns
  • Latency inflation at high concurrency
  • Throttling, timeout, connection, and other failure modes
  • Whether spreading requests across key prefixes changes behavior

It is a closed-loop concurrency benchmark: each worker waits for its GET to complete before issuing the next GET. Throughput is therefore an observed output, not a fixed offered request rate.

Benchmark structure

  • One benchmark run containing all environment-available storage providers
  • Eight cells per provider:
    • Concurrency: 1, 8, 32, and 128
    • Key distribution: SINGLE_PREFIX and SPREAD_64
  • One runner task per cell
  • Runner concurrency fixed at 1 so cells do not overlap
  • Each task owns an internal request pool that establishes the storage-request concurrency
  • 1 KB deterministic objects are reused across runs
  • First 5% of operations are warmup and excluded from reported metrics

The initial operation budget is 1,200 per cell, producing enough measured samples for p99 latency. Lower operation counts are supported for smoke tests.

How concurrency is established and validated

The runner's concurrency: 1 is intentionally not the storage concurrency setting. It means the platform executes one cell task at a time so cells cannot overlap. Each cell creates its own internal pool with exactly the requested number of async workers:

c32 cell → 32 workers
worker → GET → wait for completion → next GET

A shared operation counter assigns exactly the cell's operation budget across those workers. The benchmark increments an activeRequests counter immediately before each storage.download() call and decrements it in finally, so maxActiveRequests measures the highest number of simultaneous storage requests observed. A c128 cell with at least 128 operations must report maxActiveRequests: 128; otherwise the cell does not exercise the requested concurrency.

The benchmark reports maxActiveRequests with every cell and marks request validity separately from task completion. A task can complete successfully while its requests are all 404s; in that case successRate is 0, notFoundRate is 1, and valid is false. Smoke tests use at least 256 operations to ensure c128 can reach 128 active requests; production cells use 1,200 operations and retain 1,140 measured samples after warmup.

This is target closed-loop concurrency, not fixed-RPS load. Workers maintain up to N in-flight requests and immediately replace completed requests, but a request completion and replacement can create a brief scheduling gap. The benchmark therefore validates the target with maxActiveRequests rather than claiming an exact constant offered rate.

Corpus seeding

The benchmark does not seed during measurement. The new seed utility creates the deterministic corpus once per provider and is idempotent:

pnpm bench:storage-concurrency:seed -- --provider aws-s3
pnpm bench:storage-concurrency:seed -- --verify --provider aws-s3

The workflow supports selecting providers, choosing the operation count, and optionally seeding before a run.

Measurements

Each cell reports:

  • Throughput in operations per second
  • p50, p95, and p99 latency
  • Success rate and validity
  • Throttle, timeout, connection, not-found, server, and client error rates
  • Maximum observed active requests

Invalid cells, such as cells whose corpus objects are missing, are explicitly marked and penalized rather than presented as performance data.

Composite scoring

Each provider receives a 0-100 composite score averaged across its cells:

  • Throughput: 45%
  • p50 latency: 20%
  • p95 latency: 20%
  • p99 latency: 15%

The score uses fixed absolute ceilings so adding or removing providers does not change existing scores. Success rate and invalid cells reduce the final score.

Results and GitHub Actions

The benchmark writes a combined result file to:

results/storage-concurrency/latest.json

The workflow uploads that JSON, then a collect job generates:

  • storage-concurrency.md, including provider ranking and cell details
  • storage-concurrency.svg, showing composite scores

The Markdown report is appended to the GitHub Actions job Summary and all report files are uploaded as artifacts.

Validation

  • TypeScript --noEmit check passes
  • Workflow YAML validation passes
  • Synthetic composite-score test passes
  • Synthetic report-generation test passes
  • AWS S3 corpus seeding and 1,200-operation smoke tests completed successfully

Caveats

  • Results are single-region and single-load-generator measurements.
  • The workload is closed-loop and does not correct for coordinated omission.
  • Prefix-scope conclusions require repeated runs and additional bucket/account controls.
  • Provider credentials and a pre-seeded corpus are required for measured runs.

@open-cla

open-cla Bot commented Aug 14, 2026

Copy link
Copy Markdown

Contributor License Agreement

All contributors are covered by a CLA.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread benchmarks/storage/storage-concurrency-corpus.ts
@kisernl
kisernl force-pushed the storage-concurrency-bench branch from 4d5f0df to 33f1142 Compare August 14, 2026 15:16
kisernl added a commit that referenced this pull request Aug 14, 2026
Adopts three practices from the storage concurrency benchmark (#319), which
faced the same problem of making levels comparable to each other.

Report percentiles only when the samples back them. A p95 drawn from one
observation is the median again, so printing both claimed knowledge the run did
not have, and the score weighted that single number at 25% for the median plus
20% for the p95. Stats now carry their sample count, and consumers withhold a
p95 under 20 samples rather than repeating the median. #319 does this by
returning null for p99 below 1,000 samples.

Measure latency per loop, not per action phase. A level's action phase covers
as many loops as that level runs, so round wall clocks were never comparable
between levels; per loop, one session's ten actions while the level's sessions
run together, is. On replayed data the levels land within 2.6s to 3.2s of each
other, where the round wall clocks spread from 4s to 63s. The leaderboard and
the CI table now read Loop and Loop (p95), and no longer fall back to taskMs,
since presenting a whole action phase under a per-loop heading would relabel
the number rather than report it.

Take samples from the workload instead of from repetition. Sessions repeat their
loop per level, 20/4/2/1/1, which evens the budget out at 20 to 50 loop samples
where c1 previously had one session, ten actions, and a success rate that could
only be 0% or 100%. Repeating inside the session costs no extra browsers, which
is the expensive part, and mirrors how #319 pushes 1,200 operations through a
fixed pool. A level action budget bounds the cost for slow providers: notte
averaged 3.6s per action, which would be 12 minutes for c1 alone. The budget is
checked on loop boundaries so every session stops together and the level keeps
its concurrency while it runs.

Count concurrency instead of inferring it. sessionsAlive measures survival, so
sessions taking turns reported the same number as sessions running together.
A tracker now counts sessions in flight and records the peak per round, both for
live sessions and for sessions running actions, and the round logs a line when a
level never reached its own session count or exceeded it. On replayed kernel
data the new Peak column reads 10/50, which is its account limit made visible
rather than inferred from a regex over error strings. This is the metric that
would have caught c1 running against 91 live sessions directly.

A session now counts as successful when every action it attempted succeeded,
rather than against a fixed total, so a session stopped by the action budget is
not recorded as a provider failure.

Covered by benchmarks/browser/concurrent-sampling.test.ts, 13 checks over the
tracker, the budget boundary, loop chunking and the percentile gates, following
the tsx test-script convention in #319. Validated end to end by replaying real
rounds from run 31626532250 through the writer, merge and both SVGs: every level
reports 18 to 48 loop samples, c10 withholds its p95 at 18 samples while c50
reports it at 48, and the peaks read N/N.
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