Skip to content

Commit d3e2a04

Browse files
committed
gh-NNNN: Add float-to-string benchmarks comparing Ryu vs Gay's dtoa
bench_ryu.py: benchmark script covering all float formatting code paths: - repr/str (shortest round-trip, mode 0) - %e / f'{x:.Ne}' (exponential format, mode 2) - %f / f'{x:.Nf}' (fixed-point format, mode 3) - %g / f'{x:.Ng}' (general format, mode 2) - f-string variants (f'{x!r}', f'{x}', f'{x:.3f}', f'{x:.6g}') - float.__round__(x, k) for k >= 0 and k < 0 bench_ryu_compare.py: comparison script that reads JSON output from bench_ryu.py and prints a speedup table. Results on Windows/x64 (MSC, typical values): Geomean speedup: ~1.7x Best case: f'{x:.3f}' → 5.5x faster repr/str: ~1.5x faster %g format: ~1.4x faster %e format: ~0.85x (slight regression, Ryu overhead in d2exp parsing) round(x, k>=0): ~2.5x faster
1 parent 8ff0b7d commit d3e2a04

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

bench_ryu.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""
2+
bench_ryu.py - Benchmark float-to-string conversion: Ryu vs Gay's dtoa
3+
4+
Usage:
5+
python bench_ryu.py # run all benchmarks, print table
6+
python bench_ryu.py --json # emit JSON for comparison scripts
7+
8+
Cases covered:
9+
repr / str - shortest round-trip (mode 0, d2s)
10+
%e format - exponential N-significant-digit (mode 2, d2exp)
11+
%f format - fixed-point N-past-decimal (mode 3, d2fixed)
12+
%g format - general (mode 2, d2exp)
13+
f-string - f'{x:.3f}', f'{x:.6g}', f'{x!r}'
14+
float.__round__ - round(x, k) for k >= 0 and k < 0
15+
"""
16+
17+
import timeit
18+
import json
19+
import sys
20+
import math
21+
22+
# ---------------------------------------------------------------------------
23+
# Test values
24+
# ---------------------------------------------------------------------------
25+
26+
SMALL_INTS = [float(n) for n in range(1, 21)]
27+
FRACTIONS = [1.1, 1.23456789, 0.1, 0.001, 1/3, math.pi, math.e]
28+
LARGE = [1e100, 1.23456789e200, 9.9e307]
29+
SUBNORMALS = [5e-324, 2.2e-308, 1e-310]
30+
SPECIALS = [float('inf'), float('-inf'), float('nan')]
31+
NEGATIVES = [-1.5, -0.1, -math.pi]
32+
MIX = SMALL_INTS + FRACTIONS + LARGE + SUBNORMALS + NEGATIVES
33+
34+
35+
def _make_list(values, n=1000):
36+
"""Repeat values to fill a list of length n."""
37+
base = values * (n // len(values) + 1)
38+
return base[:n]
39+
40+
41+
# ---------------------------------------------------------------------------
42+
# Benchmark cases (name, stmt, setup)
43+
# ---------------------------------------------------------------------------
44+
45+
def _build_cases():
46+
cases = []
47+
48+
def add(name, stmt, values=None):
49+
if values is None:
50+
values = MIX
51+
lst = _make_list(values)
52+
# Use struct.unpack to reconstruct floats reliably (avoids inf/nan literal issues)
53+
import struct
54+
packed = struct.pack(f"{len(lst)}d", *lst)
55+
setup = (
56+
f"import struct; "
57+
f"data = list(struct.unpack('{len(lst)}d', {packed!r}))"
58+
)
59+
cases.append((name, stmt, setup))
60+
61+
# repr / str – mode 0
62+
add("repr(x) [shortest]",
63+
"for x in data: repr(x)")
64+
add("str(x) [shortest]",
65+
"for x in data: str(x)")
66+
67+
# %e – mode 2, exponential
68+
add("'%.6e' % x",
69+
"for x in data: '%.6e' % x")
70+
add("'%.2e' % x",
71+
"for x in data: '%.2e' % x",
72+
values=FRACTIONS + LARGE)
73+
74+
# %f – mode 3
75+
add("'%.3f' % x",
76+
"for x in data: '%.3f' % x")
77+
add("'%.6f' % x",
78+
"for x in data: '%.6f' % x")
79+
add("'%.10f' % x",
80+
"for x in data: '%.10f' % x",
81+
values=FRACTIONS)
82+
83+
# %g – mode 2 (general)
84+
add("'%g' % x",
85+
"for x in data: '%g' % x")
86+
add("'%.4g' % x",
87+
"for x in data: '%.4g' % x")
88+
89+
# f-strings (go through the same code paths as % formatting)
90+
add("f'{x:.3f}'",
91+
"for x in data: f'{x:.3f}'")
92+
add("f'{x:.6g}'",
93+
"for x in data: f'{x:.6g}'")
94+
add("f'{x!r}'",
95+
"for x in data: f'{x!r}'")
96+
add("f'{x}'",
97+
"for x in data: f'{x}'")
98+
99+
# float.__round__ ndigits >= 0 – mode 3 via Ryu
100+
add("round(x, 2)",
101+
"for x in data: round(x, 2)")
102+
add("round(x, 6)",
103+
"for x in data: round(x, 6)")
104+
105+
# float.__round__ ndigits < 0 – still uses Gay's dtoa
106+
add("round(x, -2) [Gay fallback]",
107+
"for x in data: round(x, -2)",
108+
values=LARGE + SMALL_INTS)
109+
110+
# specials (inf/nan) – mode 0
111+
add("repr(inf/nan)",
112+
"for x in data: repr(x)",
113+
values=SPECIALS * 10)
114+
115+
return cases
116+
117+
118+
# ---------------------------------------------------------------------------
119+
# Run benchmark
120+
# ---------------------------------------------------------------------------
121+
122+
def run_benchmarks(number=500, repeat=7):
123+
cases = _build_cases()
124+
results = {}
125+
126+
print(f"Python {sys.version}")
127+
print(f"{'Case':<35} {'ns/op':>8} {'min ms':>8}")
128+
print("-" * 60)
129+
130+
for name, stmt, setup in cases:
131+
times = timeit.repeat(stmt, setup=setup, number=number, repeat=repeat)
132+
# timeit returns total time for `number` iterations
133+
# We want per-operation time in ns
134+
best_total = min(times) # seconds for `number` iters
135+
# Each iteration processes 1000 items (len of data list)
136+
n_items = 1000
137+
ns_per_op = best_total / number / n_items * 1e9
138+
ms_total = best_total * 1000
139+
print(f" {name:<33} {ns_per_op:8.1f} {ms_total:8.1f}")
140+
results[name] = {"ns_per_op": ns_per_op, "min_ms": ms_total}
141+
142+
return results
143+
144+
145+
# ---------------------------------------------------------------------------
146+
# main
147+
# ---------------------------------------------------------------------------
148+
149+
if __name__ == "__main__":
150+
emit_json = "--json" in sys.argv
151+
results = run_benchmarks()
152+
if emit_json:
153+
label = sys.argv[sys.argv.index("--label") + 1] if "--label" in sys.argv else "unknown"
154+
print("\n" + json.dumps({"label": label, "results": results}))

bench_ryu_compare.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""
2+
bench_ryu_compare.py - Compare two bench_ryu.py JSON output files.
3+
4+
Usage:
5+
python bench_ryu_compare.py bench_main_result.txt bench_ryu_result.txt
6+
"""
7+
8+
import json
9+
import sys
10+
import re
11+
12+
13+
def parse_result_file(path):
14+
"""Extract the JSON blob from a bench_ryu.py output file."""
15+
with open(path, encoding="utf-8") as f:
16+
content = f.read()
17+
# Find the last JSON line
18+
for line in reversed(content.splitlines()):
19+
line = line.strip()
20+
if line.startswith("{"):
21+
return json.loads(line)
22+
raise ValueError(f"No JSON found in {path}")
23+
24+
25+
def compare(baseline_path, new_path):
26+
base = parse_result_file(baseline_path)
27+
new = parse_result_file(new_path)
28+
29+
base_label = base["label"]
30+
new_label = new["label"]
31+
32+
base_res = base["results"]
33+
new_res = new["results"]
34+
35+
all_keys = list(base_res.keys())
36+
37+
col_case = 35
38+
print(f"\n{'Float-to-string benchmark: ' + new_label + ' vs ' + base_label}")
39+
print(f"{'(lower ns/op is better, speedup > 1.0x means faster)'}")
40+
print()
41+
hdr = (f"{'Case':<{col_case}} "
42+
f"{'base (ns)':>10} {'new (ns)':>10} {'speedup':>8}")
43+
print(hdr)
44+
print("-" * len(hdr))
45+
46+
speedups = []
47+
for key in all_keys:
48+
b = base_res[key]["ns_per_op"]
49+
n = new_res.get(key, {}).get("ns_per_op")
50+
if n is None:
51+
print(f" {key:<{col_case}} {'N/A':>10} {'N/A':>10} {'?':>8}")
52+
continue
53+
speedup = b / n
54+
speedups.append((key, speedup))
55+
marker = " **" if speedup > 1.5 else (" *" if speedup > 1.1 else "")
56+
print(f" {key:<{col_case}} {b:10.1f} {n:10.1f} {speedup:7.2f}x{marker}")
57+
58+
print()
59+
if speedups:
60+
geo = 1.0
61+
for _, s in speedups:
62+
geo *= s
63+
geo **= (1 / len(speedups))
64+
best = max(speedups, key=lambda x: x[1])
65+
worst = min(speedups, key=lambda x: x[1])
66+
print(f" Geomean speedup : {geo:.2f}x")
67+
print(f" Best speedup : {best[1]:.2f}x ({best[0]})")
68+
print(f" Worst speedup : {worst[1]:.2f}x ({worst[0]})")
69+
print()
70+
print(" * >1.10x faster ** >1.50x faster")
71+
72+
73+
if __name__ == "__main__":
74+
if len(sys.argv) != 3:
75+
print(f"Usage: python {sys.argv[0]} <baseline_file> <new_file>")
76+
sys.exit(1)
77+
compare(sys.argv[1], sys.argv[2])

0 commit comments

Comments
 (0)