Skip to content

Commit b9588d9

Browse files
andialbrechtclaude
andcommitted
Unify the benchmark scripts on a shared harness
The four benchmarks arrived over independent branches and had drifted apart in every respect: three were named bench_*.py and one validate_*.py; three ran at import time while one had a main(); each carried its own copy of measure() printing either milliseconds or seconds; and the scaling analysis ranged from none at all, over raw doubling ratios, to a verdict with an exit code (plus a leftover marker string from an external report). Move the shared parts into benchmarks/_harness.py. A benchmark now only declares payload builders and a list of Vector(name, build, sizes, run), and the harness does the running, timing and reporting: - Naming: bench_<code path>.py throughout, so validate_group_comments_dos becomes bench_group_comments and bench_dollar_quote_redos becomes bench_lexer_delimiters. - Invocation: every script is `python benchmarks/bench_X.py` with the same --sizes/--timeout/--vector options, or `make benchmark` for all of them. Exit code 0 means no vector grew super-linearly, 1 means one did. - Output: one table per vector with size, input size, time, ratio to the previous size and status, closed by a scaling exponent and a verdict. The verdict comes from a least-squares fit of log(time) over log(n) rather than a timing threshold, so it stays host independent. Only the largest sizes are fitted: at small n the linear part of the work dominates and pulls a quadratic path's slope towards 1, which is where a regression would slip through. Also drop the grouping caps in bench_grouping, which otherwise reject every size before the measured work happens, and stop timing in seconds in bench_reindent_offset. Checked against 0.5.5, which predates all four fixes: every one of the nine vectors reports super-linear (exponents 1.79 to 3.03) and exits 1, against 0.99 to 1.40 and exit 0 on master. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 519e416 commit b9588d9

10 files changed

Lines changed: 462 additions & 297 deletions

‎AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ This project uses `uv` for dependency and environment management. Common command
2020
### Linting
2121
- `uv run --group dev ruff check sqlparse/` or `make lint`
2222

23+
### Benchmarks
24+
- Run all scaling benchmarks: `make benchmark`
25+
- Run a single one: `uv run --group dev python benchmarks/bench_grouping.py`
26+
- Each script exits non-zero if a measured code path grows super-linearly.
27+
See `benchmarks/README.md` for the options and for adding a benchmark.
28+
2329
### Coverage
2430
- `make coverage` (runs tests with coverage and shows report)
2531
- `make coverage-xml` (generates XML coverage report)

‎Makefile‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ test:
1919
lint:
2020
uv run --group dev ruff check sqlparse/
2121

22+
benchmark:
23+
@for bench in benchmarks/bench_*.py; do \
24+
uv run --group dev python $$bench || exit 1; \
25+
echo; \
26+
done
27+
2228
coverage:
2329
uv run --group dev coverage run -m pytest tests/
2430
uv run --group dev coverage combine

‎benchmarks/README.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Benchmarks
2+
3+
Scaling benchmarks for the code paths that have carried quadratic-CPU
4+
(DoS) regressions. They are developer tools, not part of the test suite:
5+
they are not run by `pytest` and not wired into CI.
6+
7+
Run one, or all of them at once:
8+
9+
```
10+
python benchmarks/bench_grouping.py
11+
make benchmark
12+
```
13+
14+
Every script accepts the same options:
15+
16+
```
17+
--sizes 100,200,400 comma-separated sizes, overriding the defaults
18+
--timeout SECONDS abort a single measurement (default: 30)
19+
--vector SUBSTRING only run matching vectors, repeatable
20+
```
21+
22+
## Output
23+
24+
Each vector prints one row per size and closes with a verdict:
25+
26+
```
27+
wide column lists:
28+
n input time ratio status
29+
500 4402 B 12.3 ms - ok
30+
1000 8902 B 24.6 ms 2.00x ok
31+
2000 18.9 kB 49.1 ms 1.99x ok
32+
scaling exponent 1.00 (1.0 linear, 2.0 quadratic) => linear
33+
```
34+
35+
Absolute timings are host dependent, so the verdict comes from growth
36+
rather than a timing threshold: a least-squares fit of log(time) over
37+
log(n) gives an exponent near 1 for linear and near 2 for quadratic
38+
behaviour. A script exits non-zero as soon as one vector grows
39+
super-linearly, which makes it usable as a regression check for the
40+
advisories it covers.
41+
42+
The `status` column is `ok`, `cap` when a grouping limit rejected the
43+
input before the measured path was reached, or `timeout`. Only `ok` rows
44+
above 1 ms enter the fit; a vector without at least two of them is
45+
reported as `inconclusive`.
46+
47+
## Adding a benchmark
48+
49+
Name the script after the code path it exercises, `bench_<path>.py`, and
50+
keep it to payload builders plus a `VECTORS` list -- running, timing and
51+
reporting live in `_harness.py`:
52+
53+
```python
54+
import sys
55+
56+
from _harness import Vector, main
57+
58+
59+
def wide_select(n):
60+
return 'SELECT ' + ', '.join(f'col_{i}' for i in range(n)) + ' FROM t'
61+
62+
63+
VECTORS = [Vector('wide select', wide_select, (500, 1000, 2000, 4000))]
64+
65+
if __name__ == '__main__':
66+
sys.exit(main('my subject', VECTORS, argv=sys.argv[1:]))
67+
```
68+
69+
A `Vector` is a name, a payload builder `n -> sql`, the sizes to measure
70+
and the entry point under test, which defaults to `sqlparse.parse`. Pass
71+
something else to measure a different one, e.g.
72+
`lambda sql: sqlparse.format(sql, reindent=True)`.

