Skip to content

Commit 557d593

Browse files
authored
test(resharding): add benchmarks for copy_data & replication (#1013)
Add benchmarks scripts (with hyperfine inside) for pure copy_data and replication to measure its performance.
1 parent 87da4e0 commit 557d593

16 files changed

Lines changed: 1215 additions & 0 deletions

File tree

benches/README.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Benchmarks
2+
3+
Performance benchmarks for PgDog. Results are stored in
4+
`target/pgdog_benches/` and compared automatically between runs.
5+
6+
## Structure
7+
8+
```
9+
benches/
10+
bench.sh shared runner library
11+
compare.py result comparison table
12+
resharding/ resharding benchmark suite
13+
pgdog.toml config: 3 source shards (pgdog1-3) → 4 destination shards (shard_0-3)
14+
users.toml
15+
setup.sql schema + tables + publication (run per source shard)
16+
fill.sql synthetic data generator (BENCH_SCALE env var or \set scale)
17+
prepare.sh hyperfine --prepare: drop destination schema, run schema-sync
18+
copy_data/
19+
run.sh entry point: setup sources, then benchmark data-sync --sync-only
20+
```
21+
22+
## Running
23+
24+
```sh
25+
# Setup sources and benchmark (compares against the previous run automatically)
26+
bash benches/resharding/copy_data/run.sh
27+
28+
# Save as a named baseline
29+
bash benches/resharding/copy_data/run.sh --save-baseline main
30+
31+
# Compare against a named baseline (errors immediately if it does not exist)
32+
bash benches/resharding/copy_data/run.sh --baseline main
33+
34+
# Fewer runs for a quick check
35+
RUNS=1 WARMUP=0 bash benches/resharding/copy_data/run.sh
36+
```
37+
38+
### Flow
39+
40+
1. **Setup** (once per invocation): drops and recreates `bench_copy` schema on each
41+
source shard (`pgdog1`, `pgdog2`, `pgdog3`), then fills each shard with its own
42+
slice of rows using a stepped `generate_series` keyed on `tenant_id`, so the source
43+
data is pre-distributed before the copy benchmark runs.
44+
Set `BENCH_SCALE=N` before running, or edit `\set scale` in `resharding/fill.sql` directly.
45+
46+
2. **Benchmark** (timed, N runs): hyperfine calls `prepare.sh` before each iteration
47+
(drops destination schema, runs `schema-sync`), then times `data-sync --sync-only`.
48+
This measures pure data-copy throughput with no WAL replication involved.
49+
50+
On first run there is no history yet:
51+
52+
```
53+
No previous run found -- run again to see comparison.
54+
```
55+
56+
On subsequent runs a comparison table is printed automatically:
57+
58+
```
59+
copy_data
60+
61+
latest current change
62+
---------- ---------- ------------- ----------
63+
mean 24.904 s 22.194 s -10.88%
64+
median 24.100 s 21.900 s -9.13%
65+
min 24.904 s 22.194 s -10.88%
66+
max 24.904 s 22.194 s -10.88%
67+
stddev 1.200 s 0.900 s -25.00%
68+
user 2.226 s 11.234 s +404.63%
69+
system 962.045ms 1.419 s +47.50%
70+
max_mem 48.3 MB 46.1 MB -4.55%
71+
72+
Performance has improved.
73+
```
74+
75+
`latest` is always the previous run. Named baselines persist in
76+
`target/pgdog_benches/` until manually removed.
77+
78+
If a run is interrupted, the result file is left empty. The next run detects
79+
this, skips the snapshot, and prints a notice instead of crashing. The run
80+
after that resumes normal comparison automatically.
81+
82+
## Default approach: bench.sh + compare.py
83+
84+
`bench.sh` is a sourceable shell library that provides a single function:
85+
86+
```sh
87+
bench_run NAME COMMAND [--prepare CMD] [--save-baseline NAME] [--baseline NAME]
88+
```
89+
90+
It handles:
91+
- Building pgdog in release mode (sets `PGDOG_BIN`, skipped if already set)
92+
- Setting `RUST_LOG=error` to suppress log I/O overhead
93+
- Running the command under [hyperfine](https://github.com/sharkdp/hyperfine)
94+
- Passing `--prepare CMD` to hyperfine when provided (runs before each timed iteration)
95+
- Saving results as JSON to `target/pgdog_benches/<name>.json`
96+
- Snapshotting the previous result to `<name>.prev.json` before each run
97+
- Calling `compare.py` to render the comparison table
98+
99+
`compare.py` reads two hyperfine JSON files and prints a Criterion-style table
100+
with mean, median, min, max, stddev, user time, system time, and peak memory
101+
(`max_mem` — peak of `memory_usage_byte` samples), relative change per field, and an overall pass/regression verdict. It is independent of `bench.sh`
102+
and can be called directly:
103+
104+
```sh
105+
python3 benches/compare.py target/pgdog_benches/before.json \
106+
target/pgdog_benches/after.json \
107+
--prev-label before --curr-label after
108+
```
109+
110+
Benchmarks are not required to use this approach. A test can drive hyperfine
111+
differently, use a different timing tool entirely, or produce its own output —
112+
`bench.sh` is a convenience, not a contract.
113+
114+
## Adding a new benchmark
115+
116+
Create `benches/<suite>/run.sh`, source `bench.sh`, and call `bench_run`:
117+
118+
```sh
119+
#!/usr/bin/env bash
120+
set -euo pipefail
121+
122+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
123+
BENCH_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
124+
125+
source "${BENCH_DIR}/bench.sh"
126+
127+
bench_run "<name>" "<command to benchmark>" "$@"
128+
```
129+
130+
To reset state between hyperfine iterations, pass `--prepare`:
131+
132+
```sh
133+
bench_run "<name>" "<command>" --prepare "<reset command>" "$@"
134+
```
135+
136+
The benchmarked script reads these variables from the environment at runtime,
137+
so the same env vars control both the benchmark harness and the integration
138+
test when run directly — the integration test simply uses its own defaults.

benches/bench.sh

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env bash
2+
# Shared benchmark runner. Source this file and call bench_run.
3+
#
4+
# Usage in a test script:
5+
# source "${ROOT_DIR}/benches/bench.sh"
6+
# bench_run "test_name" "command to run" "$@"
7+
#
8+
# Flags forwarded from the caller:
9+
# --prepare CMD command hyperfine runs before each timed run (reset state)
10+
# --save-baseline NAME save results as a named baseline
11+
# --baseline NAME compare against a named baseline (default: previous temp)
12+
#
13+
# Environment:
14+
# RUNS hyperfine run count (default: 3)
15+
# WARMUP hyperfine warmup (default: 1)
16+
17+
BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18+
BENCH_COMPARE="${BENCH_DIR}/compare.py"
19+
20+
bench_run() {
21+
local name="$1" cmd="$2"
22+
shift 2
23+
24+
# -- Parse flags ---------------------------------------------------------
25+
local prepare_cmd="" save_baseline="" baseline=""
26+
while [[ $# -gt 0 ]]; do
27+
case $1 in
28+
--prepare) prepare_cmd="$2"; shift 2 ;;
29+
--save-baseline) save_baseline="$2"; shift 2 ;;
30+
--baseline) baseline="$2"; shift 2 ;;
31+
*) echo "bench_run: unknown flag '$1'" >&2; return 2 ;;
32+
esac
33+
done
34+
35+
# -- Logging ------------------------------------------------------------
36+
# Suppress all log output so it does not skew timing measurements.
37+
export RUST_LOG=error
38+
39+
# -- Build ---------------------------------------------------------------
40+
if [ -z "${PGDOG_BIN:-}" ]; then
41+
local root_dir
42+
root_dir="$(cd "${BENCH_DIR}/.." && pwd)"
43+
echo "Building pgdog (release)..."
44+
cargo build --release --manifest-path "${root_dir}/Cargo.toml"
45+
export PGDOG_BIN="${root_dir}/target/release/pgdog"
46+
fi
47+
echo "Binary: $(${PGDOG_BIN} --version 2>&1 || true)"
48+
echo ""
49+
50+
# -- Paths ---------------------------------------------------------------
51+
local results_dir="${BENCH_DIR}/../target/pgdog_benches"
52+
mkdir -p "${results_dir}"
53+
54+
local latest_file="${results_dir}/${name}.json"
55+
local prev_file prev_label
56+
57+
if [ -n "${baseline}" ]; then
58+
# Named baseline: verify it exists before proceeding.
59+
prev_file="${results_dir}/${name}.${baseline}.json"
60+
prev_label="${baseline}"
61+
if [ ! -f "${prev_file}" ]; then
62+
echo "ERROR: baseline '${baseline}' not found (${prev_file})"
63+
exit 1
64+
fi
65+
else
66+
# Auto baseline: snapshot current latest before overwriting it.
67+
# Skip if the file is empty — an interrupted run leaves a zero-byte file.
68+
prev_file="${results_dir}/${name}.prev.json"
69+
prev_label="latest"
70+
[ -s "${latest_file}" ] && cp "${latest_file}" "${prev_file}"
71+
fi
72+
73+
# -- Run ----------------------------------------------------------------
74+
local hyperfine_args=(
75+
--runs "${RUNS:-3}"
76+
--warmup "${WARMUP:-1}"
77+
--show-output
78+
--export-json "${latest_file}"
79+
--command-name "${name}"
80+
)
81+
if [ -n "${prepare_cmd}" ]; then
82+
hyperfine_args+=(--prepare "${prepare_cmd}")
83+
fi
84+
hyperfine "${hyperfine_args[@]}" "${cmd}"
85+
86+
# -- Save named baseline ------------------------------------------------
87+
if [ -n "${save_baseline}" ]; then
88+
local named_file="${results_dir}/${name}.${save_baseline}.json"
89+
if [ -s "${latest_file}" ]; then
90+
cp "${latest_file}" "${named_file}"
91+
echo "Saved baseline '${save_baseline}' -> ${named_file}"
92+
else
93+
echo "WARNING: run did not complete cleanly, baseline '${save_baseline}' not saved."
94+
fi
95+
fi
96+
97+
# -- Compare ------------------------------------------------------------
98+
echo ""
99+
if [ -s "${prev_file}" ]; then
100+
python3 "${BENCH_COMPARE}" "${prev_file}" "${latest_file}" \
101+
--prev-label "${prev_label}" --curr-label "current"
102+
else
103+
echo "No previous run found -- run again to see comparison."
104+
echo ""
105+
fi
106+
}

