gh-150638: Improve performance of json.loads and json.load for numeric data - #150639
eendebakpt wants to merge 15 commits into
Conversation
Add a fast path to _match_number_unicode for integers that fit in a 64-bit integer (at most 19 decimal digits): accumulate the value directly into an unsigned long long instead of allocating a PyBytes and calling the generic PyLong_FromString. Positive values use PyLong_FromUnsignedLongLong; negatives within long long range use PyLong_FromLongLong; larger integers fall back to the previous path. For floats and big integers, copy the (always-ASCII) number text into a stack buffer for the common short case to avoid the PyBytes allocation, and call PyOS_string_to_double directly for floats. Benchmarks (optimized free-threaded build): * pyperformance json_loads: 1.06x faster overall * microbench: small int arrays ~2x, 20-int doc 1.48x, mixed dict 1.16x All test_json tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
markshannon
left a comment
There was a problem hiding this comment.
Looks good, but the wide scope of numstr makes it a bit hard to reason about refleaks.
Can you remove the early declaration of numstr and define it where you need it.
| } | ||
| else | ||
| rval = PyLong_FromString(buf, NULL, 10); | ||
| Py_XDECREF(numstr); |
There was a problem hiding this comment.
numstr is only defined on lines 1068 and 1108.
You could declare it only in those places, and decref it in the same blocks.
| *next_idx_ptr = idx; | ||
| rval = PyLong_FromUnsignedLongLong(value); | ||
| if (neg && rval != NULL) { | ||
| Py_SETREF(rval, PyNumber_Negative(rval)); |
There was a problem hiding this comment.
this can use _PyLong_Negate
There was a problem hiding this comment.
_PyLong_Negate is define in longobject.c and not part of the internal API. I do not think it is worth it here to expose.
If we change idx - start - neg <= 19 into idx - start - neg <= 18 we can fit value into a signed long and negate value (but we loose the fast path for values with 19 digits). There are a few more tricks we could do, but I think the performance gain would not be enough to warrant the extra complexity.
# Conflicts: # Lib/test/test_json/test_decode.py
8cd76f1 to
d3cc3a7
Compare
sprajs
left a comment
There was a problem hiding this comment.
Reviewed c14d8063e303475af844b3e291c9622b023bd036 against merge base 7aec160315a6316f7391abda10d4b6c57032d105.
No correctness regression reproduced in 5,252 C-versus-Python-scanner cases, 24 exact-token custom-hook checks, and 400 allocation-failure offsets. These cover 19/20-digit and signed/unsigned boundaries, negative zero, subnormal/overflow cases, exponent extremes, long buffers, 1/2/4-byte Unicode inputs and conversion limits. The shared debug-runtime comparison passes 237 JSON tests (3 skipped), plus all 46 tests in this PR's exact test_decode module on both variants.
I compared the two overlapping proposals using one PGO/LTO core built from #156897's head. The baseline _json.c sources are identical; each of baseline, #156897 and #150639 was compiled with the same -O3 flags and headers, without module-specific PGO. This isolates the decoder components:
| Workload | base | 156897 | 150639 |
|---|---|---|---|
| float_array_10000 | 1.96 ms | 1.89 ms | 1.75 ms |
| numeric_records_2000 | 1.65 ms | 1.63 ms | 1.52 ms |
| pyperformance_json_loads | 20.00 µs | 19.88 µs | 19.00 µs |
Inputs: 10,000 successive random() calls on one Random(156897) instance (the float-array workload from #156897's discussion, with a fixed seed); 2,000 records with integer IDs, two floats and a boolean; and the three-object pyperformance json_loads fixture. The latter is timed with its original 20-call unrolling and normalization. #156897 still allocates the intermediate character buffer; #150639 additionally provides integer and stack-buffer fast paths.
Timings are medians from seven interleaved fresh processes on Linux x86-64, i9-9900K/GCC 16.2.1, pinned to one logical CPU; GC disabled during timed loops, hash seed 0, calibration to 65 ms followed by a separately timed batch. Builds and tests had finished before timing; the desktop was not frequency-isolated. These are workload-specific measurements, not application-wide speedups.
Review and testing performed with Codex (AI assistance).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_match_number_unicode()(the C accelerator behindjson.loads) previously allocated aPyBytesobject for every number, copied the digits into it, and then called the genericPyLong_FromString/PyFloat_FromStringparsers.This PR parses the common cases directly from the already-scanned text.
json.loads, number-heavy document (script below)json.load, same document via file objectbm_json_loadsThe standard
bm_json_loadsdocument is string/dict-dominated, so it gainsless.
Also addresses #155742
Benchmark script