Performance Tuning#

Tips for maximising throughput and getting clean benchmark results.

Batch Size#

For seed operations using exec_batch, the size field controls how many rows are grouped into each SQL statement. This has a significant impact on insert throughput:

sizeEffect
Too small (1-10)Excessive round trips; dominated by network latency
Sweet spot (100-5000)Good throughput with manageable transaction size
Too large (10000+)Large transactions; risk of lock contention and OOM on the database

Start with size: 1000 and adjust based on your row width and database. Wide rows (many columns, large strings) benefit from smaller batches.

seed {
  populate_users(count: 1000000, size: 5000) `
    INSERT INTO users (email)
    __values__
  ` (gen('email'))
}

Seed Workers#

For seed operations, batch generation and query execution are pipelined: one goroutine generates batches while worker goroutines execute them concurrently. This keeps memory bounded (at most 2 batches in memory regardless of total row count) and lets you saturate database throughput with multiple inserters.

Use the --workers flag on the seed command or the per-query workers field in your config:

edg seed --workers 4 --driver pgx --config workload.edg --url ${DATABASE_URL}
seed {
  populate_users(count: 1000000, size: 5000, workers: 4) `
    INSERT INTO users (email)
    __values__
  ` (gen('email'))
}

The --workers CLI flag overrides per-query workers values. Only exec_batch queries support concurrent workers; query_batch queries with RETURNING data always run with a single worker.

Workers vs Pool Size#

The --workers flag controls concurrency (goroutines), while --pool-size controls the number of database connections. This applies to both run and seed commands:

ConfigurationBehaviour
workers == pool-sizeEach worker gets a dedicated connection. No contention.
workers > pool-sizeWorkers share connections. Some block waiting. Useful for simulating connection-limited environments.
workers < pool-sizeExtra connections sit idle. No benefit over matching.
pool-size = 0 (default)Pool is sized to workers, or to the busiest stage’s worker count when the config declares stages. Each worker gets its own connection.

A staged run ignores --workers for concurrency, so the pool follows the stages. Without that, a config peaking at 16 stage workers would run every one of them through the single connection --workers defaults to, and throughput would track latency rather than the worker counts the stages ask for.

For benchmarks measuring peak throughput, leave pool-size at the default. For benchmarks simulating production conditions, set pool-size to match your application’s connection pool.

Connections are recycled after --max-conn-lifetime (default 30m) plus up to --max-conn-lifetime-jitter (default 5m). For a long run against a cluster this keeps connections rebalancing across nodes; for a short benchmark neither will fire. See Connection Pool for driver support.

Prepared Statements#

Setting prepared: true on run queries reduces server-side parse overhead by caching the query plan per worker:

run {
  lookup_product(prepared: true) `
    SELECT id, name, price FROM product WHERE id = $1
  ` (ref('fetch_products').id)
}

The benefit scales with query complexity. Simple point lookups see minimal improvement, but multi-table joins with aggregations can see 20-30% latency reduction.

Prepared statements are not compatible with batch types (query_batch, exec_batch) or queries inside transactions.

With pgx (and dsql) you often don’t need prepared: true at all. edg sends $N args to pgx as bind params, and pgx then caches a prepared statement per connection by itself. That only happens when every arg in the set is bindable - a float, slice, map or raw SQL fragment forces edg to inline the whole set as literals, and pgx uses the simple protocol for any statement sent with no args, re-parsing and re-planning it every time. See Placeholders for the full rules.

If a hot query is slower than you expect, check its args for floats. Generators like uniform.float return float64, so a query taking one is inlined. Casting in SQL ($1::INT) does not help; the arg type is what matters.

prepared: true does bypass inlining, but it also binds those floats, so be aware it can change what gets stored: an inlined 3.7 reaches an INT column as a decimal literal the server rounds to 4, where the same value bound as a float8 truncates to 3. Prefer prepared: true on queries whose args are already integers or strings.

Warmup Period#

Database caches, JIT compilation, and connection establishment all affect early query latencies. Use --warmup-duration to run the workload for a period before collecting metrics:

edg run \
  --warmup-duration 30s \
  --duration 5m \
  -w 10 \
  ...

Workers run during warmup but results are discarded. This produces more representative p90/p95/p99 numbers by excluding cold-start outliers.

Deterministic Seeding#

Use --rng-seed to make generated data deterministic. This eliminates variability between runs, making it easier to compare benchmark results:

edg all --rng-seed 42 --driver pgx --config workload.edg --url ${DATABASE_URL} -w 10 -d 5m

