Commit 97335aa86f7 for php
commit 97335aa86f76fb2bffee1346840da4c14e9b37db
Author: Artem Ukrainskiy <art.ukrainskiy@gmail.com>
Date: Sat Oct 31 21:06:17 2026 +0300
ext/standard: Add a fast path for ASCII in htmlspecialchars() (#24145)
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.
diff --git a/ext/standard/html.c b/ext/standard/html.c
index 41ea6aef492..b621731a898 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,
@@ -1182,6 +1182,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) /* ' */
@@ -1345,12 +1351,36 @@ static zend_string *escape_html_entities_from(const unsigned char *old, size_t o
}
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 */