Skip to content

Commit df365c9

Browse files
committed
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.
1 parent 1c9242e commit df365c9

1 file changed

Lines changed: 32 additions & 2 deletions

File tree

‎ext/standard/html.c‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ static zend_always_inline unsigned int get_next_char_utf8(
168168
}
169169

170170
/* {{{ get_next_char */
171-
static inline unsigned int get_next_char(
171+
static zend_always_inline unsigned int get_next_char(
172172
enum entity_charset charset,
173173
const unsigned char *str,
174174
size_t str_len,
@@ -1172,6 +1172,12 @@ static inline void find_entity_for_char_basic(
11721172
}
11731173
/* }}} */
11741174

1175+
/* ASCII other than the characters htmlspecialchars() can escape */
1176+
static zend_always_inline bool is_non_special_ascii(unsigned char c)
1177+
{
1178+
return c < 0x80 && c != '&' && c != '"' && c != '\'' && c != '<' && c != '>';
1179+
}
1180+
11751181
/* Character classes used by html_verbatim_prefix_len(). The quote classes are the flags
11761182
* that make the quotes encoded. */
11771183
#define HTML_CC_SQUOTE (1<<0) /* ' */
@@ -1332,12 +1338,36 @@ static zend_string *escape_html_entities_from(const unsigned char *old, size_t o
13321338
replaced = zend_string_alloc(maxlen, 0);
13331339
memcpy(ZSTR_VAL(replaced), old, cursor);
13341340
len = cursor;
1341+
/* htmlspecialchars() without ENT_DISALLOWED leaves such ASCII as is */
1342+
const bool copy_ascii_runs = !all && !(flags & ENT_HTML_SUBSTITUTE_DISALLOWED_CHARS);
1343+
13351344
while (cursor < oldlen) {
13361345
const unsigned char *mbsequence = NULL;
13371346
size_t mbseqlen = 0,
13381347
cursor_before = cursor;
13391348
zend_result status = SUCCESS;
1340-
unsigned int this_char = get_next_char(charset, old, oldlen, &cursor, &status);
1349+
unsigned int this_char = old[cursor];
1350+
1351+
if (this_char >= 0x80) {
1352+
this_char = get_next_char(charset, old, oldlen, &cursor, &status);
1353+
} else if (copy_ascii_runs && is_non_special_ascii(this_char)) {
1354+
/* The run can be as long as the rest of the input */
1355+
if (maxlen - len < oldlen - cursor + 40) {
1356+
replaced = zend_string_safe_realloc(replaced, maxlen, 1, oldlen - cursor + 128, 0);
1357+
maxlen += oldlen - cursor + 128;
1358+
}
1359+
const unsigned char *in = &old[cursor], *const end = &old[oldlen];
1360+
char *out = &ZSTR_VAL(replaced)[len];
1361+
do {
1362+
*out++ = *in++;
1363+
} while (in < end && is_non_special_ascii(*in));
1364+
len = out - ZSTR_VAL(replaced);
1365+
cursor = in - old;
1366+
continue;
1367+
} else {
1368+
/* ASCII is a single byte in every charset */
1369+
cursor++;
1370+
}
13411371

13421372
/* guarantee we have at least 40 bytes to write.
13431373
* In HTML5, entities may take up to 33 bytes */

0 commit comments

Comments
 (0)