Skip to content

Optimize serialize - #23110

Closed
kamil-tekiela wants to merge 2 commits into
php:masterfrom
kamil-tekiela:Optimize-serialize
Closed

kamil-tekiela wants to merge 2 commits into
php:masterfrom
kamil-tekiela:Optimize-serialize

Conversation

@kamil-tekiela

Copy link
Copy Markdown
Member

NOTE: I did not write this PR. This is part of my experiments with Claude. This PR was created entirely by Claude Sonnet 5 and tested by me.

Benchmarking shows that there is real improvement, although the results vary depending on the size and contents of the payload. There doesn't seem to be any performance degradation in a normal case.

Code comments have been committed separately so that they can be discarded.

Please treat this as an experiment. If you don't feel comfortable reviewing or merging it, please don't.

kamil-tekiela and others added 2 commits August 8, 2026 00:17
Signed-off-by: Kamil Tekiela <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Signed-off-by: Kamil Tekiela <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
@ArtUkrainskiy

Copy link
Copy Markdown
Contributor

I measured this on master c65321c (15438e3 merges cleanly) with GCC 13.3, GCC 14.2, clang 18.1 and clang 20.1 on an i7-13700H, built with the flags of the official Docker image.

Change in CPU cycles per unserialize() call with the PR, relative to master (negative = fewer cycles, faster); median of five alternating runs:

call master, CPU cycles per call (GCC 13.3) GCC 13.3 GCC 14.2 clang 18.1 clang 20.1
array of 10 scalars (no references) 1,482 +1% +3% +1% +3%
session-like array, ~0.5 KB (no references) 4,766 0% +3% +3% +2%
1,000 ints (no references) 111,975 +2% 0% −1% +2%
6,200 ints (no references) 715,794 +4% 0% −2% +3%
50,000 strings (no references) 8,466,801 −1% +3% +3% 0%
5,000 objects (no references) 9,111,635 −2% +3% 0% 0%
5,000 objects sharing 50 objects (r: to early ids) 9,606,840 −1% +2% +1% −1%
20,000 objects, each refers to the previous one (r:) 58,467,095 −29% −28% −31% −31%
20,000 objects, each refers to a random earlier one (r:) 76,164,644 −15% −14% −15% −16%
6,200 ints + 100,000 R: to random ones 19,276,613 −17% −15% −16% −14%

Payloads with many r:/R: to values beyond the first 1,018 take 14–31% fewer cycles. Without such references, median changes range from −2% to +4%, depending on the compiler and payload. The GCC 13 master-against-master control ranges from −1% to +3% across cases, but that is not a noise bound for every case or compiler: on 6,200 ints, the PR is +4% while the control is −1%. These measurements do not rule out small cycle regressions.

Instructions with GCC. GCC 13 and 14 execute additional instructions on payloads without references: about 4.1 per int on 6,200 ints and 0.85–3.8 per string on 50,000 strings, or 0.2–1.0% of the call. On 6,200 ints, the clang increase is 0.15–0.16 instructions per int. The PR adds chunk-list bookkeeping to the chunk-allocation branch in var_push(), which is inlined into php_var_unserialize_internal(). That function grows by 543 bytes with GCC 13 and 683 with GCC 14 (124–135 with clang).

Moving the body of that branch into a zend_never_inline helper, with UNEXPECTED() on the condition, reduces the GCC increase on 6,200 ints to 0.05–0.07 instructions per int (GCC 13 master against master: 0.07) and brings the main function's size to within 6 bytes of master with GCC: cold-variant.diff. This reduces instructions and code size; the measurements do not establish a cycle improvement over the PR. In separate comparisons with master, the variant retains 11–30% fewer cycles on reference-heavy payloads, while payloads without such references range from −4% to +7%.

Correctness: output identical to master with all four compilers and under ASan/UBSan, on 1,429 targeted payloads covering R:/r: around the first nine chunk boundaries, invalid ids (negative, past the end, PHP_INT_MAX), references inside nested arrays, and nested unserialize() from __unserialize(), plus 3,000 random payloads of up to 12,000 values; the harness catches six deliberate mutations of var_access() and var_push().

Tests: unserialize_var_access_chunk_boundary.phpt describes ids 0, 1018, 1019 and 1050, but the payloads it builds contain R:2;, R:1020;, R:1021; and r:2102; (ids count from 1, the outer array takes id 1, each object and its property take one id each). The references still land in the base chunk and the first and second overflow chunks, so the new code is exercised, only not where the comments say. Growing chunk_list past its initial 4 entries needs an id of 5,091 or more; the test stays below that.

The PR changes unserialize() only; a title like "Optimize unserialize() back-reference lookup" would match the change.

Full tables, scripts and data: https://github.com/ArtUkrainskiy/php-src-bench/tree/main/reports/pr-23110-unserialize-var-access

@Girgias Girgias left a comment

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 seems incredibly unworthy while adding monumental complexity.

@kamil-tekiela

Copy link
Copy Markdown
Member Author

I agree. It looked to me initially like it would provide a much bigger improvement. Thanks for analysis @ArtUkrainskiy

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.

3 participants