Skip to content

ext/json: replace packed charmap bitmap with a byte-indexed lookup table - #23682

Open
adapik wants to merge 3 commits into
php:masterfrom
adapik:perf/json-escape-dirty-table
Open

adapik wants to merge 3 commits into
php:masterfrom
adapik:perf/json-escape-dirty-table

Conversation

@adapik

@adapik adapik commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

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.

Reproducing these numbers
curl -fsSL https://raw.githubusercontent.com/adapik/php-src/perf/json-sse2-escape-scripts/scripts/json/cross_cpu_bench.sh -o cross_cpu_bench.sh
chmod +x cross_cpu_bench.sh
FORK_REF=perf/json-escape-dirty-table ./cross_cpu_bench.sh

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.

@adapik
adapik requested a review from bukka as a code owner September 13, 2026 17:45
@adapik
adapik marked this pull request as draft September 13, 2026 18:43
@adapik
adapik marked this pull request as ready for review September 14, 2026 05:39
@adapik adapik changed the title json: replace packed charmap bitmap with a byte-indexed lookup table ext/json: replace packed charmap bitmap with a byte-indexed lookup table Sep 14, 2026
Comment thread ext/json/json_encoder.c Outdated

static const char digits[] = "0123456789abcdef";

static uint8_t php_json_escape_dirty_table[256];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This could be a const static table of bool, we have other such static tables in ext/mbstring.

Might make sense to have a small PHP file generate a C header file that contains just the table. :)

@adapik

adapik commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

I actually started with a hand-written static map, but found it hard to read and not self-explanatory. Now I followed the well-established ext/mbstring pattern instead - script ext/json/gen_json_escape_table.php generates php_json_escape_table.h (a static const bool[256] table).

@Girgias thanks for the tip-off

Benchmarks did'nt move (obviously).

@adapik
adapik requested a review from Girgias September 14, 2026 17:24
@TimWolla

Copy link
Copy Markdown
Member

I actually started with a hand-written static map, but found it hard to read and not self-explanatory.

