gh-156241: Add a no-escape fast path to the _json str escapers - #156242
gh-156241: Add a no-escape fast path to the _json str escapers#156242eendebakpt wants to merge 4 commits into
Conversation
ascii_escape_unicode() and escape_unicode() now return the input bulk-copied with surrounding quotes when nothing needs escaping, via a shared quote_unescaped_unicode() helper, like the writer-based write_escaped_ascii()/write_escaped_unicode() already do in-place. This speeds up json.dumps() of clean strings by 1.2x-2.2x. No behavior change.
| @@ -0,0 +1 @@ | |||
| Speed up :func:`json.dump` for strings that need no escaping. | |||
There was a problem hiding this comment.
News entry is for a different issue than the one listed in the PR title.
|
Checked out the changes and ran locally, for small inputs (len < 6) this is a sizable regression (~20%). We should be able to maintain perf for small values while improving performance at scale. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| @@ -0,0 +1 @@ | |||
| Speed up :func:`json.dump` for strings that need no escaping. | |||
There was a problem hiding this comment.
Nit: Does this change also speed up json.dumps?
Thanks for reporting. Locally I cannot reproduce the regression, but I did simplify the code a bit during development. Can you try the latest commit and show which benchmarks you used? |
|
Hi!
Gating the optimization to only run for len of 3 or greater could bring this to closer to neutral : A guess, you may have trouble reproducing the issue due to Benchmark scriptI apologize, this is an over-engineered LLM spawned mess of a benchmark process...but it *should* work.# cases_json_ns.py — declares what to time: (name, stmt, setup, iterations).
_S = "import _json\nea=_json.encode_basestring_ascii\ns=%r\n"
CASES = [(f"len={n}", "ea(s)", _S % ("a"*n), 500000) for n in (0,1,2,3,4,5,6,8,12)]# runner.py
"""Run a case file under one interpreter, print 'name\tseconds' lines."""
import sys, timeit
case_file = sys.argv[1]
ns = {}
exec(open(case_file).read(), ns)
CASES = ns["CASES"]
for name, stmt, setup, number in CASES:
t = min(timeit.repeat(stmt, setup=setup, number=number, repeat=9)) / number
print(f"{name}\t{t!r}")# abn.py """Compare N interpreters over one case file; emit a markdown table.
usage: python3 abn.py <case_file> <label>=<binary> [<label>=<binary> ...] [--rounds=N]
Methodology: all binaries are pinned to the same CPU, each round measures every
binary adjacent in time, and the reported change is the MEDIAN of the per-round
ratios rather than a ratio of global minima. On a noisy/shared box a global
min() will happily lock in one lucky window and invent a result.
"""
import subprocess, sys, statistics, collections
CPU = "8"
args = [a for a in sys.argv[1:] if not a.startswith("--")]
rounds = 5
for a in sys.argv[1:]:
if a.startswith("--rounds"):
rounds = int(a.split("=")[1])
case_file = args[0]
pairs = [a.split("=", 1) for a in args[1:]]
def measure(binary):
out = subprocess.run(["taskset", "-c", CPU, binary, "/tmp/bench/runner.py",
case_file], capture_output=True, text=True,
check=True).stdout
return {n: float(v) for n, v in
(line.split("\t") for line in out.strip().splitlines())}
times = collections.defaultdict(lambda: collections.defaultdict(list))
ratios = collections.defaultdict(lambda: collections.defaultdict(list))
order = []
ref = pairs[0][0]
for _ in range(rounds):
got = {label: measure(binary) for label, binary in pairs}
for name in got[ref]:
if name not in order:
order.append(name)
for label, _bin in pairs:
times[name][label].append(got[label][name])
ratios[name][label].append(got[label][name] / got[ref][name])
def fmt(s):
return f"{s * 1e6:.2f}us" if s < 1e-3 else f"{s * 1e3:.2f}ms"
w = max(len(x) for x in order)
head = f"| {'benchmark'.ljust(w)} |"
sep = f"|{'-' * (w + 2)}|"
for label, _ in pairs:
head += f" {label:>10} |" + ("" if label == ref else f" {'vs ' + ref:>9} |")
sep += "-----------:|" + ("" if label == ref else "----------:|")
print(head)
print(sep)
for name in order:
row = f"| {name.ljust(w)} |"
for label, _ in pairs:
row += f" {fmt(statistics.median(times[name][label])):>10} |"
if label != ref:
pct = (statistics.median(ratios[name][label]) - 1) * 100
row += f" {pct:+9.1f}% |"
print(row)then first the setup: and then it executes as such: |
|
@brittanyrey For benchmarks please use pyperf (or a similar standard benchmarking tool). And make sure you run the benchmarking with an optimized PGO build. The small regression you are seeing might also be binary layout noise. To further improve performance for small ascii strings we could split the |
|
Will do going forward! Thanks. Does adding the following cases to your script repro a ~1.05% increase in overhead for you? If not, no problem! The changes LGTM. Additional CasesCASES = {
"ascii 1": "c",
"ascii 2": "ca",
"ascii 3": "cal",
"ascii 4": "call",
"ascii 5": "calls",
"ascii 6": "callsi",
"ascii 7": "callsig",
"ascii 8": "callsign", |
… variants Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ascii_escape_unicode()andescape_unicode()now return the input bulk-copied with surrounding quotes when nothing needs escaping, via a sharedquote_unescaped_unicode()helper, like the writer-basedwrite_escaped_ascii()/write_escaped_unicode()already do.ensure_ascii=FalseBenchmark script
Run with
python bench_escape.py --fast -o out.jsonper build; compare withpyperf compare_to base.json branch.json --table.