[php-src] PR #23682: json: replace packed charmap bitmap with a byte-indexed lookup table
| From: | adapik | Date: | Sun, 13 Sep 2026 17:45:38 +0000 |
| Subject: | [php-src] PR #23682: json: replace packed charmap bitmap with a byte-indexed lookup table | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-38472@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/23682
Author: adapik
## Summary
php_json_escape_string()'s scalar loop tests every byte of every string with
ZEND_BIT_TEST(charmap, byte) — a packed 256-bit bitmap decoded as
charmap[byte/32] >> (byte%32) & 1. That's a real dependency chain:
compute a shift+mask to get the word index, *then* load, *then* shift the loaded word to extract the
bit. This runs on every character passed to json_encode(), regardless of content.
This PR replaces it with a direct 256-byte lookup table indexed by the byte value itself —
table[byte]. No address computation needed before the load; the byte value *is* the
index.
## How this was found
While investigating a SIMD fast-path #23675 for the same function, I found this classification test
wasn't part of the SIMD work at all — it's the pre-existing scalar loop every build
already runs, untouched since long before any vectorization. That made it worth optimizing on its
own, independent of any SIMD story, since it benefits every build on every architecture.
My first version generated the 256-entry table at compile time via a couple of helper macros
expanding a readable predicate into a static const initializer. It worked and
benchmarked identically, but I found the macro too ugly and verbose for what it bought. In this PR
we fill the table with a single, ordinary for loop, called once from
PHP_MINIT_FUNCTION(json), it's much easy to understand, test and process. It runs
once per process, before any request — the overhead is one 256-iteration loop at startup, which is
negligible against the millions of json_encode() calls it then speeds up over the
process's lifetime.
## Benchmark
Docker, AMD Ryzen 5 7500F, gcc 13.3, default release flags), 7 trials/corpus, master
vs. this change:
| Corpus | small | medium | large |
|---|---|---|---|
| clean_ascii | +8% | +10% | +6% |
| html_heavy | +3% | +4% | +5% |
| mixed | +3% | +18% | +18% |
| non_ascii | +5% | +6% | +4% |
| url_heavy | +1% | +1% | +6% |
No regression on any corpus beyond two 4–5 byte boundary cases at −5%. Every substantive corpus
improves, including non-ASCII and URL-heavy content, which are typically the hardest to move without
a trade-off elsewhere. Biggest win: **+18%** on mixed content (medium/large).
I used the same test suite as in #23675.
## Testing
- 30/30 corpora pass byte-for-byte correctness against unpatched output, across both default and
full-flags (UNESCAPED_SLASHES|UNESCAPED_UNICODE|HEX_*) configurations.
- Table contents independently verified bit-exact against the original charmap bitmap
for all 256 byte values.
- New test (json_encode_byte_classification.phpt) sweeps all 256 byte values against an
independently-written reference implementation of the escaping rules, across four option
combinations, plus verifies every lone high-bit byte correctly reports JSON_ERROR_UTF8.
- No behavior change — output is identical for every input; this is purely an internal
implementation change to an existing function.