benches/compare.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Compare two hyperfine JSON results and print a Criterion-style table.
4+
5+
Usage:
6+
compare.py PREVIOUS.json CURRENT.json [--prev-label NAME] [--curr-label NAME]
7+
"""
8+
9+
import json
10+
import sys
11+
import argparse
12+
13+
GREEN = "\033[32m"
14+
RED = "\033[31m"
15+
YELLOW = "\033[33m"
16+
BOLD = "\033[1m"
17+
RESET = "\033[0m"
18+
19+
20+
def supports_color() -> bool:
21+
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
22+
23+
24+
def color(text: str, code: str) -> str:
25+
return f"{code}{text}{RESET}" if supports_color() else text
26+
27+
28+
def fmt_time(seconds: float | None) -> str:
29+
if seconds is None:
30+
return "N/A"
31+
if seconds >= 60:
32+
return f"{seconds / 60:.2f} min"
33+
if seconds >= 1:
34+
return f"{seconds:.3f} s"
35+
if seconds >= 0.001:
36+
return f"{seconds * 1000:.3f} ms"
37+
return f"{seconds * 1e6:.3f} us"
38+
39+
40+
def fmt_bytes(n: int | float | None) -> str:
41+
if n is None:
42+
return "N/A"
43+
for unit, threshold in (("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)):
44+
if n >= threshold:
45+
return f"{n / threshold:.1f} {unit}"
46+
return f"{int(n)} B"
47+
48+
49+
def fmt_pct(prev: float | None, curr: float | None) -> tuple[str, float | None]:
50+
"""Returns (formatted string, raw delta %) or ('N/A', None)."""
51+
if prev is None or curr is None or prev == 0:
52+
return "N/A", None
53+
delta = (curr - prev) / prev * 100
54+
sign = "+" if delta >= 0 else ""
55+
return f"{sign}{delta:.2f}%", delta
56+
57+
58+
def load(path: str) -> dict:
59+
try:
60+
with open(path) as f:
61+
data = json.load(f)
62+
return data["results"][0]
63+
except (json.JSONDecodeError, KeyError, IndexError, OSError) as e:
64+
print(f"(skipping comparison: {path} is empty or incomplete — {e})")
65+
sys.exit(0)
66+
67+
68+
def main() -> None:
69+
ap = argparse.ArgumentParser(description=__doc__)
70+
ap.add_argument("prev", help="previous baseline JSON")
71+
ap.add_argument("curr", help="current result JSON")
72+
ap.add_argument("--prev-label", default="previous")
73+
ap.add_argument("--curr-label", default="current")
74+
args = ap.parse_args()
75+
76+
prev = load(args.prev)
77+
curr = load(args.curr)
78+
79+
name = curr.get("command", "benchmark")
80+
81+
def peak_mem(result: dict) -> float | None:
82+
samples = result.get("memory_usage_byte") or []
83+
return max(samples) if samples else None
84+
85+
rows = [
86+
("mean", prev.get("mean"), curr.get("mean")),
87+
("median", prev.get("median"), curr.get("median")),
88+
("min", prev.get("min"), curr.get("min")),
89+
("max", prev.get("max"), curr.get("max")),
90+
("stddev", prev.get("stddev"), curr.get("stddev")),
91+
("user", prev.get("user"), curr.get("user")),
92+
("system", prev.get("system"), curr.get("system")),
93+
("max_mem", peak_mem(prev), peak_mem(curr)),
94+
]
95+
w_label = 10
96+
w_time = 14
97+
w_change = 10
98+
99+
header_prev = args.prev_label[:w_time].rjust(w_time)
100+
header_curr = args.curr_label[:w_time].rjust(w_time)
101+
102+
print(f"\n{color(name, BOLD)}\n")
103+
print(f" {'':>{w_label}} {header_prev} {header_curr} {'change':>{w_change}}")
104+
print(f" {'-' * w_label} {'-' * w_time} {'-' * w_time} {'-' * w_change}")
105+
106+
mean_delta = None
107+
for label, pv, cv in rows:
108+
pct_str, delta = fmt_pct(pv, cv)
109+
110+
if label == "mean":
111+
mean_delta = delta
112+
113+
if delta is None or abs(delta) < 1.0:
114+
pct_colored = pct_str
115+
elif delta < 0:
116+
# lower is better for time; higher is worse for memory
117+
pct_colored = color(pct_str, RED if label == "max_mem" else GREEN)
118+
else:
119+
pct_colored = color(pct_str, GREEN if label == "max_mem" else RED)
120+
121+
fmt = fmt_bytes if label == "max_mem" else fmt_time
122+
print(
123+
f" {label:>{w_label}} "
124+
f"{fmt(pv):>{w_time}} "
125+
f"{fmt(cv):>{w_time}} "
126+
f"{pct_colored:>{w_change}}"
127+
)
128+
129+
# Verdict
130+
print()
131+
if mean_delta is None:
132+
pass
133+
elif abs(mean_delta) < 1.0:
134+
print(f" No change (<1%).")
135+
elif mean_delta < 0:
136+
print(f" {color('Performance has improved.', GREEN)}")
137+
else:
138+
print(f" {color('Performance has regressed.', RED)}")
139+
print()
140+
141+
142+
if __name__ == "__main__":
143+
main()

0 commit comments

Comments
 (0)