From 36bd06b749bc18cf2951eb5b4f1ac103e5d0c88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 3 Jun 2026 11:10:06 -0700 Subject: [PATCH 1/2] gh-150875: Speed up JSON string encoding for long ASCII strings ascii_escape_size() scans each string one character at a time to size the escaped output, and write_escaped_ascii() writes it verbatim when nothing needs escaping. For the one-byte representation, detect that no-escape case eight bytes at a time and return the verbatim size directly; a length guard keeps short strings on the original per-character loop. Strings that need escaping and non-Latin-1 strings keep the current path. Output is byte-identical, verified against test_json and a 199-case dumps differential in both ensure_ascii modes. dumps of long ASCII strings runs up to 5.3x faster; short keys, escaped strings, and non-ASCII are unaffected. --- ...-06-03-11-10-06.gh-issue-150875.WK9OTj.rst | 4 ++ Modules/_json.c | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-06-03-11-10-06.gh-issue-150875.WK9OTj.rst diff --git a/Misc/NEWS.d/next/Library/2026-06-03-11-10-06.gh-issue-150875.WK9OTj.rst b/Misc/NEWS.d/next/Library/2026-06-03-11-10-06.gh-issue-150875.WK9OTj.rst new file mode 100644 index 00000000000000..1c8259e4e18504 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-03-11-10-06.gh-issue-150875.WK9OTj.rst @@ -0,0 +1,4 @@ +Speed up :func:`json.dumps` encoding of strings made up of long runs of +characters that need no escaping, by scanning eight bytes at a time. Short +strings, strings that need escaping, and strings containing non-Latin-1 +characters are unaffected. Patch by Bernát Gábor. diff --git a/Modules/_json.c b/Modules/_json.c index 6c4f38834631d3..e193dd1891065c 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -164,6 +164,43 @@ ascii_escape_size(const void *input, int kind, Py_ssize_t input_chars) Py_ssize_t i; Py_ssize_t output_size; + /* SWAR no-escape fast path (1-byte): when no byte needs escaping the + output is just the input plus the two surrounding quotes. needs-escape + is c < 0x20 || c > 0x7e || c == '"' || c == '\\'; skip 8 bytes at a time + and fall through to the per-character loop at the first such byte. The + length guard keeps short strings (the common dict key) on the original + loop, where the fast path's setup would not pay off. */ + if (kind == PyUnicode_1BYTE_KIND && input_chars >= 16 + && input_chars < PY_SSIZE_T_MAX - 2) { + const Py_UCS1 *p = (const Py_UCS1 *)input; + const uint64_t ones = 0x0101010101010101ULL; + const uint64_t high = 0x8080808080808080ULL; + const uint64_t bq = 0x22ULL * ones, bs = 0x5cULL * ones; + const uint64_t b7f = 0x7fULL * ones, bc = 0xE0ULL * ones; + Py_ssize_t j = 0; + int needs_escape = 0; + for (; j + 8 <= input_chars; j += 8) { + uint64_t w; + memcpy(&w, p + j, 8); + uint64_t mq = w ^ bq; mq = (mq - ones) & ~mq & high; /* == '"' */ + uint64_t ms = w ^ bs; ms = (ms - ones) & ~ms & high; /* == '\\' */ + uint64_t vc = w & bc; uint64_t mlo = (vc - ones) & ~vc & high;/* < 0x20 */ + uint64_t m7 = w ^ b7f; m7 = (m7 - ones) & ~m7 & high; /* == 0x7f */ + if (mq | ms | mlo | (w & high) | m7) { /* (w & high): >= 0x80 */ + needs_escape = 1; + break; + } + } + if (!needs_escape) { + for (; j < input_chars; j++) { + if (!S_CHAR(p[j])) { needs_escape = 1; break; } + } + } + if (!needs_escape) { + return input_chars + 2; + } + } + /* Compute the output size */ for (i = 0, output_size = 2; i < input_chars; i++) { Py_UCS4 c = PyUnicode_READ(kind, input, i); From 7d10318c13671a29b5021adf2c1a0e852e6b3cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 3 Jun 2026 15:03:42 -0700 Subject: [PATCH 2/2] Add tests exercising the ensure_ascii encoder paths Cover long runs that cross the scan windows and the short-string guard, with a character needing escaping at every offset in 1-byte and wider strings, plus the no-escape verbatim fast path and \uXXXX escaping of non-ASCII. --- Lib/test/test_json/test_dump.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Lib/test/test_json/test_dump.py b/Lib/test/test_json/test_dump.py index 5bc03085e60a3d..a0dfc95e41685d 100644 --- a/Lib/test/test_json/test_dump.py +++ b/Lib/test/test_json/test_dump.py @@ -130,6 +130,25 @@ def __str__(self): self.assertEqual(self.dumps({'key': obj}), '{"key": "nonascii:\\u00e9"}') + def test_ascii_encode_long_string_paths(self): + # Exercise the ensure_ascii encoder's escape scan over long runs that + # cross the 8-byte scan windows and the short-string guard: a character + # needing escaping at every offset, in 1-byte and wider strings. + dumps, loads = self.dumps, self.loads + for n in range(40): + run = "a" * n + for tail in ('"', "\\", "\n", "\x01", "\x7f", "\xe9", "中", + "\U0001f600"): + s = run + tail + "tail" + self.assertEqual(loads(dumps(s)), s) + # No-escape pure-ASCII fast path returns the string verbatim. + self.assertEqual(dumps("x" * 20), '"' + "x" * 20 + '"') + # Non-ASCII is escaped under the default ensure_ascii=True. + self.assertEqual(dumps("a" * 20 + "\xe9"), '"' + "a" * 20 + '\\u00e9"') + self.assertEqual(dumps("a" * 20 + "中"), '"' + "a" * 20 + '\\u4e2d"') + self.assertEqual(dumps("a" * 20 + "\x7f"), '"' + "a" * 20 + '\\u007f"') + self.assertEqual(dumps("a" * 20 + '"'), '"' + "a" * 20 + '\\""') + class TestPyDump(TestDump, PyTest): pass