I believe that by using designated initializers and comments we can get the best of both worlds: Readability and no moving parts (the generated file should be set up with https://github.com/php/php-src/blob/master/.github/actions/verify-generated-files/action.yml and the Makefile).

Inverting the meaning of the entries and then doing the following:

static const bool php_json_escape_clean_table[256] = {
[' '] = 1,
['!'] = 1,
['#'] = 1,
['$'] = 1,
['%'] = 1,
['('] = 1,
[')'] = 1,
['*'] = 1,
['+'] = 1,
[','] = 1,
['-'] = 1,
['.'] = 1,
['0'] = 1,
['1'] = 1,
['2'] = 1,
['3'] = 1,
['4'] = 1,
['5'] = 1,
['6'] = 1,
['7'] = 1,
['8'] = 1,
['9'] = 1,
[':'] = 1,
[';'] = 1,
['='] = 1,
['?'] = 1,
['@'] = 1,
['A'] = 1,
['B'] = 1,
['C'] = 1,
['D'] = 1,
['E'] = 1,
['F'] = 1,
['G'] = 1,
['H'] = 1,
['I'] = 1,
['J'] = 1,
['K'] = 1,
['L'] = 1,
['M'] = 1,
['N'] = 1,
['O'] = 1,
['P'] = 1,
['Q'] = 1,
['R'] = 1,
['S'] = 1,
['T'] = 1,
['U'] = 1,
['V'] = 1,
['W'] = 1,
['X'] = 1,
['Y'] = 1,
['Z'] = 1,
['['] = 1,
[']'] = 1,
['^'] = 1,
['_'] = 1,
['`'] = 1,
['a'] = 1,
['b'] = 1,
['c'] = 1,
['d'] = 1,
['e'] = 1,
['f'] = 1,
['g'] = 1,
['h'] = 1,
['i'] = 1,
['j'] = 1,
['k'] = 1,
['l'] = 1,
['m'] = 1,
['n'] = 1,
['o'] = 1,
['p'] = 1,
['q'] = 1,
['r'] = 1,
['s'] = 1,
['t'] = 1,
['u'] = 1,
['v'] = 1,
['w'] = 1,
['x'] = 1,
['y'] = 1,
['z'] = 1,
['{'] = 1,
['|'] = 1,
['}'] = 1,
['~'] = 1,
[127] = 1,
};

will safely initialize the remaining entries to 0.

Side note: Should 0x7f really not be escaped? That looks like a pre-existing bug.

@adapik

adapik commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@TimWolla Good point, but I don't think this needs the CI/Makefile editing. The table content is fixed (it encodes JSON's escaping rules, which aren't expected to change), so gen_json_escape_table.php isn't a build-time dependency — it's there as a documentation/maintainability aid. In practice no one is going to read the 256-entry table itself; if someone does need to read or change it, they'll follow the header comment back to the generator script, which is short, concise, and self-explanatory.

For what it's worth, the mbstring generators this PR follows (ucgendat.php → eaw_table.h, gen_rare_cp_bitvec.php → rare_cp_bitvec.h) aren't wired into verify-generated-files or the Makefile either, so this keeps parity with that existing pattern.

DEL (0x7F) — checked against RFC 8259. Everything from 0x5D up — DEL included — is legal unescaped. So not escaping it is spec-compliant, not a bug. This PR preserves that existing behavior.

@TimWolla

Copy link
Copy Markdown
Member

aren't wired into verify-generated-files

That would be a bug then. The CI job exists to verify generated files actually are in sync (to prevent accidental or malicious changes from slipping into generated files).

DEL (0x7F) — checked against RFC 8259. Everything from 0x5D up — DEL included — is legal unescaped. So not escaping it is spec-compliant, not a bug. This PR preserves that existing behavior.

Surprising (given that it's practically non-printable), but it seems that node.js agrees, so no change needed.

@adapik

adapik commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor Author

@TimWolla No worries, not against it in general - just figured it wasn't needed since the table's static. But yeah, fair point about what the CI job's actually for, so I went ahead and added it.

I wouldn't mind going through the rest of the scripts and opening a follow-up PR for those later on.

I wouldn't trust nodejs :) just saying, only stick to RFC

@adapik
adapik force-pushed the perf/json-escape-dirty-table branch from 53fd066 to 5114f86 Compare September 14, 2026 20:15
@adapik

adapik commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

@Girgias @TimWolla requested changes made, I would appreciate if you could re-review

@ArtUkrainskiy

Copy link
Copy Markdown
Contributor

I tested this against current master (bd6c637) with four compilers, since it changes the hottest loop of json_encode() and what a byte lookup saves over the bit test depends on how the compiler emits that bit test.

Correctness. The branch merges cleanly. Output and json_last_error() are byte-identical to master on every pair of bytes and every three-byte sequence with an E0..EF lead byte (six flag sets), every byte value at every position of ASCII strings 1–24 bytes long, literal keys through arrays, stdClass and declared properties, and 400,000 random strings under 11 flag sets. It builds with --enable-werror on GCC 13.3, GCC 14.2, clang 18.1 and clang 20.1.

Numbers. Cycles / instructions per json_encode() call relative to master; perf stat over N calls minus the same process with 0 calls, median of 3 alternating runs, release builds, i7-13700H pinned to one P-core:

4 KiB string unless noted GCC 13.3 GCC 14.2 clang 18.1 clang 20.1
ASCII, 4 KiB 0.79 / 0.67 0.82 / 0.67 1.00 / 0.75 1.02 / 0.75
ASCII, 256 bytes 0.84 / 0.74 0.86 / 0.74 1.03 / 0.81 1.04 / 0.81
HTML 0.81 / 0.80 0.87 / 0.80 1.07 / 0.88 1.00 / 0.88
URLs 0.93 / 0.82 0.96 / 0.82 1.05 / 0.89 1.08 / 0.89
a quote every 16 bytes 1.00 / 0.75 1.00 / 0.75 1.00 / 0.83 0.99 / 0.83
a newline every 40 bytes 1.10 / 0.71 0.81 / 0.70 1.01 / 0.79 1.02 / 0.79
a quote in every byte 1.00 / 0.90 0.94 / 0.90 1.01 / 0.93 0.93 / 0.93
Cyrillic 1.19 / 0.96 1.21 / 0.96 0.96 / 0.99 1.00 / 0.99
CJK 1.00 / 0.97 0.99 / 0.97 0.99 / 0.99 1.02 / 0.99
1,000 arrays of 10 fields 0.99 / 0.87 1.16 / 0.87 0.99 / 0.91 0.99 / 0.91
1,000 objects 1.04 / 0.87 1.05 / 0.87 0.99 / 0.91 0.99 / 0.91
  • The table removes instructions with every compiler: 25–33% on ASCII.
  • With GCC that shows in cycles (0.79–0.82 on ASCII). With clang the cycles on ASCII stay at 1.00–1.04 at 25% fewer instructions.
  • Cyrillic with GCC is at 1.19–1.21 with fewer instructions. Measured earlier with GCC 13 on cd383dc, branch mispredictions on that input go from 250–315 thousand to 680–710 thousand per 4,000 calls.
  • With GCC 14, 1,000 arrays take 1.16 of master's cycles at 0.87 of its instructions.

Where the table pays off fully. The scan for plain bytes sits inside the large loop body of php_json_escape_string(), so the code the compiler emits for it changes with everything else in the function. I tried moving it into a small always-inline function that returns the length of the plain run; the main loop then appends the run and handles one special character. With the same 256-byte table, cycles relative to master (output identical under the same checks; these builds also include the first, two- and three-byte version of the inline UTF-8 decoding from #24283, which does not change the instructions for ASCII):

GCC 13.3 GCC 14.2 clang 18.1 clang 20.1
ASCII, 4 KiB 0.59 0.54 0.53 0.53
HTML 0.70 0.63 1.01 1.02
URLs 0.78 0.78 1.01 1.05
ASCII with an é every 31 bytes on average 0.31 0.31 0.69 0.59
a quote every 16 bytes 0.30 0.29 0.81 0.69
a newline every 40 bytes 0.39 0.34 0.67 0.62
a quote in every byte 1.01 1.01 0.84 0.85
1,000 arrays of 10 fields 0.89 0.92 0.97 0.91

With the scan split out, the table gives 0.53–0.59 on ASCII with all four compilers. The same split without the table loses on GCC 13 (1.27 on ASCII): there GCC compiles the bit test to shr %cl + test instead of bt.

Why I think the table should land in some form. While working on UTF-8 decoding in the same function I tried six different changes to this loop: compiler hints, a different loop exit, a loop over consecutive non-ASCII characters, keeping the position in a register. Each of them moved slowdowns of 10–40% between inputs and compilers, because the scan's code depends on the rest of the function. My decoding PR #24283 alone, for example, makes URLs 9–15% slower with GCC 13 and 14 on an Intel P-core at the same instruction count, and with GCC 13 also 1,000 arrays 10% slower, because the functions after php_json_escape_string() move; clang and an AMD machine show nothing of the kind. The scan split with the byte table is the only shape I found where every input on every compiler I measured is at most 5% slower and most are much faster; with GCC the URL row goes to 0.78. The table is what makes it independent of the compiler.

So this PR and the scan split will need the same table. I'm happy with whichever order works for you: the split as a follow-up on top of this PR once it lands, or folded in here if you'd rather, or I can send it separately with a table that matches yours so that whichever lands first, the other just rebases.

A wider comparison of the open json_encode() PRs, with the same method: https://artukrainskiy.github.io/posts/json-encode-utf8-autumn-2026/

@adapik

adapik commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@ArtUkrainskiy, thank you for the feedback on this PR. I'm glad you found a way to improve performance
further on top of this.

I haven't gone through your code in detail yet, but I plan to, and I find the proposals quite interesting. One thing I'd like to point out now, as it may matter for all further performance discussions here: your measurements use -O2 -falign-functions=64 -falign-loops=32, while the official Docker images build PHP with plain -O2 (no alignment flags). Every flag combination produces its own code layout, which can dramatically improve the performance of one function and hurt another. I think we should base our measurements on the default flags of the the official Docker images and a stock ./configure, since that is what most end users actually run.

For example, on this PR branch (Ryzen 5 7500F, GCC 13.3, cycles via perf stat) the flags you used show a regression (ascii-4k 1.01, Cyrillic 1.05 vs master), while plain -O2 doesn't (ascii-4k 0.83, Cyrillic 0.90). The instruction count is lower in both cases, so the change itself works either way.

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.

4 participants