Two runs with the same seed produce identical seed data and identical random selections during the run phase.

Distribution Selection#

Choosing the right distribution for your workload matters more than raw throughput numbers:

DistributionWhen to use
uniform / refEven access across all rows. Good baseline.
zipf / zipf.setHot-key patterns. Realistic for most OLTP workloads where some rows are accessed far more often.
norm / norm.setBell curve access. Good for time-based or range queries where most activity clusters around a centre.
exp / exp.setHeavy bias toward low values. Good for recency-biased access (recent orders, new users).

Zipfian distributions with s=1.1 to s=2.0 are the most common for realistic OLTP benchmarks. Higher s values create more contention.

Run Weights#

Use run_weights to control the read/write mix rather than duplicating queries:

weights {
  read_order = 80
  update_status = 15
  insert_order = 5
}

This produces 80% reads, 15% updates, and 5% inserts, a realistic OLTP mix. Without run_weights, all queries execute sequentially on every iteration.

Metrics Samples#

Percentile calculations (p90, p95, p99) use a sliding window of the most recent latency samples. By default, edg keeps the last 10,000 samples per query. For long-running benchmarks this keeps memory bounded while still producing accurate percentiles.

Increase the window if you need percentiles calculated over a larger history, or decrease it to reduce memory usage with many concurrent queries:

edg run \
  --metrics-samples 50000 \
  --driver pgx \
  --config workload.edg \
  --url ${DATABASE_URL} \
  -w 100 \
  -d 1h

Count, average, QPS, and error metrics are always exact regardless of this setting.

Monitoring During Runs#

Use --metrics-addr to expose Prometheus metrics for real-time monitoring:

edg run --metrics-addr :9090 --driver pgx --config workload.edg --url ${DATABASE_URL} -w 10 -d 5m

This lets you correlate edg metrics with database-side metrics in Grafana. See Observability for dashboard setup.

Stages for Load Profiling#

Use stages to vary worker counts over time and observe how the database responds to changing load. Each stage can also override run_weights to shift the workload mix per phase:

stages {
  ramp(workers: 1, duration: 30s, weights: {read_order: 95, insert_order: 5})
  low(workers: 10, duration: 2m)
  peak(workers: 100, duration: 5m, weights: {read_order: 60, update_status: 25, insert_order: 15})
  cooldown(workers: 10, duration: 2m)
}

weights {
  read_order = 80
  update_status = 15
  insert_order = 5
}

Stages without run_weights fall back to the top-level weights. Combine with Prometheus metrics to produce load-vs-latency curves that reveal database scaling characteristics.

QPS Rate Limiting#

Use the qps field on a stage to cap total queries per second across all workers. This is useful for testing how your database behaves at a specific throughput target rather than running flat out:

stages {
  steady(workers: 50, duration: 5m, qps: 1000)
  burst(workers: 50, duration: 2m, qps: 5000)
}

Without qps, workers fire queries as fast as the database can respond. With qps set, workers wait for a shared rate limiter before each iteration, producing a steady and predictable load. This makes it easier to correlate latency changes with a known request rate.

QPS rate limiting applies per stage. Set workers high enough that they can sustain the target rate - if you set qps: 5000 with 2 workers, you won’t reach 5,000 QPS if each query takes more than 400 microseconds.

QPS Ramping#

Use ramp(start, end, duration) to move QPS linearly from start to end instead of jumping straight to the target rate:

stages {
  warmup(workers: 50, duration: 5m, qps: ramp(10, 1000, 60s))
  sustain(workers: 50, duration: 30m, qps: 1000)
}

This ramps QPS from 10 to 1,000 over the first 60 seconds, then holds at 1,000 for the remainder. All workers start immediately; only the rate limit changes.

end may be lower than start, which winds a stage down rather than up:

stages {
  evening_peak(workers: 16, duration: 4h, qps: ramp(250, 900, 90m))
  wind_down(workers: 3, duration: 2h, qps: ramp(900, 40, 90m))
}

The ramp always occupies the first duration of the stage and the end rate is held for whatever remains, so ramp() describes a transition into a stage rather than a shape spanning it. A stage whose rate should vary across its whole length wants signal() instead.

QPS ramping via ramp() is independent of ramp_duration. When both are set, ramp_duration controls worker staggering while ramp() controls the QPS curve.

Signal-Driven QPS#

