Skip to content

gh-156241: Add a no-escape fast path to the _json str escapers - #156242

Open
eendebakpt wants to merge 4 commits into
python:mainfrom
eendebakpt:json_escape_fastpath
Open

gh-156241: Add a no-escape fast path to the _json str escapers#156242
eendebakpt wants to merge 4 commits into
python:mainfrom
eendebakpt:json_escape_fastpath

Conversation

@eendebakpt

@eendebakpt eendebakpt commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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.

Benchmark main PR
dumps ascii 10 342 ns 349 ns: ~same
dumps ascii 100 508 ns 442 ns: 1.15x faster
dumps ascii 1 kB 1.73 µs 1.19 µs: 1.45x faster
dumps ascii 16 kB 21.4 µs 13.2 µs: 1.63x faster
dumps unicode 1 kB, ensure_ascii=False 3.04 µs 1.80 µs: 1.69x faster
dump list of 100 strings 23.2 µs 21.1 µs: 1.10x faster
dumps escapes 100 531 ns 520 ns: ~same
dumps list of 3 short strings 1.30 µs 1.29 µs: ~same
Benchmark script
"""Benchmark for the _json str-escaper no-escape fast path.

json.dumps(str) uses encode_basestring[_ascii] directly; json.dump() uses
it for every encoded string; dumps() of a container uses the writer-based
escapers (unchanged).
"""
import functools
import io
import json

import pyperf

CASES = {
    "ascii 10": "callsign-7",
    "ascii 100": "The quick brown fox jumps over the lazy dog. " * 2 + "0123456789",
    "ascii 1k": "abcdefghij" * 100,
    "ascii 16k": "abcdefghij" * 1600,
    "escapes 100": ('line\n"quoted"\ttab\\ ' * 5)[:100],
    "unicode 1k": "héllo wörld unicode … " * 47,
}


def dumps_obj(o, **kw):
    json.dumps(o, **kw)


if __name__ == "__main__":
    runner = pyperf.Runner()
    for name, s in CASES.items():
        runner.bench_func("dumps %s" % name, dumps_obj, s)
    runner.bench_func("dumps unicode 1k noascii",
                      functools.partial(dumps_obj, CASES["unicode 1k"],
                                        ensure_ascii=False))
    runner.bench_func("dumps list of 3 short strings", dumps_obj,
                      ["alpha-01", "beta-002", "gamma-03"])

    def dump_obj(o):
        json.dump(o, io.StringIO())
    runner.bench_func("dump list of 3 short strings", dump_obj,
                      ["alpha-01", "beta-002", "gamma-03"])
    runner.bench_func("dump list of 100 strings", dump_obj,
                      ["string number %04d of the benchmark payload" % i
                       for i in range(100)])

Run with python bench_escape.py --fast -o out.json per build; compare with pyperf compare_to base.json branch.json --table.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

News entry is for a different issue than the one listed in the PR title.

@brittanyrey

Copy link
Copy Markdown
Contributor

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.

@brittanyrey brittanyrey Aug 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Does this change also speed up json.dumps?

@eendebakpt

Copy link
Copy Markdown
Contributor Author

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.

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?

@brittanyrey

brittanyrey commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Hi!
After your most recent change the regression is only visible for me on len 0, 1, 2.
Here's the numbers I collected:

len base e32789e vs base 3c3051a vs base
0 36.9 ns 42.1 ns +14.1% 41.8 ns +13.3%
1 39.1 ns 50.8 ns +29.9% 44.4 ns +13.6%
2 41.2 ns 50.9 ns +23.5% 44.5 ns +8.0%
3 44.7 ns 51.8 ns +15.9% 44.6 ns −0.2%
4 47.6 ns 52.2 ns +9.7% 46.0 ns −3.4%
5 50.4 ns 52.9 ns +5.0% 46.2 ns −8.3%
6 52.5 ns 53.5 ns +1.9% 47.0 ns −10.5%
8 59.2 ns 54.2 ns −8.4% 48.5 ns −18.1%
12 69.5 ns 57.8 ns −16.8% 52.6 ns −24.3%

Gating the optimization to only run for len of 3 or greater could bring this to closer to neutral : output_size == input_chars + 2 && input_chars >= 3.

A guess, you may have trouble reproducing the issue due to _json.c being packaged as an .so...so even if you had multiple Python executables for testing, they'd all just pull in the most recently built _json.so. (I hit this, but maybe it's something different on your end)

Benchmark script I 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:

  for ref in 3f99ebe1929:base e32789e915a:c1 3c3051ac742:c2; do
    sha=${ref%%:*}; tag=${ref##*:}
    git worktree add --detach ~/wt/$tag $sha
    ( cd ~/wt/$tag && ./configure && make -j50 )
  done
  md5sum ~/wt/{base,c1,c2}/build/lib.*/_json*.so

and then it executes as such:
python3 abn.py cases_json_ns.py \ base=~/wt/base/python c1=~/wt/c1/python c2=~/wt/c2/python --rounds=11

@eendebakpt

eendebakpt commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@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 quote_unescaped_unicode into quote_unescaped_unicode and quote_unescaped_ascii. Then the new code is doing strictly less work.

@brittanyrey

Copy link
Copy Markdown
Contributor

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 Cases
CASES = {
    "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>
Comment thread Misc/NEWS.d/next/Library/2026-08-13-09-00-00.gh-issue-156241.eScFP1.rst Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants