From f5ca9ffe4f35ea0b8880426ce6ffa61e265cbd6f Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Tue, 6 Oct 2026 17:10:09 -0400 Subject: [PATCH v5] 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. unicode_assigned() now walks the rest of the string directly instead of counting its codepoints with pg_mbstrlen_with_len() first. Also, match the normalization form argument against the text value instead of converting it to a C string on every call; that allocation is a noticeable part of the cost once the ASCII fast path is taken. Add regression coverage for the all-ASCII case in all three functions, including stored short-header and compressed values, sweeps over ASCII prefixes of varying length, and invalid and mixed-case form names. Author: Andrew Dunstan Author: Nazir Bilal Yavuz Author: Rui Zhao Reviewed-by: Tristan Partin Reviewed-by: Chao Li Discussion: https://postgr.es/m/8cb366d6-a30f-49a4-b5d3-a5dfc0f2fea2@dunslane.net --- src/backend/utils/adt/varlena.c | 125 +++++++++++++++++++++----- src/test/regress/expected/unicode.out | 112 +++++++++++++++++++++++ src/test/regress/sql/unicode.sql | 38 ++++++++ 3 files changed, 252 insertions(+), 23 deletions(-) diff --git a/src/backend/utils/adt/varlena.c b/src/backend/utils/adt/varlena.c index 6c95fcb24b7..2ab05488c78 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,8 @@ 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 UnicodeNormalizationForm unicode_norm_form_from_text(text *formtxt); +static int valid_ascii_prefix_len(const unsigned char *s, int len); /***************************************************************************** @@ -5419,8 +5422,10 @@ getClosestMatch(ClosestMatchState *state) */ static UnicodeNormalizationForm -unicode_norm_form_from_string(const char *formstr) +unicode_norm_form_from_text(text *formtxt) { + const char *formstr = VARDATA_ANY(formtxt); + int formlen = VARSIZE_ANY_EXHDR(formtxt); UnicodeNormalizationForm form = -1; /* @@ -5431,18 +5436,18 @@ unicode_norm_form_from_string(const char *formstr) (errcode(ERRCODE_SYNTAX_ERROR), errmsg("Unicode normalization can only be performed if server encoding is UTF8"))); - if (pg_strcasecmp(formstr, "NFC") == 0) + if (formlen == 3 && pg_strncasecmp(formstr, "NFC", 3) == 0) form = UNICODE_NFC; - else if (pg_strcasecmp(formstr, "NFD") == 0) + else if (formlen == 3 && pg_strncasecmp(formstr, "NFD", 3) == 0) form = UNICODE_NFD; - else if (pg_strcasecmp(formstr, "NFKC") == 0) + else if (formlen == 4 && pg_strncasecmp(formstr, "NFKC", 4) == 0) form = UNICODE_NFKC; - else if (pg_strcasecmp(formstr, "NFKD") == 0) + else if (formlen == 4 && pg_strncasecmp(formstr, "NFKD", 4) == 0) form = UNICODE_NFKD; else ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("invalid normalization form: %s", formstr))); + errmsg("invalid normalization form: %s", text_to_cstring(formtxt)))); return form; } @@ -5475,6 +5480,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,16 +5516,27 @@ unicode_assigned(PG_FUNCTION_ARGS) { text *input = PG_GETARG_TEXT_PP(0); unsigned char *p; - int size; + unsigned char *end; + int len; + int start; if (GetDatabaseEncoding() != PG_UTF8) ereport(ERROR, (errmsg("Unicode categorization can only be performed if server encoding is UTF8"))); - /* convert to char32_t */ - size = pg_mbstrlen_with_len(VARDATA_ANY(input), VARSIZE_ANY_EXHDR(input)); - p = (unsigned char *) VARDATA_ANY(input); - for (int i = 0; i < size; i++) + /* 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); + + /* + * Check the remaining code points without first counting them, stopping + * at a NUL as pg_mbstrlen_with_len() would. + */ + p = (unsigned char *) VARDATA_ANY(input) + start; + end = (unsigned char *) VARDATA_ANY(input) + len; + while (p < end && *p) { char32_t uchar = utf8_to_unicode(p); int category = unicode_category(uchar); @@ -5511,7 +5554,7 @@ Datum unicode_normalize_func(PG_FUNCTION_ARGS) { text *input = PG_GETARG_TEXT_PP(0); - char *formstr = text_to_cstring(PG_GETARG_TEXT_PP(1)); + text *formtxt = PG_GETARG_TEXT_PP(1); UnicodeNormalizationForm form; size_t size; char32_t *input_chars; @@ -5519,26 +5562,44 @@ 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); + form = unicode_norm_form_from_text(formtxt); + + /* + * 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. No + * canonical composition has an ASCII character as its second element, so + * nothing before that last character can be affected. 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 +5612,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); @@ -5577,7 +5640,7 @@ Datum unicode_is_normalized(PG_FUNCTION_ARGS) { text *input = PG_GETARG_TEXT_PP(0); - char *formstr = text_to_cstring(PG_GETARG_TEXT_PP(1)); + text *formtxt = PG_GETARG_TEXT_PP(1); UnicodeNormalizationForm form; size_t size; char32_t *input_chars; @@ -5587,20 +5650,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); + form = unicode_norm_form_from_text(formtxt); + + /* + * 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..496ee4e7ae1 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,8 +74,35 @@ 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) + +-- Use stored values so this check is not folded to a constant. +CREATE TABLE unicode_ascii_input (val text); +INSERT INTO unicode_ascii_input VALUES + (''), ('abc'), (repeat('a', 64)), (repeat('b', 5000)); +SELECT bool_and(normalize(val) = val COLLATE "C") AS test_ascii_idem_column, + count(pg_column_compression(val)) AS num_compressed +FROM unicode_ascii_input; + test_ascii_idem_column | num_compressed +------------------------+---------------- + t | 1 +(1 row) + +DROP TABLE unicode_ascii_input; SELECT "normalize"('abc', 'def'); -- run-time error ERROR: invalid normalization form: def +SELECT "normalize"('abc', 'NFCx'); -- run-time error +ERROR: invalid normalization form: NFCx +SELECT "normalize"(U&'\0061\0308', 'nFc') = U&'\00E4' COLLATE "C" AS test_form_case; + test_form_case +---------------- + t +(1 row) + SELECT U&'\00E4\24D1c' IS NORMALIZED AS test_default; test_default -------------- @@ -82,6 +115,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 +144,79 @@ ORDER BY num; SELECT is_normalized('abc', 'def'); -- run-time error ERROR: invalid normalization form: def +SELECT is_normalized('abc', 'NFKCx'); -- run-time error +ERROR: invalid normalization form: NFKCx +-- 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..b21353d44f7 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,23 @@ 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; +-- Use stored values so this check is not folded to a constant. +CREATE TABLE unicode_ascii_input (val text); +INSERT INTO unicode_ascii_input VALUES + (''), ('abc'), (repeat('a', 64)), (repeat('b', 5000)); +SELECT bool_and(normalize(val) = val COLLATE "C") AS test_ascii_idem_column, + count(pg_column_compression(val)) AS num_compressed +FROM unicode_ascii_input; +DROP TABLE unicode_ascii_input; SELECT "normalize"('abc', 'def'); -- run-time error +SELECT "normalize"('abc', 'NFCx'); -- run-time error +SELECT "normalize"(U&'\0061\0308', 'nFc') = U&'\00E4' COLLATE "C" AS test_form_case; 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, @@ -36,6 +49,31 @@ FROM ORDER BY num; SELECT is_normalized('abc', 'def'); -- run-time error +SELECT is_normalized('abc', 'NFKCx'); -- 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) -- 2.43.0