‎benchmarks/_harness.py‎

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Shared harness for the benchmark scripts in this directory.
2+
3+
Every benchmark describes its workload as a list of :class:`Vector`
4+
objects and hands them to :func:`main`, which takes care of running them,
5+
printing a table and deriving a verdict. A vector is a payload builder
6+
``n -> sql`` plus the entry point that consumes it, measured over a range
7+
of sizes::
8+
9+
from _harness import Vector, main
10+
11+
def wide_select(n):
12+
return 'SELECT ' + ', '.join(f'col_{i}' for i in range(n)) + ' FROM t'
13+
14+
VECTORS = [Vector('wide select', wide_select, (500, 1000, 2000, 4000))]
15+
16+
if __name__ == '__main__':
17+
sys.exit(main('grouping', VECTORS))
18+
19+
Absolute timings are host dependent, so the verdict is based on growth
20+
instead: a least-squares fit of log(time) over log(n) yields an exponent
21+
of about 1 for linear behaviour and about 2 for quadratic behaviour.
22+
Benchmarks exit non-zero as soon as one vector grows super-linearly,
23+
which makes them usable as a regression check for the DoS advisories
24+
without hard-coding a timing threshold.
25+
"""
26+
27+
import argparse
28+
import math
29+
import signal
30+
import time
31+
from collections.abc import Callable
32+
from dataclasses import dataclass
33+
34+
import sqlparse
35+
36+
DEFAULT_TIMEOUT = 30
37+
38+
#: Midpoint between linear (1.0) and quadratic (2.0) growth. At or above
39+
#: this the measured code path is reported as super-linear.
40+
SUPERLINEAR_EXPONENT = 1.5
41+
42+
#: Measurements below this are dominated by interpreter noise and are left
43+
#: out of the fit; a vector without enough usable points is inconclusive.
44+
MIN_SIGNIFICANT_MS = 1.0
45+
46+
_LINEAR = 'linear'
47+
_SUPERLINEAR = 'super-linear'
48+
_INCONCLUSIVE = 'inconclusive'
49+
50+
51+
@dataclass
52+
class Vector:
53+
"""One measured code path.
54+
55+
:param name: Shown as the table heading.
56+
:param build: Builds the payload for a given size, ``n -> sql``.
57+
:param sizes: Sizes to measure, ascending.
58+
:param run: Entry point under test, defaults to :func:`sqlparse.parse`.
59+
"""
60+
61+
name: str
62+
build: Callable[[int], str]
63+
sizes: tuple
64+
run: Callable[[str], object] = sqlparse.parse
65+
66+
67+
class Timeout(Exception):
68+
"""Raised when a single measurement exceeds the timeout."""
69+
70+
71+
def _alarm_handler(signum, frame):
72+
raise Timeout
73+
74+
75+
# signal.alarm() is Unix-only; elsewhere a benchmark simply runs to
76+
# completion, which is acceptable for a developer tool.
77+
_CAN_ALARM = hasattr(signal, 'SIGALRM')
78+
if _CAN_ALARM:
79+
signal.signal(signal.SIGALRM, _alarm_handler)
80+
81+
82+
def measure(run, sql, timeout=DEFAULT_TIMEOUT):
83+
"""Run ``run(sql)`` once, return ``(status, elapsed milliseconds)``.
84+
85+
The status is ``ok``, ``cap`` when a grouping limit rejected the input
86+
before the measured path was reached, or ``timeout``.
87+
"""
88+
if _CAN_ALARM:
89+
signal.alarm(timeout)
90+
status = 'ok'
91+
t0 = time.perf_counter()
92+
try:
93+
run(sql)
94+
except sqlparse.exceptions.SQLParseError:
95+
status = 'cap'
96+
except Timeout:
97+
status = 'timeout'
98+
finally:
99+
if _CAN_ALARM:
100+
signal.alarm(0)
101+
return status, (time.perf_counter() - t0) * 1000
102+
103+
104+
def _format_size(n_bytes):
105+
if n_bytes < 10_000:
106+
return f'{n_bytes} B'
107+
return f'{n_bytes / 1000:.1f} kB'
108+
109+
110+
def _exponent(points):
111+
"""Least-squares slope of log(time) over log(n) for ``(n, ms)`` pairs.
112+
113+
Only the largest sizes are fitted. At small n the linear part of the
114+
work (lexing the payload) dominates and pulls the slope of a
115+
quadratic path down towards 1, which is exactly where a regression
116+
would slip through; the asymptotic end of the range is what the
117+
verdict should be based on.
118+
"""
119+
if len(points) < 2:
120+
return None
121+
points = points[-max(3, (len(points) + 1) // 2):]
122+
xs = [math.log(n) for n, _ in points]
123+
ys = [math.log(ms) for _, ms in points]
124+
mean_x = sum(xs) / len(xs)
125+
mean_y = sum(ys) / len(ys)
126+
variance = sum((x - mean_x) ** 2 for x in xs)
127+
if variance == 0:
128+
return None
129+
covariance = sum((x - mean_x) * (y - mean_y)
130+
for x, y in zip(xs, ys, strict=True))
131+
return covariance / variance
132+
133+
134+
def run_vector(vector, timeout=DEFAULT_TIMEOUT):
135+
"""Measure one vector, print its table and return its verdict."""
136+
print(f'{vector.name}:')
137+
print(f' {"n":>8} {"input":>9} {"time":>11} {"ratio":>7} status')
138+
139+
points = []
140+
previous = None
141+
for n in vector.sizes:
142+
sql = vector.build(n)
143+
status, elapsed = measure(vector.run, sql, timeout)
144+
ratio = f'{elapsed / previous:.2f}x' if previous else '-'
145+
print(f' {n:>8} {_format_size(len(sql)):>9} {elapsed:>8.1f} ms '
146+
f'{ratio:>7} {status}')
147+
previous = elapsed
148+
if status == 'ok' and elapsed >= MIN_SIGNIFICANT_MS:
149+
points.append((n, elapsed))
150+
151+
exponent = _exponent(points)
152+
if exponent is None:
153+
print(' too few usable measurements => inconclusive\n')
154+
return _INCONCLUSIVE
155+
156+
verdict = _SUPERLINEAR if exponent >= SUPERLINEAR_EXPONENT else _LINEAR
157+
print(f' scaling exponent {exponent:.2f} '
158+
f'(1.0 linear, 2.0 quadratic) => {verdict}\n')
159+
return verdict
160+
161+
162+
def _parse_args(argv, title, vectors):
163+
parser = argparse.ArgumentParser(
164+
description=f'sqlparse benchmark: {title}',
165+
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
166+
parser.add_argument(
167+
'--sizes', type=lambda s: tuple(int(p) for p in s.split(',')),
168+
help='comma-separated sizes, overriding the per-vector defaults')
169+
parser.add_argument(
170+
'--timeout', type=int, default=DEFAULT_TIMEOUT,
171+
help='abort a single measurement after this many seconds')
172+
parser.add_argument(
173+
'--vector', action='append', metavar='SUBSTRING',
174+
help='only run vectors whose name contains SUBSTRING '
175+
f'(available: {", ".join(v.name for v in vectors)})')
176+
return parser.parse_args(argv)
177+
178+
179+
def main(title, vectors, note=None, argv=None):
180+
"""Run `vectors` and return a process exit code.
181+
182+
Exit code 0 means no vector grew super-linearly, 1 means at least one
183+
did -- i.e. the quadratic behaviour a fix was meant to remove is back.
184+
"""
185+
args = _parse_args(argv, title, vectors)
186+
if args.vector:
187+
wanted = [s.lower() for s in args.vector]
188+
vectors = [v for v in vectors
189+
if any(s in v.name.lower() for s in wanted)]
190+
if not vectors:
191+
print(f'No vector matches {args.vector}.')
192+
return 2
193+
194+
print(f'sqlparse benchmark: {title}')
195+
if note:
196+
print(note)
197+
print()
198+
199+
verdicts = [
200+
run_vector(
201+
vector if args.sizes is None
202+
else Vector(vector.name, vector.build, args.sizes, vector.run),
203+
args.timeout)
204+
for vector in vectors
205+
]
206+
207+
superlinear = verdicts.count(_SUPERLINEAR)
208+
inconclusive = verdicts.count(_INCONCLUSIVE)
209+
if superlinear:
210+
overall = _SUPERLINEAR
211+
elif inconclusive:
212+
overall = f'{_LINEAR} where measurable'
213+
else:
214+
overall = _LINEAR
215+
plural = '' if len(verdicts) == 1 else 's'
216+
summary = f'{len(verdicts)} vector{plural}, {superlinear} super-linear'
217+
if inconclusive:
218+
summary += f', {inconclusive} inconclusive'
219+
print(f'verdict: {overall} ({summary})')
220+
return 1 if superlinear else 0

0 commit comments

Comments
 (0)