A constant and a linear ramp are the only shapes qps can describe on its own. Use signal('name', peak: N) to drive the rate limiter from a pre-computed signal buffer instead, which gives you any shape you can write as an expression - and lets the same declaration shape both your load and your data:

sim(period: '24h', in: '20m', from: '2024-06-03T00:00:00Z')

# 288 points, one per simulated 5 minutes, so i / 12.0 is the hour of day.
signal workday(from: '2024-06-03T00:00:00Z', to: '2024-06-04T00:00:00Z', interval: '5m') {
  cond ( i / 12.0 >= 7 && i / 12.0 < 19 ,
         0.05 + 0.95 * pow ( sin ( pi * ( i / 12.0 - 7 ) / 12 ) , 2 ) ,
         0.05 )
}

stages {
  day(workers: 100, duration: 24h, qps: signal('workday', peak: 2000))
}

Flat 100 QPS overnight, ramping from 07:00 to a 2,000 QPS peak at lunchtime, back to 100 by 19:00. duration: 24h is simulated time, so sim scaling turns it into 20 minutes of wall clock.

peak is required and must be positive. The buffer is normalised rather than read raw: its maximum maps to peak QPS and everything else scales proportionally. That means a signal written as a 0..1 shape and one written in row counts behave identically, and reusing a row-count signal for QPS does not silently ask for 7,000 queries a second.

How the buffer is indexed#

Two rules, applied in order:

  1. A sim block is present and the signal declares an interval - the buffer advances one step per interval of simulated time, wrapping at the end. Above, that is one step per simulated 5 minutes, and 288 points traverse the 20-minute run exactly once. The run starts at index 0 unless both the sim block and the signal declare a from, in which case the run joins the curve wherever the sim clock starts - which is what lets a run begin mid-day.
  2. Otherwise - the buffer stretches across the whole run: index = progress * (len - 1). This covers length-only signals and configs with no sim block.

Position is measured from the start of the run, not the start of the stage, so consecutive stages carve up one curve instead of each replaying it:

stages {
  quiet(workers: 10,  duration: 1h,  qps: signal('interactive', peak: 400))
  batch(workers: 200, duration: 2h,  qps: signal('etl', peak: 800))
  day(workers: 100,   duration: 21h, qps: signal('interactive', peak: 400))
}

The day stage resumes the interactive curve at 03:00 rather than restarting it, so the morning ramp lands at 07:00 where the signal puts it. The etl signal is zero outside its 01:00-03:00 window and the batch stage runs only inside it, so the two agree without either having to say so twice.

Details#

  • Values between buffer points are linearly interpolated, so a buffer coarser than the tick rate still produces a smooth curve rather than a staircase.
  • Negative values clamp to zero and the rate never drops below 1 QPS. A rate limiter at zero blocks forever, so a signal that bottoms out would stall the stage rather than idle it.
  • A signal whose maximum is not positive cannot be normalised and is a load-time error.
  • qps: signal(...) and qps: ramp(...) are mutually exclusive. ramp_duration on a signal-driven stage keeps its other meaning: staggering worker spawn.
  • Set workers high enough to sustain the peak, exactly as for a flat qps. If the pool saturates, the limiter stops being the binding constraint and the curve flattens at the top.
  • Under a cluster run the curve is run-wide and divided between agents. A rebalance changes the divisor, not the shape.

One table, one query and one curve - the smallest version of all this - is at examples/signals/basic/crdb.edg, with the rows bucketed afterwards to show the declared shape coming back out. examples/signals/cluster/ is the same workload submitted to a cluster, where two agents take half the curve each.

Six shapes - flat, square, a sine hump, hourly spikes, a sine hump carrying empirical.float() noise and a decay - run concurrently across one simulated day at examples/qps_shapes/crdb.edg. One stage follows a composite signal that sums the six, and each run item fires with its shape’s share of that composite, so the measured histograms show every curve coming out as declared while they all compete for the same limiter. See Shapes.

Breakpoint Testing#

Use edg perf breakpoint to automatically find the point at which your database starts to degrade. It starts with one worker and adds another every interval, monitoring p99 latency, per-worker QPS, and error count. When metrics degrade past a threshold, it warns you. When absolute limits are hit, it stops.

edg perf breakpoint \
  --driver pgx \
  --config workload.edg \
  --url ${DATABASE_URL} \
  --ramp-interval 10s \
  --threshold 30 \
  --stop-p99 100ms

This tells you how many concurrent workers your database can handle before latency becomes unacceptable. Combine with --pool-size to test under realistic connection limits.

See Breakpoint Testing for full flag reference and examples.