Skip to content

Speed up the html escaping strategy for long strings - #4983

Open
Kocal wants to merge 1 commit into
twigphp:3.xfrom
Kocal:perf/escaper-html-strtr
Open

Kocal wants to merge 1 commit into
twigphp:3.xfrom
Kocal:perf/escaper-html-strtr

Conversation

@Kocal

@Kocal Kocal commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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

// 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):

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.

Comment thread src/Runtime/EscaperRuntime.php Outdated

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)) {

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.

could be worth a PHP 8.7 check or a note about php/php-src#23957

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

@Kocal
Kocal force-pushed the perf/escaper-html-strtr branch from 3da44a0 to 4aa69f4 Compare October 7, 2026 05:39
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
Kocal force-pushed the perf/escaper-html-strtr branch from 4aa69f4 to 881022c Compare October 7, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants