Skip to content

Add json_encode/json_decode micro-benchmarks and optimize JSON key writing - #22492

Open
Squareys wants to merge 5 commits into
php:masterfrom
Squareys:json-optimization
Open

Squareys wants to merge 5 commits into
php:masterfrom
Squareys:json-optimization

Conversation

@Squareys

Copy link
Copy Markdown

Hi @bukka,

This PR aims to be a first small step to get my feet wet optimizing some of the most used parts of the runtime. JSON encoding/decoding is used in pretty much every web application, and optimized parsing is something I've done lots of for Wonderland Engine in the past, so it feels like a good match.

In this PR

This PR does the following (each in a small commit):

  1. Adds an encode/decode of various data structures to micro_bench.php, to be able to measure impact of this PR and future PRs.
  2. Optimizes JSON "<key>" output as the most used primitive in JSON output. The idea is fairly simple: most key strings will be trivial identifiers like "id", "name", "value", "list" etc. and we don't need to escape the string at all. We detect this case to do a single alloc + simple memcpy instead.
  3. Another frequent case is PHP identifiers as JSON keys for which we can use a specialized, slim form of string escaping. It is used especially when encoding class objects to JSON.
  4. Finally, I realized that even if we fail the fast-path detection, we can reuse the information of where it failed and fast-path the trivial prefix, which avoids pessimizing strings with long trivial ASCII prefixes that contain ASCII characters.

I know the string encoding work looks like it overlaps with #17734, which optimizes the encoding itself (especially for long strings), but in the spirit of "the fastest code is that which does not run at all", this optimization cuts past the code that the other PR would optimize.

Results

From callgrind:

State json_encoder.c insns vs baseline
Baseline 248,600,000
+ String fast path 226,300,000 -9.0%
+ Identifier fast path 197,000,000 -20.8%
+ Prefix flush 209,600,000 -15.7%

I wanted to run some WordPress/Symfony benchmarks to measure the impact there, but the results were a bit too noisy to be presentable. The "regression" in the prefix flush is because the benchmarks contain only the trivial case strings so far, but it's necessary to avoid pessimization mentioned above.

LLM usage disclosure

I did use coding agents/LLMs for the following tasks:

  • Running and "typing out" of the benchmarks
  • Implementing optimization ideas
  • Writing commit messages
  • Search open PRs for existing overlapping work

More importantly, I did not use LLMs for ideas on optimization approaches, those were exclusively generated by myself. Also, reading the code, and the process of optimization and strict reviews of the LLM's generated code as well as critical evaluation of the benchmark results are my own work.. This PR description is hand-written.

Squareys added 5 commits June 27, 2026 15:18
Adds json_encode_obj() and json_encode_arr() benchmark cases using the
same structure as the rest of the file. json_encode_obj uses a declared-
property class (JsonObj) to exercise the properties_info_table path;
json_encode_arr uses an associative array for comparison.
Add php_json_append_quoted() which reserves len+2 bytes in one
smart_str_extend() call and writes '"', the string body, and '"' via
raw pointer writes, replacing the previous three-call sequence
(appendc, appendl, appendc) that each checked buffer capacity.

Hoist the charmap to file scope so it can be shared with the upcoming
identifier encoder.

Callgrind on a mixed object+array workload (30k iterations each):
  baseline  2,687,712,329 instructions
  this diff 2,463,931,521 instructions  (-8.3%)
PHP property names are valid identifiers and cannot contain any ASCII
character that requires JSON escaping. Add php_json_encode_identifier()
which replaces the full charmap scan with a single byte-range check
(< 0x80): pure-ASCII identifiers take the fast path (one alloc + raw
write via php_json_append_quoted), multibyte identifiers fall through to
the same UTF-8 handling as php_json_escape_string.

Use this in the properties_info_table path of php_json_encode_array,
which is the hot path for objects with declared properties.

Callgrind on a mixed object+array workload (30k iterations each):
  string fast path  2,463,931,521 instructions
  + this diff       2,153,092,353 instructions  (-12.6% vs baseline)
