Skip to content

Commit 0cf30bc

Browse files
committed
chore(bench): add repeatable decode and encode snapshot scripts
Provide local benchmark scripts for C1/C2/C3 decode snapshots and deep encode traversal comparisons.
1 parent dd16195 commit 0cf30bc

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

scripts/bench_decode_snapshot.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
"""Local decode benchmark snapshot (not used by CI).
3+
4+
Usage:
5+
python scripts/bench_decode_snapshot.py
6+
python scripts/bench_decode_snapshot.py --warmups 3 --samples 5
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import statistics
13+
import sys
14+
import time
15+
from dataclasses import dataclass
16+
from pathlib import Path
17+
18+
19+
ROOT = Path(__file__).resolve().parents[1]
20+
sys.path.insert(0, str(ROOT / "src"))
21+
22+
from qs_codec import DecodeOptions, decode
23+
24+
25+
@dataclass(frozen=True)
26+
class DecodeCase:
27+
name: str
28+
count: int
29+
comma: bool
30+
utf8_sentinel: bool
31+
value_len: int
32+
iterations: int
33+
34+
35+
CASES = (
36+
DecodeCase(name="C1", count=100, comma=False, utf8_sentinel=False, value_len=8, iterations=120),
37+
DecodeCase(name="C2", count=1000, comma=False, utf8_sentinel=False, value_len=40, iterations=16),
38+
DecodeCase(name="C3", count=1000, comma=True, utf8_sentinel=True, value_len=40, iterations=16),
39+
)
40+
41+
42+
def make_value(length: int, seed: int) -> str:
43+
out: list[str] = []
44+
state = ((seed * 2654435761) + 1013904223) & 0xFFFFFFFF
45+
for _ in range(length):
46+
state ^= (state << 13) & 0xFFFFFFFF
47+
state ^= (state >> 17) & 0xFFFFFFFF
48+
state ^= (state << 5) & 0xFFFFFFFF
49+
50+
x = state % 62
51+
if x < 10:
52+
ch = chr(0x30 + x)
53+
elif x < 36:
54+
ch = chr(0x41 + (x - 10))
55+
else:
56+
ch = chr(0x61 + (x - 36))
57+
out.append(ch)
58+
59+
return "".join(out)
60+
61+
62+
def build_query(count: int, comma_lists: bool, utf8_sentinel: bool, value_len: int) -> str:
63+
parts: list[str] = []
64+
if utf8_sentinel:
65+
parts.append("utf8=%E2%9C%93")
66+
67+
for i in range(count):
68+
key = f"k{i}"
69+
value = "a,b,c" if comma_lists and i % 10 == 0 else make_value(value_len, i)
70+
parts.append(f"{key}={value}")
71+
72+
return "&".join(parts)
73+
74+
75+
def measure_case(case: DecodeCase, warmups: int, samples: int) -> tuple[float, int]:
76+
query = build_query(case.count, case.comma, case.utf8_sentinel, case.value_len)
77+
options = DecodeOptions(
78+
comma=case.comma,
79+
parse_lists=True,
80+
parameter_limit=float("inf"),
81+
raise_on_limit_exceeded=False,
82+
interpret_numeric_entities=False,
83+
charset_sentinel=case.utf8_sentinel,
84+
ignore_query_prefix=False,
85+
)
86+
87+
for _ in range(warmups):
88+
decode(query, options=options)
89+
90+
measurements: list[float] = []
91+
key_count = 0
92+
for _ in range(samples):
93+
start = time.perf_counter()
94+
parsed: dict[str, object] = {}
95+
for _ in range(case.iterations):
96+
parsed = decode(query, options=options)
97+
elapsed = (time.perf_counter() - start) * 1000.0 / case.iterations
98+
measurements.append(elapsed)
99+
key_count = len(parsed)
100+
101+
return statistics.median(measurements), key_count
102+
103+
104+
def parse_args() -> argparse.Namespace:
105+
parser = argparse.ArgumentParser(description="Benchmark decode performance snapshot cases (C1/C2/C3).")
106+
parser.add_argument("--warmups", type=int, default=5, help="warm-up runs per case")
107+
parser.add_argument("--samples", type=int, default=7, help="timed samples per case")
108+
return parser.parse_args()
109+
110+
111+
def main() -> None:
112+
args = parse_args()
113+
if args.warmups < 0:
114+
raise ValueError("--warmups must be >= 0")
115+
if args.samples <= 0:
116+
raise ValueError("--samples must be > 0")
117+
118+
print(f"qs.py decode perf snapshot (median of {args.samples} samples)")
119+
print("Decode (public API):")
120+
for case in CASES:
121+
median_ms, key_count = measure_case(case, warmups=args.warmups, samples=args.samples)
122+
print(
123+
" "
124+
f"{case.name}: count={str(case.count).rjust(4)}, "
125+
f"comma={str(case.comma).ljust(5)}, "
126+
f"utf8={str(case.utf8_sentinel).ljust(5)}, "
127+
f"len={str(case.value_len).rjust(2)}: "
128+
f"{median_ms:7.3f} ms/op | keys={key_count}"
129+
)
130+
131+
132+
if __name__ == "__main__":
133+
main()

scripts/bench_encode_depth.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python3
2+
"""Local deep-encode benchmark (not used by CI).
3+
4+
Usage:
5+
python scripts/bench_encode_depth.py
6+
python scripts/bench_encode_depth.py --runs 5 --depths 2000 5000 12000
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import statistics
13+
import sys
14+
import time
15+
import typing as t
16+
from pathlib import Path
17+
18+
19+
ROOT = Path(__file__).resolve().parents[1]
20+
sys.path.insert(0, str(ROOT / "src"))
21+
22+
from qs_codec import encode
23+
from qs_codec.models.encode_options import EncodeOptions
24+
25+
26+
def make_nested(depth: int) -> t.Dict[str, t.Any]:
27+
data: t.Dict[str, t.Any] = {"leaf": "x"}
28+
for _ in range(depth):
29+
data = {"a": data}
30+
return data
31+
32+
33+
def run_once(depth: int) -> float:
34+
data = make_nested(depth)
35+
start = time.perf_counter()
36+
result = encode(data, options=EncodeOptions(encode=False))
37+
elapsed = time.perf_counter() - start
38+
if not result.endswith("=x"):
39+
raise RuntimeError(f"unexpected encoded output for depth={depth}")
40+
return elapsed
41+
42+
43+
def parse_args() -> argparse.Namespace:
44+
parser = argparse.ArgumentParser(description="Benchmark deep encode performance.")
45+
parser.add_argument("--runs", type=int, default=3, help="timed runs per depth")
46+
parser.add_argument("--warmups", type=int, default=1, help="warm-up runs per depth")
47+
parser.add_argument(
48+
"--depths",
49+
type=int,
50+
nargs="+",
51+
default=[2000, 5000, 12000],
52+
help="depth values to benchmark",
53+
)
54+
return parser.parse_args()
55+
56+
57+
def main() -> None:
58+
args = parse_args()
59+
if args.runs <= 0:
60+
raise ValueError("--runs must be > 0")
61+
if args.warmups < 0:
62+
raise ValueError("--warmups must be >= 0")
63+
if not args.depths:
64+
raise ValueError("--depths must not be empty")
65+
66+
print(f"python={sys.version.split()[0]} runs={args.runs} warmups={args.warmups}")
67+
for depth in args.depths:
68+
for _ in range(args.warmups):
69+
run_once(depth)
70+
71+
times = [run_once(depth) for _ in range(args.runs)]
72+
median = statistics.median(times)
73+
print(f"depth={depth} median={median:.6f}s " f"runs=[{', '.join(f'{t_:.6f}' for t_ in times)}]")
74+
75+
76+
if __name__ == "__main__":
77+
main()

0 commit comments

Comments
 (0)