From df365c9ae93fb8be70a2e8cd79e031be73fad480 Mon Sep 17 00:00:00 2001 From: Artem Ukrainskiy Date: Mon, 5 Oct 2026 22:28:44 +0300 Subject: [PATCH] ext/standard: Add a fast path for ASCII in htmlspecialchars() ASCII is a single byte in every supported charset, so get_next_char() is only called for bytes >= 0x80, and is inlined there. htmlspecialchars() never changes ASCII other than & " ' < >, so after the verbatim prefix such bytes are copied in a loop of their own instead of being looked up in the entity table one by one. Output is unchanged. CPU cycles per htmlspecialchars() call, i7-13700H, GCC 13.3, GCC 14.2, clang 18.1, clang 20.1: English text, 1-64 KB 45-84% fewer HTML, 1-64 KB 41-80% fewer Markdown, JavaScript, 4 KB 64-89% fewer 40-byte title with & " < > 41-65% fewer only & or " 7-27% fewer htmlentities(): 12-26% fewer cycles on titles and English text, from 3% more to 23% fewer on HTML and Russian text. Strings with nothing to encode, which are returned as they are, run the same instructions; their cycles move by -8% to +15% with code placement. --- ext/standard/html.c | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/ext/standard/html.c b/ext/standard/html.c index 075d629f10b6..56b2fa94e03a 100644 --- a/ext/standard/html.c +++ b/ext/standard/html.c @@ -168,7 +168,7 @@ static zend_always_inline unsigned int get_next_char_utf8( } /* {{{ get_next_char */ -static inline unsigned int get_next_char( +static zend_always_inline unsigned int get_next_char( enum entity_charset charset, const unsigned char *str, size_t str_len, @@ -1172,6 +1172,12 @@ static inline void find_entity_for_char_basic( } /* }}} */ +/* ASCII other than the characters htmlspecialchars() can escape */ +static zend_always_inline bool is_non_special_ascii(unsigned char c) +{ + return c < 0x80 && c != '&' && c != '"' && c != '\'' && c != '<' && c != '>'; +} + /* Character classes used by html_verbatim_prefix_len(). The quote classes are the flags * that make the quotes encoded. */ #define HTML_CC_SQUOTE (1<<0) /* ' */ @@ -1332,12 +1338,36 @@ static zend_string *escape_html_entities_from(const unsigned char *old, size_t o replaced = zend_string_alloc(maxlen, 0); memcpy(ZSTR_VAL(replaced), old, cursor); len = cursor; + /* htmlspecialchars() without ENT_DISALLOWED leaves such ASCII as is */ + const bool copy_ascii_runs = !all && !(flags & ENT_HTML_SUBSTITUTE_DISALLOWED_CHARS); + while (cursor < oldlen) { const unsigned char *mbsequence = NULL; size_t mbseqlen = 0, cursor_before = cursor; zend_result status = SUCCESS; - unsigned int this_char = get_next_char(charset, old, oldlen, &cursor, &status); + unsigned int this_char = old[cursor]; + + if (this_char >= 0x80) { + this_char = get_next_char(charset, old, oldlen, &cursor, &status); + } else if (copy_ascii_runs && is_non_special_ascii(this_char)) { + /* The run can be as long as the rest of the input */ + if (maxlen - len < oldlen - cursor + 40) { + replaced = zend_string_safe_realloc(replaced, maxlen, 1, oldlen - cursor + 128, 0); + maxlen += oldlen - cursor + 128; + } + const unsigned char *in = &old[cursor], *const end = &old[oldlen]; + char *out = &ZSTR_VAL(replaced)[len]; + do { + *out++ = *in++; + } while (in < end && is_non_special_ascii(*in)); + len = out - ZSTR_VAL(replaced); + cursor = in - old; + continue; + } else { + /* ASCII is a single byte in every charset */ + cursor++; + } /* guarantee we have at least 40 bytes to write. * In HTML5, entities may take up to 33 bytes */