…de_escape

Reduces duplication in the surrogate pair paths in both php_json_escape_string
and php_json_encode_identifier. Also moves charmap inside php_json_escape_string
since it is only used there.
…and encode_identifier

When a string has a long clean ASCII prefix but ends with a character
requiring escaping, the fast path scan already found the split point.
Flush the prefix with a single smart_str_appendl before entering the
slow loop instead of re-scanning it byte by byte.
@Squareys
Squareys requested a review from bukka as a code owner June 27, 2026 19:32
@Squareys Squareys changed the title Add json_encode/json_decode micro-benchmarks and optimize Jkey writing Add json_encode/json_decode micro-benchmarks and optimize JSON key writing Jun 29, 2026
@Squareys

Squareys commented Jul 6, 2026

Copy link
Copy Markdown
Author

Hey @Girgias, sorry to bug you, I might have selected the wrong reviewer for this (first time contribution).
Who would be best to ask for feedback on this?

Comment thread ext/json/json_encoder.c
Comment on lines +166 to +211
} else {
if (pos) {
smart_str_appendl(buf, s, pos);
s += pos;
pos = 0;
}
zend_result status;
us = php_next_utf8_char((unsigned char *)s, len, &pos, &status);

if (UNEXPECTED(status != SUCCESS)) {
if (options & PHP_JSON_INVALID_UTF8_IGNORE) {
/* ignore invalid UTF-8 byte */
} else if (options & PHP_JSON_INVALID_UTF8_SUBSTITUTE) {
if (options & PHP_JSON_UNESCAPED_UNICODE) {
smart_str_appendl(buf, "\xef\xbf\xbd", 3);
} else {
smart_str_appendl(buf, "\\ufffd", 6);
}
} else {
ZSTR_LEN(buf->s) = checkpoint;
encoder->error_code = PHP_JSON_ERROR_UTF8;
if (options & PHP_JSON_PARTIAL_OUTPUT_ON_ERROR) {
smart_str_appendl(buf, "null", 4);
}
return FAILURE;
}
} else if ((options & PHP_JSON_UNESCAPED_UNICODE)
&& ((options & PHP_JSON_UNESCAPED_LINE_TERMINATORS)
|| us < 0x2028 || us > 0x2029)) {
smart_str_appendl(buf, s, pos);
} else {
if (us >= 0x10000) {
unsigned int next_us;
us -= 0x10000;
next_us = (unsigned short)((us & 0x3ff) | 0xdc00);
us = (unsigned short)((us >> 10) | 0xd800);
php_json_append_unicode_escape(buf, us);
us = next_us;
}
php_json_append_unicode_escape(buf, us);
}
s += pos;
len -= pos;
pos = 0;
}
} while (len);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is very similar to the code in php_json_escape_string, stripped of the unreachable paths.

@Girgias

Girgias commented Jul 6, 2026

Copy link
Copy Markdown
Member

I'm not sure who is best place for optimization code review. Maybe @TimWolla ?

However, I would recommend creating a separate PR for the php_json_append_unicode_escape() change as factoring this out doesn't seem really relevant with the performance change? And that is easily reviewable by anyone.

Comment thread ext/json/json_encoder.c

