From: Andrew Dunstan <andrew@dunslane.net>
Date: Sun, 13 Sep 2026 17:13:15 -0400
Subject: [PATCH v3] Add ASCII fast path to Unicode normalization functions

unicode_is_normalized(), unicode_assigned(), and unicode_normalize_func()
each decoded its whole input to an array of char32_t codepoints, one
utf8_to_unicode()/pg_utf_mblen() call at a time, before doing any real
work.  ASCII characters need none of that: they have no canonical or
compatibility decomposition, a combining class of zero, and are all
assigned.  So leading ASCII in a string is unchanged by normalization in
any form, except that its last character might compose with a following
combining mark.

Find the leading ASCII with a byte scan using the existing SIMD-vectorized
is_valid_ascii() (already used by pg_utf8_verifystr()).  If the whole
string is ASCII, return at once.  Otherwise decode and process only the
rest of the string, starting with the last ASCII character so that
composition with a following combining mark still happens.

Add regression coverage for the all-ASCII case in all three functions,
and sweeps over ASCII prefixes of varying length.

Author: Andrew Dunstan <andrew@dunslane.net>
Co-Author: Nazir Bilal Yavuz <byavuz81@gmail.com>
Reviewed-by: Tristan Partin <tristan@partin.io>
Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/8cb366d6-a30f-49a4-b5d3-a5dfc0f2fea2@dunslane.net
---
 src/backend/utils/adt/varlena.c       | 91 ++++++++++++++++++++++---
 src/test/regress/expected/unicode.out | 96 +++++++++++++++++++++++++++
 src/test/regress/sql/unicode.sql      | 29 ++++++++
 3 files changed, 206 insertions(+), 10 deletions(-)

diff --git a/src/backend/utils/adt/varlena.c b/src/backend/utils/adt/varlena.c
index 6c95fcb24b7..11eb212384d 100644
--- a/src/backend/utils/adt/varlena.c
+++ b/src/backend/utils/adt/varlena.c
@@ -35,6 +35,7 @@
 #include "parser/scansup.h"
 #include "port/pg_bswap.h"
 #include "regex/regex.h"
+#include "utils/ascii.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
 #include "utils/lsyscache.h"
@@ -167,6 +168,7 @@ static void text_format_string_conversion(StringInfo buf, char conversion,
 										  int flags, int width);
 static void text_format_append_string(StringInfo buf, const char *str,
 									  int flags, int width);
+static int	valid_ascii_prefix_len(const unsigned char *s, int len);


 /*****************************************************************************
@@ -5475,6 +5477,33 @@ icu_unicode_version(PG_FUNCTION_ARGS)
 		PG_RETURN_NULL();
 }

+/*
+ * Return the length of an initial portion of s[0..len) that is valid ASCII,
+ * that is, contains no zero bytes and no bytes with the high bit set, or
+ * len if all of it is.
+ *
+ * is_valid_ascii() requires a length that is a multiple of sizeof(Vector8),
+ * so check one chunk at a time with it and then the remainder byte by byte.
+ * A failure inside a chunk is reported at the start of that chunk, so the
+ * result can be up to sizeof(Vector8) - 1 bytes short of the full valid
+ * ASCII prefix.
+ */
+static int
+valid_ascii_prefix_len(const unsigned char *s, int len)
+{
+	int			chunk_len = len - (len % sizeof(Vector8));
+
+	for (int i = 0; i < chunk_len; i += sizeof(Vector8))
+		if (!is_valid_ascii(s + i, sizeof(Vector8)))
+			return i;
+
+	for (int i = chunk_len; i < len; i++)
+		if (s[i] == 0 || IS_HIGHBIT_SET(s[i]))
+			return i;
+
+	return len;
+}
+
 /*
  * Check whether the string contains only assigned Unicode code
  * points. Requires that the database encoding is UTF-8.
@@ -5484,15 +5513,23 @@ unicode_assigned(PG_FUNCTION_ARGS)
 {
 	text	   *input = PG_GETARG_TEXT_PP(0);
 	unsigned char *p;
+	int			len;
 	int			size;
+	int			start;

 	if (GetDatabaseEncoding() != PG_UTF8)
 		ereport(ERROR,
 				(errmsg("Unicode categorization can only be performed if server encoding is UTF8")));

+	/* ASCII code points are always assigned, so only check the rest */
+	len = VARSIZE_ANY_EXHDR(input);
+	start = valid_ascii_prefix_len((unsigned char *) VARDATA_ANY(input), len);
+	if (start == len)
+		PG_RETURN_BOOL(true);
+
 	/* convert to char32_t */
