Skip to content

Commit 7c4e2e5

Browse files
committed
Merge remote-tracking branch 'origin/main' into dz-csv-export-table-2
2 parents 842f291 + 5e27319 commit 7c4e2e5

42 files changed

Lines changed: 13364 additions & 13597 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

meta/bench/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# LQP Parser & Pretty-Printer Benchmarks
2+
3+
Microbenchmarks comparing the current generated LL(k) parser and pretty-printer
4+
(`lqp>=0.3.0`) against the old Lark-based implementation (`lqp==0.2.3`).
5+
6+
## What's measured
7+
8+
- **Parse**: full text-to-protobuf pipeline. The old version goes through an
9+
intermediate IR (`text → IR → protobuf`); the new version parses directly
10+
(`text → protobuf`).
11+
- **Pretty-print**: protobuf/IR to text. The old version prints from its IR;
12+
the new version prints from protobuf messages.
13+
14+
All `.lqp` files under `tests/lqp/` are used as inputs. Files that fail to
15+
parse under either version are skipped (the old parser doesn't support some
16+
newer syntax).
17+
18+
## Running
19+
20+
```
21+
uv run --no-project python meta/bench/run.py [iterations]
22+
```
23+
24+
`iterations` defaults to 20. Each file is parsed and pretty-printed that many
25+
times; the reported time is the per-iteration average.
26+
27+
The runner uses `uv run --with` to create ephemeral environments for each
28+
version — no manual venv setup needed.
29+
30+
## Running a single version
31+
32+
To benchmark only one version (outputs JSON):
33+
34+
```
35+
# Old (Lark-based)
36+
uv run --no-project --with "lqp==0.2.3" python meta/bench/benchmark.py
37+
38+
# New (generated)
39+
uv run --no-project --with ./sdks/python python meta/bench/benchmark.py
40+
```
41+
42+
Control iterations via `BENCH_ITERATIONS` env var.
43+
44+
## Files
45+
46+
- `run.py` — orchestrator: runs both versions, prints comparison table.
47+
- `benchmark.py` — timing logic: auto-detects which `lqp` is installed,
48+
benchmarks all test files, outputs JSON to stdout.

