Skip to content

ext/standard: Optimize var_export - #23189

Open
kamil-tekiela wants to merge 6 commits into
php:masterfrom
kamil-tekiela:Optimize-var_export
Open

kamil-tekiela wants to merge 6 commits into
php:masterfrom
kamil-tekiela:Optimize-var_export

Conversation

@kamil-tekiela

Copy link
Copy Markdown
Member

The first commit replaces sprintf with memset.

The second commit replaces php_addcslashes and php_str_to_str with a custom function based on the smart_str_append_escaped function from zend_smart_str.c. At one of the 3 call sites, the php_str_to_str was absent, and I didn't know what to do, which is why I added the flag. This makes the most sense to me, but I could also make it separate from the new function.

I don't know whether buffer_append_spaces should have inline or not. I assume it shouldn't.

In my simple round of testing, this proved to make var_export 3x faster.

@ArtUkrainskiy ArtUkrainskiy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

var_export() of a string with enough NUL bytes overflows the escaped length on 32-bit builds and crashes, details and a replacement inline. Measurements in a separate comment.

Comment thread ext/standard/var.c Outdated
@ArtUkrainskiy

ArtUkrainskiy commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

I measured this on master c65321c (dc5f795 merges with a conflict in UPGRADING only) 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. On 32-bit builds the escaped length overflows and var_export() crashes; details and a replacement are in my review comment on var.c.

Change in CPU cycles per var_export($value, true) 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
an int 157 −1% 0% +3% −1%
a 10–20-byte string, nothing to escape 302 −34% −40% −36% −34%
a 16–25-byte string with ' and \ 342 −16% −15% −16% −21%
array of 10 mixed values 5,925 −57% −57% −57% −57%
nested config array, 0.1–7.4 KB of output (median 2.4 KB) 42,556 −62% −66% −63% −65%
1,000 ints 177,222 −49% −46% −45% −47%
1,000 strings 319,568 −68% −67% −63% −65%
1,000 string keys and values 511,363 −68% −67% −63% −61%
100 objects (\Pt::__set_state()) 234,876 −41% −42% −40% −41%
a 64 KB string, nothing to escape 147,551 −6% +7% +126% +121%
a 64 KB string, ' or \ after one word in eight 181,004 +95% +92% +163% +172%
a 1 KB string with NUL bytes after one word in four 3,815 +72% +53% +105% +116%

Long strings get slower. Strings, arrays, config arrays and objects of small and medium size take 15–68% fewer cycles, since the temporary strings and spprintf() are gone. Long strings take more: the PR reads every byte twice, once to count and once to write, and both loops test for ', \ and NUL separately, so a 64 KB string with ' or \ after one word in eight runs 23–28 instructions per byte where master runs 8–9 (php_addcslashes() looks each byte up in a table once, php_str_to_str() finds NULs with memchr()).

The one-pass version in my review comment (a byte class table, plain runs appended with smart_str_appendl()) is faster than master in every case I measured except a single int (−2 … +2%, within the −1 … +2% of master against master), on all four compilers:

call master, CPU cycles per call (GCC 13.3) GCC 13.3 GCC 14.2 clang 18.1 clang 20.1
an int 155 −2% +1% +1% +2%
a 10–20-byte string, nothing to escape 294 −48% −50% −43% −44%
a 16–25-byte string with ' and \ 332 −36% −34% −30% −29%
array of 10 mixed values 5,914 −59% −60% −57% −59%
nested config array, 0.1–7.4 KB of output (median 2.4 KB) 42,449 −71% −70% −70% −70%
1,000 ints 176,321 −48% −47% −46% −48%
1,000 strings 318,269 −72% −72% −69% −68%
1,000 string keys and values 518,113 −75% −73% −70% −68%
100 objects (\Pt::__set_state()) 228,438 −41% −43% −41% −42%
a 64 KB string, nothing to escape 146,372 −50% −51% −38% −38%
a 64 KB string, ' or \ after one word in eight 181,129 −29% −33% −24% −23%
a 1 KB string with NUL bytes after one word in four 3,870 −53% −54% −47% −49%

Correctness: output identical to master with the PR and with that version, on all four compilers, under ASan/UBSan and on a 32-bit build, for every byte value as value and key, all strings up to 4 bytes over a, ', \ and NUL as values and keys, and as property names where valid (not empty, not starting with NUL), long runs and mixes of them, nested arrays and objects with visibility and enums, and 200,000 random nested values; the harness catches six deliberate mutations of the PR (11 → 10 per NUL, a skipped escape, one space less, NUL escaped in property names, and others).

Minor: buffer_append_spaces() takes an int. A level of −1 through php_var_export_ex() (it is PHPAPI) gives array elements a width of 0, where %*c still wrote one space, and a level of −2 or below gives a negative width that becomes SIZE_MAX in memset(). In-tree callers pass 1 or more.

On buffer_append_spaces() (the question in the description): as a plain static function it is inlined by clang 18 and 20 and kept out of line by GCC 13 and 14 (7 call sites). Arrays and objects gain about the same with both (1,000 ints: −46 … −49% with GCC, −45 … −47% with clang), so leaving it without inline is fine.

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

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.

2 participants