Repository navigation
Conversation
|
|
||
| if ('UTF-8' === $charset) { | ||
| // On valid UTF-8, htmlspecialchars() only replaces these characters but decodes every other one: long strings are faster through strtr() | ||
| if (\strlen($string) > 32 && preg_match('//u', $string)) { |
Contributor
There was a problem hiding this comment.
could be worth a PHP 8.7 check or a note about php/php-src#23957
Kocal
force-pushed
the
perf/escaper-html-strtr
branch
from
October 7, 2026 05:39
3da44a0 to
4aa69f4
Compare
With the UTF-8 charset, the `html` strategy calls `htmlspecialchars()`, which decodes the string one character at a time: about 5 ns per byte, so 1.6 µs for the 280-character class list of a styled button, even when nothing needs escaping. On valid UTF-8, all it does is replace `&`, `"`, `'`, `<` and `>`: `ENT_SUBSTITUTE` only matters for invalid sequences.
Strings longer than 32 bytes are now checked for UTF-8 validity with `preg_match('//u')` and escaped with `strtr()` on those five characters. Invalid UTF-8 and shorter strings still go through `htmlspecialchars()`, whose fixed cost is lower below that length. The output is identical: compared against `htmlspecialchars()` on every Unicode code point (in three positions) and on invalid sequences (lone continuation bytes, overlong forms, surrogates, code points above U+10FFFF, truncated sequences), 3.3 million strings, no difference on PHP 8.4 and 8.5. The shortcut only applies on PHP < 8.7 (`PHP_VERSION_ID < 80700`), since php/php-src#23957 and php/php-src#24145 (both still open, targeting PHP 8.7) make `htmlspecialchars()` itself skip the work on input that needs no encoding.
Benchmark below, PHP 8.4, 3 interleaved runs each side, identical output, ns per escape:
| String | Before | After |
| --- | --- | --- |
| short id (`user-42`) | **155-165 ns** | **168-175 ns** (below the threshold; the length check costs ~10 ns) |
| 40-character title | **327-334 ns** | **269-285 ns** |
| 280-character CSS class list | **1590-1630 ns** | **498-511 ns** (3.2x) |
| paragraph with quotes and tags | **1634-1663 ns** | **758-766 ns** (2.2x) |
| accented paragraph | **1310-1321 ns** | **373-380 ns** (3.5x) |
End to end, on an admin dashboard built with Symfony UX components that escapes about 2,500 attribute values per request (mostly long Tailwind class lists), CPU time per request goes from **36.7-38.7 ms** to **35.7-36.4 ms** (minimum of 6 runs of 60 requests each side), about 3%.
```php
<?php
// Run from the repository root: php bench.php
require getcwd().'/vendor/autoload.php';
$escaper = new Twig\Runtime\EscaperRuntime();
$classes = 'inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50';
$cases = [
'short (id)' => 'user-42',
'title (40 chars)' => 'How we made our dashboard ten times faster',
'CSS classes (280 chars)' => $classes,
'paragraph with quotes' => str_repeat('Twig escapes "quotes" & <tags> in a sentence like this one. ', 5),
'accented paragraph' => str_repeat('Une phrase accentuée, déjà très répétée. ', 6),
];
foreach ($cases as $label => $string) {
$times = [];
for ($run = 0; $run < 7; ++$run) {
$start = hrtime(true);
for ($i = 0; $i < 200000; ++$i) {
$escaper->escape($string);
}
$times[] = (hrtime(true) - $start) / 200000;
}
sort($times);
printf("%-24s %5.0f ns/escape | sha1 %s\n", $label, $times[3], sha1($escaper->escape($string)));
}
```
Blackfire profiles (Symfony UX dashboard):
- Before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/b344f602-a21f-41ef-a585-46fc865e0880/graph
- After: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/e5e16b8b-8895-40e7-9952-43282e3bd0df/graph
- Comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/b344f602-a21f-41ef-a585-46fc865e0880...e5e16b8b-8895-40e7-9952-43282e3bd0df/graph
Blackfire barely moves (**2.29 s** -> **2.30 s** overall) because its per-call instrumentation dominates calls this short, even though `htmlspecialchars()` drops out of the profile entirely (5,398 calls per request before).
This came out of profiling Symfony UX components on https://github.com/Kocal/sf-ux-perfs-twig-components.
Kocal
force-pushed
the
perf/escaper-html-strtr
branch
from
October 7, 2026 05:49
4aa69f4 to
881022c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
With the UTF-8 charset, the
htmlstrategy callshtmlspecialchars(), which decodes the string one character at a time: about 5 ns per byte, so 1.6 µs for the 280-character class list of a styled button, even when nothing needs escaping. On valid UTF-8, all it does is replace&,",',<and>:ENT_SUBSTITUTEonly matters for invalid sequences.Strings longer than 32 bytes are now checked for UTF-8 validity with
preg_match('//u')and escaped withstrtr()on those five characters. Invalid UTF-8 and shorter strings still go throughhtmlspecialchars(), whose fixed cost is lower below that length. The output is identical: compared againsthtmlspecialchars()on every Unicode code point (in three positions) and on invalid sequences (lone continuation bytes, overlong forms, surrogates, code points above U+10FFFF, truncated sequences), 3.3 million strings, no difference on PHP 8.4 and 8.5. The shortcut only applies on PHP < 8.7 (PHP_VERSION_ID < 80700), since php/php-src#23957 and php/php-src#24145 (both still open, targeting PHP 8.7) makehtmlspecialchars()itself skip the work on input that needs no encoding.Benchmark below, PHP 8.4, 3 interleaved runs each side, identical output, ns per escape:
user-42)End to end, on an admin dashboard built with Symfony UX components that escapes about 2,500 attribute values per request (mostly long Tailwind class lists), CPU time per request goes from 36.7-38.7 ms to 35.7-36.4 ms (minimum of 6 runs of 60 requests each side), about 3%.
Blackfire profiles (Symfony UX dashboard):
Blackfire barely moves (2.29 s -> 2.30 s overall) because its per-call instrumentation dominates calls this short, even though
htmlspecialchars()drops out of the profile entirely (5,398 calls per request before).This came out of profiling Symfony UX components on https://github.com/Kocal/sf-ux-perfs-twig-components.