Hardware-vs-throughput benchmark for the Traceway backend. Provisions Hetzner Cloud servers, runs OTLP ingest in realistic batch shapes (one signal per matrix entry), and produces per-signal charts of sustainable throughput per hardware tier and DB mode.
On hardware tier X with DB config Y, you can sustain N spans/sec, M metric data points/sec, and P log records/sec via OTLP under collector-shaped batch traffic (gzipped protobuf, batches up to 8192).
Three signals are tested in separate matrix entries:
- spans →
POST /api/otel/v1/traces(ExportTraceServiceRequest) - metrics →
POST /api/otel/v1/metrics(ExportMetricsServiceRequest, Gauge data points) - logs →
POST /api/otel/v1/logs(ExportLogsServiceRequest)
Three DB modes are supported:
- sqlite — single-binary Traceway with embedded SQLite (
Dockerfile.sqlite). - pgch — full ClickHouse + Postgres stack, all in Docker on the SUT (
Dockerfile.minimal). - managed-ch —
Dockerfile.minimalpointed at an externally-hosted ClickHouse (ClickHouse Cloud, Aiven, Altinity, etc.) via env vars. Postgres still runs locally in the SUT's Docker. See Running against managed ClickHouse.
Four hardware tiers, all Hetzner CCX (dedicated vCPU) so neighbor noise doesn't pollute the latency signal:
| Tier | vCPU | RAM | Disk |
|---|---|---|---|
| CCX13 | 2 | 8 GB | 80 GB NVMe |
| CCX23 | 4 | 16 GB | 160 GB NVMe |
| CCX33 | 8 | 32 GB | 240 GB NVMe |
| CCX43 | 16 | 64 GB | 360 GB NVMe |
Two distinct questions; one matrix-entry script answers either depending on
the --scenario flag.
-
throughput(default). A three-phase ingest ramp (Phase 1: batch size; Phase 2: collector-shape rate; Phase 3: SDK-fleet-shape rate). Results land inbenchmarks/results-throughput/. Answers "how fast can the SUT swallow OTLP data without erroring or rejecting?" -
read-probe. Fills the table to a sequence of row counts (100k, 1M, 10M, 100M) and probes one dashboard read at each level, failing the step when the read exceeds--read-threshold-ms(default 5 s). Results land inbenchmarks/results-probe/. Answers "how big can the table grow before the dashboard read on this endpoint cliffs?"Between the fill and the probes sits a digestion gate (
--max-digest-wait, default 10 m): the loadgen polls/api/health/deepuntil the engine's db+WAL file sizes are stable across two consecutive polls. DuckDB checkpoints its WAL asynchronously after ingest stops, and on a small tier that checkpoint can outlast the settle window — probing through it produces uniform timeouts on sub-millisecond queries (a wedged engine, not query cost; observed as a nondeterministic logs/spans "cliff" on ccx13 before the gate existed). The wait is recorded per fill level asdigestSeconds(withdigestTimedOutwhen the cap expired), so "how long until the box answers again after a burst" is itself a published number. Backends without engine gauges (SQLite, ClickHouse) pass the gate on the first poll, and the probes use a client without the shared 30 s timeout so--read-threshold-msabove 29 s actually takes effect.
The two scenarios write to sibling folders so one never overwrites the other. Each folder is wiped on each dispatch of its scenario.
Don't read the throughput number as "the dashboard stays usable at N items/sec" — that's what read-probe is for.
Every backend PrepareBatch call funnels through chdb.BatchCtx(), which
reads CH_ASYNC_INSERT once at startup. This is a per-dispatch toggle
— a single matrix entry runs one insert mode; comparison requires two
dispatches.
CH_ASYNC_INSERT=0 (default) |
CH_ASYNC_INSERT=1 |
|
|---|---|---|
| Driver flag | clickhouse.WithAsync(false) |
clickhouse.WithAsync(true) |
| Part-per-request | 1 part per insert (worst case for MergeTree) | Batched server-side, parts amortised |
| What it measures | Pessimistic ceiling — what you get if you point a stock OTel collector at the default Traceway config | Realistic production ceiling — what a tuned deployment gets |
| Result filename | <tier>-<mode>-<signal>-<scenario>.json |
<tier>-<mode>-<signal>-<scenario>-async.json |
| Headline bar in charts | Solid | Solid (sibling bar in chart-headline-.png when both passes are present) |
Quote both numbers when reporting. The sync number tells you the floor your users hit before tuning; the async number tells you the headroom.
Triggering async-insert locally:
# Default (sync) — no env var needed
./benchmarks/scripts/run-local.sh --tier ccx13 --mode pgch --signal spans
# Async — set CH_ASYNC_INSERT=1 in the parent env. run-matrix-entry.sh
# propagates it via the 7th positional arg "async" to sut-bootstrap.sh,
# which exports it into the SUT's docker-compose env so chdb.BatchCtx()
# reads the right value at startup.
CH_ASYNC_INSERT=1 ./benchmarks/scripts/run-local.sh --tier ccx13 --mode pgch --signal spans --asyncrun-local.sh --async is sugar that just sets CH_ASYNC_INSERT=1 and
threads the async arg into run-matrix-entry.sh. Direct script call:
./benchmarks/scripts/run-matrix-entry.sh ccx13 pgch spans 30m benchmarks/results-throughput "" async
# ^tier ^mode ^sig ^dur ^out-dir ^smoke ^asyncTriggering async-insert in CI:
In the GitHub Actions workflow_dispatch form, flip the async_insert
boolean to true. The workflow propagates it through to
run-matrix-entry.sh and tags both the artifact and the committed JSON
filenames with -async. Dispatch twice (once off, once on) and the
aggregate job's chart renderer paints sibling bars in
chart-headline-<signal>.png.
managed-ch mode also honours CH_ASYNC_INSERT — useful for measuring
how much of the managed offering's headroom is currently being left on
the table by the conservative sync default.
Per matrix entry (one tier × one mode × one signal):
-
hetzner-up.shprovisions a SUT box + a CAX11 loadgen box on a private network (override withLOADGEN_TIER). -
sut-bootstrap.shrsyncs the repo, installs Docker, brings up the rightdocker-compose.<mode>.yml, waits for/health. -
seed-project.shregisters a fresh user + project, captures the project bearer token. -
loadgen-bootstrap.shcross-compiles the loadgen, pushes it to the loadgen box, runs it with--signal <spans|metrics|logs>against the SUT's private IP. -
The loadgen runs a three-phase ramp. After every step it GETs
/api/health/deepand embeds achblock in the step's JSON (partsCount,partsByTable,activeMerges,longestMergeSec,errorsRecent,memoryUsageBytes,uptimeSec). This is how the bench sees ClickHouse-side pressure — the HTTP-only signal alone can't distinguish "SUT cliffed on CPU" from "MergeTree threwToo many parts".- Phase 1 — batch-size ramp. Single client at a fixed 5 req/sec.
Batch sizes step through
256,1024,4096,8192,16384. Each step holds for--step-duration(default 2 min). Stops at the first failing step. - Inter-phase merge-idle wait. Phase 1's last step typically runs the
SUT near saturation; CH merge horizons are minutes, not seconds. The
loadgen polls
/api/health/deepevery 5 s untilactiveMerges == 0andpartsCountis stable (within ±5%) for two consecutive polls, or--max-merge-idle-wait(default 5 m) expires. The old fixed--inter-phase-cooldown-secondsflag still works (additive pre-wait) but defaults to 0 — it's deprecated in favour of polling CH directly. Without this gate, pgch runs reliably produced "0 OK / 0 errors" Phase 2 because new requests sat on the SUT-side TCP backlog while CH was still merging Phase 1's parts. - Phase 2 — request-rate ramp (collector shape). Batch size fixed at
min(Phase 1 winner, --phase2-batch-cap=16384). Request rates step through1,5,25,100,400. When the coarse ramp finds the first failing step, up to--phase2-bisect-max-steps(default 3) bisection steps run between the last passing rate and the failing rate to pin the cliff within--phase2-bisect-tolerance(default 20%). So5→25(a 5× jump) gets refined into something like5, 15, 10, 12until the gap is <20% of the last passing rate. - Merge-idle wait before Phase 3 too.
- Phase 3 — request-rate ramp (SDK-fleet shape). Batch size fixed at
--phase3-batch-size(default 100, matching typical language-SDKBatchSpanProcessoroutput rather than the collector's 8192). Request rates step through--phase3-request-rates(default10,100,1000,5000,10000— much higher than Phase 2 because each request is much cheaper at batch=100). Same bisection logic as Phase 2. Measures the SUT under "thousands of small batches/sec from a real SDK fleet" load, which stresses the HTTP/auth/decode/queue path more than the raw insert path Phase 2 measures.
- Phase 1 — batch-size ramp. Single client at a fixed 5 req/sec.
Batch sizes step through
-
A step "fails" when any of:
- combined error rate (HTTP failures + OTLP
PartialSuccessrejected items) exceeds--ingest-err-threshold(default 5%), or - the SUT silently dropped rows mid-step: the loadgen polls
/api/health/deepbefore and after each step and any positivedroppedRowsTotaldelta fails the step, since throughput built on acked-but-discarded items is not real ingested throughput (DuckDB drops rows its appender rejects instead of erroring the request; SQLite and ClickHouse report 0 here). The end-of-step snapshot lands in the step JSON assutIngest, including DuckDB db/WAL file sizes and engine memory, so a latency cliff can be correlated with WAL growth or memory pressure after the fact. Read-probe fills record the same counter asdroppedRowsper fill level (the true table size isrowsIngestedminusdroppedRows), or - the achieved request rate falls below
--soft-cliff-ratio× target rate (default 70%) — meaning the workers can't keep up with the limiter, the SUT has cliffed on latency, and we'd be erroring out one step later anyway.
The headline
maxSustainableItemsPerSecis the highestactualItemsPerSecrecorded across passing steps from any of Phase 1, Phase 2, or Phase 3 — measured from real OK responses, not the formulabatchSize × targetRate(which over-reports when workers saturate before the limiter does). Different phases probe different shapes (collector fat-batch vs SDK small-batch fleet) and the SUT's best-shape ceiling is what the headline reports; per-phase numbers stay in the JSON for shape-specific analysis. - combined error rate (HTTP failures + OTLP
-
hetzner-down.shdeletes everything via a bashtrap— even on Ctrl-C.
pgch and managed-ch containers declare restart: on-failure:3 in their
compose files. When ClickHouse OOMs under Phase 1's heaviest step (typical
on ccx33 + pgch once Phase 2 climbs past batch=16384 × rate=100), Docker
auto-restarts the crashed service within seconds. After each merge-idle
wait the loadgen polls GET <target>/health for up to
--sut-health-timeout-seconds (default 60 s) to confirm the SUT actually
came back. If /health never returns 200, the loadgen skips the remaining
phases and writes the partial JSON via the existing per-step checkpoint —
no data already collected is lost. The bounded on-failure:3 retry count
prevents a permanently-broken SUT from restart-looping forever; if the
third restart also crashes, the container stays down and the health-poll
fires the clean-abort path.
Detecting silent CH restarts. Compose's restart policy used to mean
a crashed-and-recovered ClickHouse was invisible: the SUT came back up and
the bench kept running, producing a "passing" report that hid the
incident. The bench now tracks CH's uptime() across every step. If
step N's uptime is lower than step N-1's uptime plus the elapsed step
window, ClickHouse restarted mid-step. The step is marked failed with
failReason: CH restarted mid-step (...), chRestarted: true is set on
the top-level JSON, the run skips remaining phases, and the loadgen
exits non-zero. The chart renderer hatches the affected headline bar
in red with a CH↻ annotation so the dispatch report flags the run as
invalid rather than burying the restart in a passing number.
After all matrix entries finish, chart.py renders the full chart suite
(headline bars, Phase 1 / 2 / 3 ramps, Pareto, tier scaling, cliff grid,
batch efficiency, signal mix — plus the read-probe equivalents in the
read-probe folder) and a combined summary.md. See
charts.md for a guide to reading each chart.
- Install tooling:
hcloud,jq,python3, Go 1.25+,rsync. On macOS:brew install hcloud jq rsync go. - Install matplotlib in a venv (system Python is usually PEP 668 locked):
python3 -m venv .venv source .venv/bin/activate pip install matplotlib - Generate an SSH key specifically for benchmarks and upload its public
half to Hetzner under the name
benchmark-key:ssh-keygen -t ed25519 -f ~/.ssh/hetzner_benchmark -C benchmark-key chmod 600 ~/.ssh/hetzner_benchmark hcloud ssh-key create --name benchmark-key --public-key-from-file ~/.ssh/hetzner_benchmark.pub
- Export creds (use
direnvor a sourced.envrc.local, never commit):export HCLOUD_TOKEN=... export BENCHMARK_SSH_KEY=~/.ssh/hetzner_benchmark
./benchmarks/scripts/run-local.sh --smokeOne tier (ccx13), one mode (sqlite), one signal (spans), short steps. If this works, the full matrix works.
./benchmarks/scripts/run-local.sh4 tiers × 2 modes × 3 signals, default --duration 30m. Output goes to
benchmarks/results-throughput/ (or benchmarks/results-probe/ when
running --scenario read-probe), which is wiped at the start of every run
so each dispatch's output stands alone — no silent cross-run mixing that
the old date-folder layout produced. The two scenario folders are
independent: a read-probe run never touches results-throughput/ and
vice-versa.
# Validate environment without provisioning anything (free)
./benchmarks/scripts/run-local.sh --dry-run
# Re-run just one tier/mode across all signals
./benchmarks/scripts/run-local.sh --tier ccx23 --mode pgch
# One signal only across all tiers/modes
./benchmarks/scripts/run-local.sh --signal spans
# A single matrix cell
./benchmarks/scripts/run-local.sh --tier ccx13 --mode sqlite --signal logs
# Override the per-entry runtime
./benchmarks/scripts/run-local.sh --tier ccx13 --duration 10m
# Switch to the read-probe scenario (writes to benchmarks/results-probe/)
./benchmarks/scripts/run-local.sh --scenario read-probe
# Async-insert pass — same matrix but with CH_ASYNC_INSERT=1 on the SUT.
# Output filenames pick up a -async suffix so paired bars render in
# chart-headline-<signal>.png. Dispatch the default (sync) pass first,
# then this one, into the same results folder.
./benchmarks/scripts/run-local.sh --tier ccx13 --mode pgch --signal spans --async.github/workflows/benchmark-hardware.yml, workflow_dispatch only. Inputs
mirror the local flags. The workflow YAML is a thin wrapper around the same
run-matrix-entry.sh script the local path uses — if it works locally, it
works in CI.
Required GitHub secrets:
HCLOUD_TOKENBENCHMARK_SSH_PRIVATE_KEY— the private key matching the Hetzner-sidebenchmark-key.
After the matrix completes, an aggregate job downloads all artifacts, runs
chart.py, and commits the matching scenario folder
(benchmarks/results-throughput/ for throughput runs,
benchmarks/results-probe/ for read-probe runs) to main via a bot commit
(git add -A, so files from a prior dispatch that aren't in this one get
staged for deletion). No PR — it's a generated artifact. Each scenario
folder always reflects exactly one dispatch of that scenario; a
throughput dispatch never modifies results-probe/ and vice-versa.
Comparing to previous runs is git log -- benchmarks/results-throughput/
or git show <sha>:benchmarks/results-throughput/summary.md. Dated
historical folders (benchmarks/results/2026-05-15/ etc.) from before
this layout change are kept in git for reference but are no longer written
to.
.github/workflows/benchmark-metricsdb.yml, workflow_dispatch only. It
provisions one dedicated-core Hetzner box per database (VictoriaMetrics,
ClickHouse, DuckDB, Firebolt) and tears it down afterwards; a full run of the
default four on ccx33 is about EUR 2.50. It uses the same two secrets as the
hardware benchmark. The benchmarks/metricsdb tree it builds and runs is not
on main yet (.gitignore excludes it while it lives on a branch), so a
dispatch fails at the build job until that tree lands.
Setting modes=managed-ch in the workflow dispatch (or --mode managed-ch
locally) points the SUT's Traceway container at an externally-hosted
ClickHouse. Postgres still runs locally in the SUT's Docker — this benchmark
is about ClickHouse characteristics.
Required GitHub repository secrets (Settings → Secrets and variables → Actions):
| Secret | Example | Notes |
|---|---|---|
BENCH_CH_SERVER |
your-cluster.us-east-1.aws.clickhouse.cloud:9440 |
Native TCP endpoint with TLS port (usually 9440) |
BENCH_CH_USERNAME |
default |
A dedicated bench user is wiser than default |
BENCH_CH_PASSWORD |
•••••• |
|
BENCH_CH_DATABASE |
traceway |
Optional, defaults to traceway |
BENCH_CH_HTTPS_PORT |
8443 |
Optional, defaults to 8443 (CH Cloud); some hosts use 8123 for plain HTTP |
The bench user needs DROP DATABASE and CREATE DATABASE privileges — between
every matrix entry the orchestrator runs reset-managed-ch.sh, which wipes and
recreates the bench database via the HTTPS interface so each (tier × signal ×
scenario) cell starts on an empty cluster. ~5–10s of overhead per entry. If
you're running on a shared cluster, point the bench at a dedicated database
or you will lose other data.
Locally: export the same vars (CLICKHOUSE_SERVER, CLICKHOUSE_USERNAME,
CLICKHOUSE_PASSWORD, optional CLICKHOUSE_DATABASE, BENCH_CH_HTTPS_PORT)
before invoking run-local.sh --mode managed-ch. Preflight will fail early if
they're missing.
- Network RTT dominates. Hetzner
nbg1to ClickHouse Cloudus-east-1is ~100ms each way; that's a wall the cluster can't climb. Match regions (nbg1/fsn1/hel1→ CH Cloud EU;ash/hil→ CH Cloud US) for numbers comparable to local-CHpgch. - Bandwidth matters too. At 8192-span gzipped OTLP batches × 400 req/sec you push ~30–80 MB/s outbound from the SUT. Hetzner's egress is generous; the managed cluster's ingress quota is the more likely cap.
- Read-probe is the more interesting scenario on managed CH. Throughput often gets bottlenecked on the SUT→cluster pipe before the cluster's actual ingest path. Read-probe surfaces the cluster's read scaling, which is what you actually buy from a managed offering.
Two additional workflows benchmark the JS symbolicator. They answer different questions and run on different infrastructure:
| Workflow | What it measures | Where it runs | Cost |
|---|---|---|---|
benchmark-symbolicator |
Bundle parser throughput: goja vs oxc (go test -bench) |
GitHub runner | free, ~10 min |
benchmark-symbolicator-cache |
Resolver cache architecture: in-memory LRU vs mmap disk cache | One Hetzner box | under 1 EUR, ~2 h |
Both are workflow_dispatch only. The workflow file must exist on main to be
dispatchable; select the feature branch in the run dropdown to execute that
branch's scripts and code.
scripts/benchmark-symbolicator.sh (repo root scripts/, not this folder)
builds the oxc Rust shim, runs the symbolicator test suite as a gate, then runs
BenchmarkBundleParsers over the bundler fixtures (simple, inlining, webpack,
metro, preact) plus 1MB/5MB synthetic bundles, with both parsers. Results land
in the job step summary. Expected shape: oxc 2-4.5x faster than goja with
roughly 99% fewer allocations; BenchmarkOpenTW in the hundreds of
nanoseconds.
With a corpus of N uploaded bundles (more than RAM can hold), a hot set of ~30 active exceptions, and R% of lookups hitting the long tail, which resolver cache is cheaper: parsed-in-memory or mmap'd
.twfiles on disk, and at what corpus size does one break?
The harness is backend/tools/cachebench (it imports backend internals, so it
lives in the backend module): generate writes a corpus of synthetic
bundle + VLQ source map + .tw triples, run executes one cell
(mode x entries x cold-ratio) in a fresh process and emits a JSON row with
throughput (rps), weighted p50/p95/p99 latency, peak RSS sampled at 200ms, GC
totals, and cache internals (hits, builds, disk hits, store hits, evictions),
table merges the rows into a markdown summary that marks the crossover
corpus size and any OOM break point.
Run it three ways:
# Locally, no Hetzner (small corpus; RSS reads 0 on macOS, Linux is the real measurement)
./benchmarks/scripts/cachebench-local.sh
# From a laptop against Hetzner (same env vars as the hardware benchmark)
export HCLOUD_TOKEN=... BENCHMARK_SSH_KEY=~/.ssh/benchmark-key
SMOKE=1 ./benchmarks/scripts/cachebench-entry.sh # ~5 min pipeline check
./benchmarks/scripts/cachebench-entry.sh # full sweep
# From GitHub Actions: dispatch benchmark-symbolicator-cache, smoke=true firstDefaults: ccx13 (2 vCPU / 8 GB), corpus 1000 to 12000 bundles (~1 MB resolver
each against a 5.6 GB bounded memory budget, 70% of RAM), cold ratios
0 / 0.05 / 0.25, plus an unbounded in-memory variant per cell to find the OOM
break point. Each cell gets a wiped tw-cache and dropped page caches. A cell
killed by the OOM killer is recorded as BREAK (OOM) instead of failing the
sweep; ssh drops and timeouts are recorded as ERROR/TIMEOUT so they cannot
masquerade as cache conclusions.
What to expect in the summary table (one section per cold ratio):
- Cold 0%: memory wins or ties every row; with only the hot set in play nothing ever misses. Baseline sanity.
- Cold 5% / 25%: memory wins while the corpus fits its budget, then
degrades once it does not (every cold lookup pulls the
.twartifact from storage into the heap, churning the LRU and the GC) while disk holds sub-ms p99 via mmap re-opens. The table prints the crossover row. - memory-unbounded: RSS climbs with corpus size until
BREAK (OOM), producing the "file-based is the only option beyond here" line. Disk RSS stays flat (only hot pages resident).
Results are not committed back to the repo: every dispatch uploads
benchmarks/results-cachebench/ (per-cell JSON plus summary.md) as a run
artifact, and the summary table is rendered in the job step summary.
benchmarks/
compose/ # SUT-side docker compose, one per mode
docker-compose.sqlite.yml
docker-compose.pgch.yml
docker-compose.managed-ch.yml # External CH; Postgres still local
loadgen/ # The Go binary that generates load (OTLP-only)
main.go # CLI + orchestration + merge-idle wait
ingest.go # Worker pool driving the selected signal sender
otlp_common.go # Shared OTLP helpers + signalSender interface
otlp_spans.go # ExportTraceServiceRequest builder
otlp_metrics.go # ExportMetricsServiceRequest builder (Gauge)
otlp_logs.go # ExportLogsServiceRequest builder
ramp.go # Three-phase ramp + CH-restart detection
ch_snapshot.go # GET /api/health/deep -> per-step ch{} block
stats.go # Latency tracker (percentiles via sort)
util.go, log.go # Misc helpers
scripts/
run-local.sh # ★ Laptop entry point
run-matrix-entry.sh # One matrix cycle (used by local + CI)
preflight.sh # Validates env before any provisioning
hetzner-up.sh # hcloud server create
hetzner-down.sh # hcloud server delete (idempotent)
sut-bootstrap.sh # Installs Docker, brings up backend
reset-managed-ch.sh # Wipes the managed CH DB between matrix entries
loadgen-bootstrap.sh # Cross-compiles + runs loadgen
seed-project.sh # /api/register -> JWT + project token JSON
chart.py # matplotlib renderer (throughput + read-probe charts)
_ssh.sh # Shared ssh/rsync helpers + retry_eof
cachebench-local.sh # Symbolicator cache sweep on this machine
cachebench-entry.sh # Symbolicator cache sweep on one Hetzner box
_cachebench.sh # Shared matrix/stub helpers for the two above
results-throughput/ # Committed throughput results (wiped per dispatch)
results-probe/ # Committed read-probe results (wiped per dispatch)
results-cachebench/ # Symbolicator cache results; CI uploads these as a run artifact, never committed
results/ # Historical dated folders (not written to anymore)
charts.md # Reading guide for every chart chart.py emits
- A run leaves Hetzner servers behind.
hetzner-down.shis idempotent and callable directly:./benchmarks/scripts/hetzner-down.sh <run-id>. Run IDs appear in the orchestrator's stderr at the start of each matrix entry. Also visible inhcloud server list— anything taggedbench=trueis safe to delete. preflight.shcomplains aboutbenchmark-key. The Hetzner-side SSH key resource is namedbenchmark-keyregardless of what you named the file locally. Re-upload viahcloud ssh-key create --name benchmark-key --public-key-from-file <your-pub-key>.- Docker compose build is slow on tiny tiers. First-time
docker compose up --buildon a CCX13 takes 5–8 minutes (npm install + Go build).sut-bootstrap.shwaits up to 10 minutes for/healthbefore giving up. Subsequent runs reuse the Docker layer cache via the persistent volumes. - All steps pass on the highest tier. Raise the upper end of
--phase2-request-rates(default1,5,25,100,400) — e.g. add1000,2000— if you want to find the ceiling on big boxes. The default is meant to keep one-tier runs bounded.