meta/bench/benchmark.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""
2+
Benchmark LQP parsing and pretty-printing.
3+
4+
Auto-detects whether the old (Lark-based, lqp<=0.2.3) or new (generated LL(k),
5+
lqp>=0.3.0) implementation is installed, and benchmarks accordingly.
6+
7+
Outputs JSON results to stdout.
8+
"""
9+
10+
import json
11+
import os
12+
import sys
13+
import timeit
14+
from pathlib import Path
15+
16+
WARMUP_ITERATIONS = 3
17+
18+
19+
def detect_version():
20+
"""Detect which lqp version is installed based on available modules."""
21+
try:
22+
from lqp.gen.parser import parse # noqa: F401
23+
24+
return "new"
25+
except ImportError:
26+
pass
27+
try:
28+
from lqp.parser import parse_lqp # noqa: F401
29+
30+
return "old"
31+
except ImportError:
32+
pass
33+
print("error: no lqp package found", file=sys.stderr)
34+
sys.exit(1)
35+
36+
37+
def find_lqp_files(tests_dir: Path):
38+
"""Find all .lqp test files."""
39+
lqp_dir = tests_dir / "lqp"
40+
if not lqp_dir.is_dir():
41+
print(f"error: {lqp_dir} not found", file=sys.stderr)
42+
sys.exit(1)
43+
return sorted(lqp_dir.glob("*.lqp"))
44+
45+
46+
def warmup(fn):
47+
"""Run a function several times to warm up caches."""
48+
for _ in range(WARMUP_ITERATIONS):
49+
fn()
50+
51+
52+
def bench_old(lqp_files, iterations):
53+
"""Benchmark the old Lark-based parser and pretty-printer."""
54+
from lqp.emit import ir_to_proto
55+
from lqp.parser import parse_lqp
56+
from lqp.print import to_string
57+
58+
results = []
59+
for path in lqp_files:
60+
name = path.stem
61+
text = path.read_text()
62+
filename = str(path)
63+
64+
try:
65+
ir_node = parse_lqp(filename, text)
66+
_ = ir_to_proto(ir_node)
67+
except Exception:
68+
print(f"skip {name} (parse failed)", file=sys.stderr)
69+
results.append(
70+
{
71+
"file": name,
72+
"parse_ms": None,
73+
"parse_emit_ms": None,
74+
"pretty_ms": None,
75+
}
76+
)
77+
continue
78+
79+
def do_parse():
80+
return parse_lqp(filename, text)
81+
82+
def do_parse_emit():
83+
return ir_to_proto(parse_lqp(filename, text))
84+
85+
def do_pretty():
86+
return to_string(ir_node)
87+
88+
warmup(do_parse_emit)
89+
parse_time = timeit.timeit(do_parse, number=iterations)
90+
parse_emit_time = timeit.timeit(do_parse_emit, number=iterations)
91+
92+
warmup(do_pretty)
93+
pretty_time = timeit.timeit(do_pretty, number=iterations)
94+
95+
results.append(
96+
{
97+
"file": name,
98+
"parse_ms": parse_time / iterations * 1000,
99+
"parse_emit_ms": parse_emit_time / iterations * 1000,
100+
"pretty_ms": pretty_time / iterations * 1000,
101+
}
102+
)
103+
104+
return results
105+
106+
107+
def bench_new(lqp_files, iterations):
108+
"""Benchmark the new generated parser and pretty-printer."""
109+
from lqp.gen.parser import parse
110+
from lqp.gen.pretty import pretty
111+
112+
results = []
113+
for path in lqp_files:
114+
name = path.stem
115+
text = path.read_text()
116+
117+
try:
118+
proto = parse(text)
119+
except Exception:
120+
print(f"skip {name} (parse failed)", file=sys.stderr)
121+
results.append({"file": name, "parse_ms": None, "pretty_ms": None})
122+
continue
123+
124+
def do_parse():
125+
return parse(text)
126+
127+
def do_pretty():
128+
return pretty(proto)
129+
130+
warmup(do_parse)
131+
parse_time = timeit.timeit(do_parse, number=iterations)
132+
133+
warmup(do_pretty)
134+
pretty_time = timeit.timeit(do_pretty, number=iterations)
135+
136+
results.append(
137+
{
138+
"file": name,
139+
"parse_ms": parse_time / iterations * 1000,
140+
"pretty_ms": pretty_time / iterations * 1000,
141+
}
142+
)
143+
144+
return results
145+
146+
147+
def main():
148+
iterations = int(os.environ.get("BENCH_ITERATIONS", "20"))
149+
default_tests_dir = Path(__file__).resolve().parent.parent.parent / "tests"
150+
tests_dir_env = os.environ.get("BENCH_TESTS_DIR")
151+
tests_dir = Path(tests_dir_env) if tests_dir_env else default_tests_dir
152+
153+
version = detect_version()
154+
lqp_files = find_lqp_files(tests_dir)
155+
156+
if version == "old":
157+
results = bench_old(lqp_files, iterations)
158+
else:
159+
results = bench_new(lqp_files, iterations)
160+
161+
output = {
162+
"version": version,
163+
"iterations": iterations,
164+
"files": len(lqp_files),
165+
"results": results,
166+
}
167+
json.dump(output, sys.stdout, indent=2)
168+
print()
169+
170+
171+
if __name__ == "__main__":
172+
main()

meta/bench/run.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Run LQP parser/pretty-printer benchmarks comparing old (Lark) vs new (generated).
4+
5+
Uses `uv run --with` to create ephemeral environments for each version.
6+
"""
7+
8+
import json
9+
import os
10+
import subprocess
11+
import sys
12+
from pathlib import Path
13+
14+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
15+
BENCHMARK_SCRIPT = Path(__file__).resolve().parent / "benchmark.py"
16+
SDK_PYTHON = REPO_ROOT / "sdks" / "python"
17+
TESTS_DIR = REPO_ROOT / "tests"
18+
OLD_PACKAGE = "lqp==0.2.3"
19+
20+
21+
def run_benchmark(label: str, with_pkg: str, iterations: int):
22+
"""Run benchmark.py in an ephemeral uv environment."""
23+
env = {
24+
**os.environ,
25+
"BENCH_TESTS_DIR": str(TESTS_DIR),
26+
"BENCH_ITERATIONS": str(iterations),
27+
}
28+
cmd = [
29+
"uv",
30+
"run",
31+
"--no-project",
32+
"--with",
33+
with_pkg,
34+
"python",
35+
str(BENCHMARK_SCRIPT),
36+
]
37+
print(f"Running {label} benchmarks...", file=sys.stderr)
38+
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
39+
if result.stderr:
40+
print(result.stderr, end="", file=sys.stderr)
41+
if result.returncode != 0:
42+
print(f"error: {label} benchmark failed", file=sys.stderr)
43+
sys.exit(1)
44+
return json.loads(result.stdout)
45+
46+
47+
def fmt_speedup(old_ms, new_ms):
48+
"""Format a speedup ratio."""
49+
if new_ms > 0:
50+
return f"{old_ms / new_ms:>7.2f}x"
51+
return f"{'inf':>7}"
52+
53+
54+
def print_parse_table(old_by_file, new_by_file, all_files):
55+
"""Print parser comparison table."""
56+
print()
57+
print("## Parser")
58+
print()
59+
hdr = f"{'file':<25} {'old parse':>10} {'old p+emit':>11} {'new parse':>10} {'speedup':>8}"
60+
print(hdr)
61+
print("-" * len(hdr))
62+
63+
total_old = 0.0
64+
total_old_emit = 0.0
65+
total_new = 0.0
66+
skipped = 0
67+
compared = 0
68+
69+
for f in all_files:
70+
o = old_by_file[f]
71+
n = new_by_file[f]
72+
73+
po = o["parse_ms"]
74+
peo = o.get("parse_emit_ms")
75+
pn = n["parse_ms"]
76+
77+
if po is None or pn is None:
78+
print(f"{f:<25} {'skip':>10} {'skip':>11} {'skip':>10} {'':>8}")
79+
skipped += 1
80+
continue
81+
82+
compared += 1
83+
pe = peo if peo else po
84+
total_old += po
85+
total_old_emit += pe
86+
total_new += pn
87+
88+
print(f"{f:<25} {po:>9.3f}ms {pe:>10.3f}ms {pn:>9.3f}ms {fmt_speedup(pe, pn)}")
89+
90+
print("-" * len(hdr))
91+
print(
92+
f"{'TOTAL':<25} {total_old:>9.3f}ms {total_old_emit:>10.3f}ms"
93+
f" {total_new:>9.3f}ms {fmt_speedup(total_old_emit, total_new)}"
94+
)
95+
96+
print()
97+
print("old parse = Lark parse to IR")
98+
print("old p+emit = Lark parse to IR + ir_to_proto")
99+
print("new parse = generated LL(k) parser to protobuf")
100+
print("speedup = old p+emit / new parse")
101+
102+
return compared, skipped
103+
104+
105+
def print_pretty_table(old_by_file, new_by_file, all_files):
106+
"""Print pretty-printer comparison table."""
107+
print()
108+
print("## Pretty-printer")
109+
print()
110+
hdr = f"{'file':<25} {'old':>10} {'new':>10} {'speedup':>8}"
111+
print(hdr)
112+
print("-" * len(hdr))
113+
114+
total_old = 0.0
115+
total_new = 0.0
116+
117+
for f in all_files:
118+
o = old_by_file[f]
119+
n = new_by_file[f]
120+
121+
pro = o["pretty_ms"]
122+
prn = n["pretty_ms"]
123+
124+
if pro is None or prn is None:
125+
print(f"{f:<25} {'skip':>10} {'skip':>10} {'':>8}")
126+
continue
127+
128+
total_old += pro
129+
total_new += prn
130+
131+
print(f"{f:<25} {pro:>9.3f}ms {prn:>9.3f}ms {fmt_speedup(pro, prn)}")
132+
133+
print("-" * len(hdr))
134+
print(
135+
f"{'TOTAL':<25} {total_old:>9.3f}ms {total_new:>9.3f}ms {fmt_speedup(total_old, total_new)}"
136+
)
137+
138+
print()
139+
print("old = IR to text")
140+
print("new = protobuf to text")
141+
142+
143+
def print_comparison(old_data, new_data):
144+
"""Print formatted comparison tables."""
145+
old_by_file = {r["file"]: r for r in old_data["results"]}
146+
new_by_file = {r["file"]: r for r in new_data["results"]}
147+
all_files = sorted(set(old_by_file) & set(new_by_file))
148+
149+
compared, skipped = print_parse_table(old_by_file, new_by_file, all_files)
150+
print_pretty_table(old_by_file, new_by_file, all_files)
151+
152+
print()
153+
print(f"Iterations per file: {old_data['iterations']}")
154+
print(f"Files compared: {compared}")
155+
if skipped:
156+
print(f"Files skipped: {skipped} (unsupported by old parser)")
157+
158+
159+
def main():
160+
iterations = int(sys.argv[1]) if len(sys.argv) > 1 else 20
161+
162+
old_data = run_benchmark("old (Lark)", OLD_PACKAGE, iterations)
163+
new_data = run_benchmark("new (generated)", str(SDK_PYTHON), iterations)
164+
print_comparison(old_data, new_data)
165+
166+
167+
if __name__ == "__main__":
168+
main()

meta/pyrefly.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ project_includes = ["**/*"]
22
project_excludes = [
33
"**/.[!/.]*",
44
"**/*venv/**/*",
5+
"bench",
56
"build",
67
]
78
search_path = ["."]

0 commit comments

Comments
 (0)