-	size = pg_mbstrlen_with_len(VARDATA_ANY(input), VARSIZE_ANY_EXHDR(input));
-	p = (unsigned char *) VARDATA_ANY(input);
+	size = pg_mbstrlen_with_len(VARDATA_ANY(input) + start, len - start);
+	p = (unsigned char *) VARDATA_ANY(input) + start;
 	for (int i = 0; i < size; i++)
 	{
 		char32_t	uchar = utf8_to_unicode(p);
@@ -5519,26 +5556,42 @@ unicode_normalize_func(PG_FUNCTION_ARGS)
 	unsigned char *p;
 	text	   *result;
 	size_t		i;
+	int			len;
+	int			start;

 	form = unicode_norm_form_from_string(formstr);

+	/*
+	 * ASCII characters have no decomposition and a combining class of zero,
+	 * so leading ASCII is unchanged by normalization in any form, except that
+	 * its last character might compose with a following combining mark.  So
+	 * return an all-ASCII string as is, and otherwise normalize only the rest
+	 * of the string, starting with that last ASCII character.
+	 */
+	len = VARSIZE_ANY_EXHDR(input);
+	start = valid_ascii_prefix_len((unsigned char *) VARDATA_ANY(input), len);
+	if (start == len)
+		PG_RETURN_TEXT_P(input);
+	if (start > 0)
+		start--;
+
 	/* convert to char32_t */
-	size = pg_mbstrlen_with_len(VARDATA_ANY(input), VARSIZE_ANY_EXHDR(input));
+	size = pg_mbstrlen_with_len(VARDATA_ANY(input) + start, len - start);
 	input_chars = palloc_array(char32_t, size + 1);
-	p = (unsigned char *) VARDATA_ANY(input);
+	p = (unsigned char *) VARDATA_ANY(input) + start;
 	for (i = 0; i < size; i++)
 	{
 		input_chars[i] = utf8_to_unicode(p);
 		p += pg_utf_mblen(p);
 	}
 	input_chars[i] = (char32_t) '\0';
-	Assert((char *) p == VARDATA_ANY(input) + VARSIZE_ANY_EXHDR(input));
+	Assert((char *) p == VARDATA_ANY(input) + len);

 	/* action */
 	output_chars = unicode_normalize(form, input_chars);

-	/* convert back to UTF-8 string */
-	size = 0;
+	/* convert back to UTF-8 string, after the unchanged ASCII */
+	size = start;
 	for (char32_t *wp = output_chars; *wp; wp++)
 	{
 		unsigned char buf[4];
@@ -5551,6 +5604,8 @@ unicode_normalize_func(PG_FUNCTION_ARGS)
 	SET_VARSIZE(result, size + VARHDRSZ);

 	p = (unsigned char *) VARDATA_ANY(result);
+	memcpy(p, VARDATA_ANY(input), start);
+	p += start;
 	for (char32_t *wp = output_chars; *wp; wp++)
 	{
 		unicode_to_utf8(*wp, p);
@@ -5587,20 +5642,36 @@ unicode_is_normalized(PG_FUNCTION_ARGS)
 	UnicodeNormalizationQC quickcheck;
 	size_t		output_size;
 	bool		result;
+	int			len;
+	int			start;

 	form = unicode_norm_form_from_string(formstr);

+	/*
+	 * Leading ASCII is unchanged by normalization, except that its last
+	 * character might compose with a following combining mark (see
+	 * unicode_normalize_func()).  So an all-ASCII string is normalized in
+	 * every form, and otherwise only the rest of the string, starting with
+	 * that last ASCII character, needs to be checked.
+	 */
+	len = VARSIZE_ANY_EXHDR(input);
+	start = valid_ascii_prefix_len((unsigned char *) VARDATA_ANY(input), len);
+	if (start == len)
+		PG_RETURN_BOOL(true);
+	if (start > 0)
+		start--;
+
 	/* convert to char32_t */
-	size = pg_mbstrlen_with_len(VARDATA_ANY(input), VARSIZE_ANY_EXHDR(input));
+	size = pg_mbstrlen_with_len(VARDATA_ANY(input) + start, len - start);
 	input_chars = palloc_array(char32_t, size + 1);
-	p = (unsigned char *) VARDATA_ANY(input);
+	p = (unsigned char *) VARDATA_ANY(input) + start;
 	for (i = 0; i < size; i++)
 	{
 		input_chars[i] = utf8_to_unicode(p);
 		p += pg_utf_mblen(p);
 	}
 	input_chars[i] = (char32_t) '\0';
-	Assert((char *) p == VARDATA_ANY(input) + VARSIZE_ANY_EXHDR(input));
+	Assert((char *) p == VARDATA_ANY(input) + len);

 	/* quick check (see UAX #15) */
 	quickcheck = unicode_is_normalized_quickcheck(form, input_chars);
diff --git a/src/test/regress/expected/unicode.out b/src/test/regress/expected/unicode.out
index 63e48d3a961..655dd99750a 100644
--- a/src/test/regress/expected/unicode.out
+++ b/src/test/regress/expected/unicode.out
@@ -26,6 +26,12 @@ SELECT unicode_assigned(U&'abc\+10FFFF');
  f
 (1 row)

+SELECT unicode_assigned('abc');
+ unicode_assigned
+------------------
+ t
+(1 row)
+
 SELECT normalize('');
  normalize
 -----------
@@ -68,6 +74,19 @@ SELECT normalize(U&'\00E4\24D1c', NFKD) = U&'\0061\0308bc' COLLATE "C" AS test_n
  t
 (1 row)

+SELECT normalize('abc') = 'abc' COLLATE "C" AS test_ascii_idem;
+ test_ascii_idem
+-----------------
+ t
+(1 row)
+
+SELECT normalize(val) = val COLLATE "C" AS test_ascii_idem_column
+FROM (VALUES ('abc'::text)) v(val);
+ test_ascii_idem_column
+------------------------
+ t
+(1 row)
+
 SELECT "normalize"('abc', 'def');  -- run-time error
 ERROR:  invalid normalization form: def
 SELECT U&'\00E4\24D1c' IS NORMALIZED AS test_default;
@@ -82,6 +101,12 @@ SELECT U&'\00E4\24D1c' IS NFC NORMALIZED AS test_nfc;
  t
 (1 row)

+SELECT 'abc' IS NORMALIZED AS test_ascii;
+ test_ascii
+------------
+ t
+(1 row)
+
 SELECT num, val,
     val IS NFC NORMALIZED AS NFC,
     val IS NFD NORMALIZED AS NFD,
@@ -105,6 +130,77 @@ ORDER BY num;

 SELECT is_normalized('abc', 'def');  -- run-time error
 ERROR:  invalid normalization form: def
+-- Exercise the ASCII fast-path's chunk/remainder boundary handling: a
+-- non-NFC-normalized codepoint sequence must still be detected as such
+-- regardless of how much pure-ASCII padding surrounds it.
+SELECT len,
+    (repeat('a', len) || U&'\0061\0308') IS NORMALIZED AS non_nfc_at_end,
+    (U&'\0061\0308' || repeat('a', len)) IS NORMALIZED AS non_nfc_at_start
+FROM generate_series(0, 40) AS len
+ORDER BY len;
+ len | non_nfc_at_end | non_nfc_at_start
+-----+----------------+------------------
+   0 | f              | f
+   1 | f              | f
+   2 | f              | f
+   3 | f              | f
+   4 | f              | f
+   5 | f              | f
+   6 | f              | f
+   7 | f              | f
+   8 | f              | f
+   9 | f              | f
+  10 | f              | f
+  11 | f              | f
+  12 | f              | f
+  13 | f              | f
+  14 | f              | f
+  15 | f              | f
+  16 | f              | f
+  17 | f              | f
+  18 | f              | f
+  19 | f              | f
+  20 | f              | f
+  21 | f              | f
+  22 | f              | f
+  23 | f              | f
+  24 | f              | f
+  25 | f              | f
+  26 | f              | f
+  27 | f              | f
+  28 | f              | f
+  29 | f              | f
+  30 | f              | f
+  31 | f              | f
+  32 | f              | f
+  33 | f              | f
+  34 | f              | f
+  35 | f              | f
+  36 | f              | f
+  37 | f              | f
+  38 | f              | f
+  39 | f              | f
+  40 | f              | f
+(41 rows)
+
+-- normalize() and unicode_assigned() skip leading ASCII too, so check that
+-- they give the right answer whatever its length.  A combining mark right
+-- after the ASCII must still compose with its last character.  This lists
+-- only the lengths that give a wrong answer.
+SELECT len
+FROM generate_series(0, 40) AS len
+WHERE normalize(repeat('a', len) || U&'\0061\0308', NFC)
+        <> (repeat('a', len) || U&'\00E4') COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFD)
+        <> (repeat('a', len) || U&'\0061\0308' || repeat('b', len)) COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFC)
+        <> (repeat('a', len) || U&'\00E4' || repeat('b', len)) COLLATE "C"
+   OR unicode_assigned(repeat('a', len) || U&'\+10FFFF')
+   OR NOT unicode_assigned(repeat('a', len) || U&'\00E4' || repeat('b', len));
+ len
+-----
+(0 rows)
+
 -- Hangul NFC recomposition tests
 -- L+V -> LV composition (first and last)
 SELECT normalize(U&'\1100\1161', NFC) = U&'\AC00' COLLATE "C" AS hangul_lv_first;
diff --git a/src/test/regress/sql/unicode.sql b/src/test/regress/sql/unicode.sql
index 951f86a336e..373745b32d3 100644
--- a/src/test/regress/sql/unicode.sql
+++ b/src/test/regress/sql/unicode.sql
@@ -8,6 +8,7 @@ SELECT U&'\0061\0308bc' <> U&'\00E4bc' COLLATE "C" AS sanity_check;
 SELECT unicode_version() IS NOT NULL;
 SELECT unicode_assigned(U&'abc');
 SELECT unicode_assigned(U&'abc\+10FFFF');
+SELECT unicode_assigned('abc');

 SELECT normalize('');
 SELECT normalize(U&'\0061\0308\24D1c') = U&'\00E4\24D1c' COLLATE "C" AS test_default;
@@ -16,11 +17,15 @@ SELECT normalize(U&'\00E4bc', NFC) = U&'\00E4bc' COLLATE "C" AS test_nfc_idem;
 SELECT normalize(U&'\00E4\24D1c', NFD) = U&'\0061\0308\24D1c' COLLATE "C" AS test_nfd;
 SELECT normalize(U&'\0061\0308\24D1c', NFKC) = U&'\00E4bc' COLLATE "C" AS test_nfkc;
 SELECT normalize(U&'\00E4\24D1c', NFKD) = U&'\0061\0308bc' COLLATE "C" AS test_nfkd;
+SELECT normalize('abc') = 'abc' COLLATE "C" AS test_ascii_idem;
+SELECT normalize(val) = val COLLATE "C" AS test_ascii_idem_column
+FROM (VALUES ('abc'::text)) v(val);

 SELECT "normalize"('abc', 'def');  -- run-time error

 SELECT U&'\00E4\24D1c' IS NORMALIZED AS test_default;
 SELECT U&'\00E4\24D1c' IS NFC NORMALIZED AS test_nfc;
+SELECT 'abc' IS NORMALIZED AS test_ascii;

 SELECT num, val,
     val IS NFC NORMALIZED AS NFC,
@@ -37,6 +42,30 @@ ORDER BY num;

 SELECT is_normalized('abc', 'def');  -- run-time error

+-- Exercise the ASCII fast-path's chunk/remainder boundary handling: a
+-- non-NFC-normalized codepoint sequence must still be detected as such
+-- regardless of how much pure-ASCII padding surrounds it.
+SELECT len,
+    (repeat('a', len) || U&'\0061\0308') IS NORMALIZED AS non_nfc_at_end,
+    (U&'\0061\0308' || repeat('a', len)) IS NORMALIZED AS non_nfc_at_start
+FROM generate_series(0, 40) AS len
+ORDER BY len;
+
+-- normalize() and unicode_assigned() skip leading ASCII too, so check that
+-- they give the right answer whatever its length.  A combining mark right
+-- after the ASCII must still compose with its last character.  This lists
+-- only the lengths that give a wrong answer.
+SELECT len
+FROM generate_series(0, 40) AS len
+WHERE normalize(repeat('a', len) || U&'\0061\0308', NFC)
+        <> (repeat('a', len) || U&'\00E4') COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFD)
+        <> (repeat('a', len) || U&'\0061\0308' || repeat('b', len)) COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFC)
+        <> (repeat('a', len) || U&'\00E4' || repeat('b', len)) COLLATE "C"
+   OR unicode_assigned(repeat('a', len) || U&'\+10FFFF')
+   OR NOT unicode_assigned(repeat('a', len) || U&'\00E4' || repeat('b', len));
+
 -- Hangul NFC recomposition tests
 -- L+V -> LV composition (first and last)
 SELECT normalize(U&'\1100\1161', NFC) = U&'\AC00' COLLATE "C" AS hangul_lv_first;
--
2.43.0
