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 */