static zend_always_inline void php_json_append_quoted(smart_str *buf, const char *s, size_t len)
{
char *dst = smart_str_extend(buf, len + 2);

@cgsmith cgsmith Oct 23, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I tried the same string fast path independently on current master before finding your PR. Adding a gate is helpful too.

dst = smart_str_fextend_ex(buf, len + 2, false, /* fallible */ true);
if (UNEXPECTED(!dst)) {
     return FAILURE;
}

@anun333

anun333 commented Oct 29, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

I compared this PR's head (6e114dbbe96) with its merge base (f619e66781d).

Output: identical on 600,000 generated json_encode() calls (three seeds of 200,000): the same string, or false, and the same json_last_error() in every case. Keys and strings were built from pieces covering each escaping branch (JSON-special ASCII, control bytes, DEL, 2- to 4-byte UTF-8, U+2028/U+2029, and invalid UTF-8: lone, truncated, overlong, surrogate, above U+10FFFF), with random combinations of the 14 encode flags. Objects included declared classes whose public property names use bytes 0x80–0xff, so php_json_encode_identifier()'s slow path ran per 200,000 calls with UTF-8 names (≈9,000), U+2028 in the name (≈10,700, ≈2,600 with JSON_UNESCAPED_UNICODE) and invalid UTF-8 (≈30,000; ≈7,500 each with JSON_PARTIAL_OUTPUT_ON_ERROR, JSON_INVALID_UTF8_IGNORE, JSON_INVALID_UTF8_SUBSTITUTE). ext/json/tests passes on both (99). No existing test declares a property with a non-ASCII name, so a small .phpt for those cases would keep the new function covered.

Instructions per call, from callgrind on release builds as in the description's table, against the merge base. The second column is the fast path for keys only, which I tried: master's php_json_escape_string() unchanged for values, and this PR's version as a static php_json_escape_key() called only at the string-key site in php_json_encode_array(), where the compiler inlines it (as it does php_json_encode_identifier()). Its output is also identical on the 600,000 calls, and ext/json/tests passes.

json_encode() of this PR keys only
micro_bench's JsonObj −19.2% −18.7%
micro_bench's 5-key array −2.5% −10.6%
100 objects, 12 declared properties each −24.5% −24.4%
100 rows, 12 string keys each −1.3% −7.8%
100 strings of 200 ASCII chars, nothing to escape +7.3% 0.0%
100 strings of UTF-8 text (José Müller-Øster…) +1.2% 0.0%
100 URLs (https://example.org/fhir/R4/Patient/123/…) +2.5% 0.0%
100 strings of 200 chars starting with " +0.8% 0.0%
100 strings of 200 chars ending with " +8.4% 0.0%
100 × 'a/b' +8.6% 0.0%
a FHIR search Bundle, 20 patients as nested arrays 0.0% −7.5%

So keys get cheaper as described, declared properties most of all, but every string value costs more, clean ones included, and on the mixed Bundle (URLs, references, accented names) the two cancel. With the fast path on keys only, the key gains stay, and on arrays they come out larger, as the values no longer cost more.

The branch also conflicts with master now: in UPGRADING, and at the declared-property key site, where master returns early on out-of-memory since 3584a98bda0. Elsewhere it merges silently, which would put the new fast path in php_json_escape_string() ahead of the fallible smart_str_falloc() master now uses there, so a rebase would want that allocation fallible too (as cgsmith suggested). #24123, on PHP-8.4, changes the same key-site lines, so whichever lands second needs a small merge.

How this was tested

Linux x86_64, gcc 13.3. Debug builds of f619e66781d, 6e114dbbe96 and the keys-only change on top of it for the output comparison, release builds (the same without --enable-debug) for instruction counts:

./buildconf --force
./configure --enable-debug --disable-cgi --disable-phpdbg --disable-fileinfo --enable-pcntl --enable-zend-test --enable-fpm --with-curl --enable-mbstring --disable-mbregex --with-mysqli --with-pdo-mysql
make -j4

The output comparison: a PHP script seeded with mt_srand(SEED) builds each value (scalars, lists, string-keyed arrays, stdClass with dynamic properties, and fresh instances of 40 eval()-declared classes whose 1–5 properties have identifier names such as é…, \xff…, \xe2\x80\xa8…, some private or protected), picks each flag with probability ¼, and prints id flags json_last_error() md5(output); the two builds' files are diffed. Instructions: each workload's data is built once, then json_encode() runs 200 times under valgrind --tool=callgrind; callgrind_annotate --inclusive=yes, the zif_json_encode line divided by 200. Suites: TEST_PHP_EXECUTABLE=$PWD/sapi/cli/php sapi/cli/php run-tests.php -n -q -j4 ext/json/tests.

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