Skip to content

Upstream Gecko performance changes to the normalizer and the collator - #8682

Draft
hsivonen wants to merge 2 commits into
unicode-org:mainfrom
hsivonen:upstreamnormcoll
Draft

hsivonen wants to merge 2 commits into
unicode-org:mainfrom
hsivonen:upstreamnormcoll

Conversation

@hsivonen

Copy link
Copy Markdown
Member

Changelog

icu_collator

  • Turn off the identical prefix optimization when the serde feature is enabled (to ensure that the comparison functions implement a total order even with bogus data)
  • For performance, make the collator only accept small-mode CodePointTries in collation data. (datagen has never provided an option to generate different tries.)
  • For performance, fuse normalization CodePointTrie lookups with UTF decoding.
  • Introduce a quick primary check when the first characters of the strings being compared differ (only when the serde feature is not used, since this requires normalization and collation data to agree for the comparison function to implement a total order and is also not compatible with previously-generated data).
  • For performance, some data is hoisted from the root to tailorings in datagen.

icu_normalizer

  • For performance, lookups from the main CodePointTrie are fused with UTF decoding.
  • For performance, introduce a new CodePointTrie-based data structure for the canonical composition operation.
  • Performance improvements to normalizing from linear input buffers.
  • New specialized Latin1 normalizer.

Open issues

  • The PR just drops the POSIX collation locale from datagen. Are we OK with that or do we want a fancier fix for Investigate use cases for en-US-posix collation data adjust datagen ergonomics accordingly #6511?
  • Loading of old-style canonical composition data isn't implemented although code for consuming old-style data is present. (IIRC, @Manishearth previously said he'd write this bit.)
  • Versions of CollationRootV1 and CollationTailoringV1 are not incremented for the new kind of quick primary check data, because the new kind of data isn't used if serde is enabled and when the new quick primary check code is not in use, old data works. (If we want this optimization to work with serde, we'd have to do heavier validation of data: iterate over the trie data and cross-check with the normalization trie that the values are consistent.)
  • serde-enabled builds lose the identical prefix optimization but not because of data changes here: It was already bad to have it with GIGO. It we wanted to validate data for correctness on this point, we'd need to be able to make Char16Trie tell us what strings it can match. I don't know if that's possible. Specifically, the validation would have to find all contractions that match 3 or more characters and end with a starter. Then it would have to check that the characters in the middle (non-first, non-last) of these strings also have contraction CE32s.
  • datagen now generates collation data for the new quick primary check, which requires the consuming code from this patch. Do we want datagen to retain the capability of generating old-style collation data?

Closes #7526

glandium and others added 2 commits November 10, 2026 09:13
…ationElements::new. r=hsivonen

rustc lowers the `['\u{FFFF}'; 2]` array repeat expression in
`CollationElements::new` to a two-iteration store loop, which LLVM ends
up lowering to a `memset_pattern16` libcall, so every call ends up
calling into libSystem in order to initialise 8 bytes of stack.

Writing the array out element by element gives a plain 8-byte immediate
store instead.

The exact conditions that trigger LLVM's behavior are still under
investigation.

Differential Revision: https://phabricator.services.mozilla.com/D319664
@hsivonen hsivonen added the discuss-priority Discuss at the next ICU4X meeting label Nov 11, 2026
#[cfg(feature = "latin1")]
fn split_prefix_latin1<'a, 'b>(left: &'a [u8], right: &'b [u8]) -> (&'a [u8], &'a [u8], &'b [u8]) {
if cfg!(feature = "serde") {
// The identical prefix optimization relies on correct data. Turn it off when `serde` is enabled.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: you can always load data with the _unstable constructor. I do not think this can work.

@Manishearth

Copy link
Copy Markdown
Member

Loading of old-style canonical composition data isn't implemented although code for consuming old-style data is present. (IIRC, @Manishearth previously said he'd write this bit.)

I don't recall offering this. Maybe I offered something else? I don't understand what is going on here well enough to do this, but I might be misunderstanding your ask.

I think it would be helpful to split this PR up as much as possible. There's a lot going on here.

Versions of CollationRootV1 and CollationTailoringV1 are not incremented for the new kind of quick primary check data, because the new kind of data isn't used if serde is enabled and when the new quick primary check code is not in use, old data works. (If we want this optimization to work with serde, we'd have to do heavier validation of data: iterate over the trie data and cross-check with the normalization trie that the values are consistent.)

cc @sffc @robertbastian . we were having problems with this when we did it for datetime stuff

@Manishearth

Copy link
Copy Markdown
Member

serde-enabled builds lose the identical prefix optimization but not because of data changes here: It was already bad to have it with GIGO. It we wanted to validate data for correctness on this point, we'd need to be able to make Char16Trie tell us what strings it can match. I don't know if that's possible. Specifically, the validation would have to find all contractions that match 3 or more characters and end with a starter. Then it would have to check that the characters in the middle (non-first, non-last) of these strings also have contraction CE32s.

I don't understand this. What's wrong with GIGO here? It is an explicit ICU4X data policy that bad data must never cause unsoundness but is allowed to cause nonsense behavior. I would rather have GIGO than have the optimization not work when serde is enabled.

@Manishearth

Manishearth commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

I told an agent to look for some specific things (specifically around data policy, semver, and unstable) and do a first pass review, here is what it had to say:

It does seem to have found some semver issues beyond data policy.

Details

1. What the PR is Doing (High-Level Overview)

This PR is a large mega-PR (8,344 lines of code diff + ~84,000 lines of regenerated baked/JSON data) combining changes across four main workstreams (plus a small commit from Mike Hommey in components/collator/src/comparison.rs initializing upcoming to ['\0', '\u{FFFF}', '\u{FFFF}'] to avoid a memset_pattern16 call on macOS):

  1. Fused UTF/Latin1 Decoding + CodePointTrie Lookups (icu_normalizer & icu_collator):
    • Adopts the fused iterator traits landed in PR Add iterators over UTF/Latin1 slices that fuse a CodePointTrie lookup #8658 (CharsWithTrieDefaultForAsciiEx, Utf8CharsWithTrieDefaultForAsciiEx, Utf16CharsWithTrieEx, Latin1CharsWithTrieEx, and AbstractCodePointTrie) so that decoding UTF-8/UTF-16/Latin1 and looking up trie values happen in a single pass without re-branching on code point ranges inside CodePointTrie::get.
    • Monomorphizes the decomposition trie as FastCodePointTrie<'trie, u32> when not(feature = "serde") and CodePointTrie<'trie, u32> when feature = "serde".
  2. New Canonical Composition Data Structure (NormalizerNfcV2 / CanonicalCompositionsNew):
    • Replaces the Char16Trie in NormalizerNfcV1 with a new NormalizerNfcV2 marker (components/normalizer/src/provider.rs):
      • trie: CodePointTrie<'data, u16> (always Fast), mapping the starter char to a packed (offset << 4) | len into linear slices, or special constants for Hangul L (0xD6A7..=0xFFFF) and Hangul LV (0xD600).
      • linear16: ZeroVec<'data, (u16, u16)> for BMP (combining_mark, composed) pairs and linear24: ZeroVec<'data, (char, char)> for non-BMP pairs.
      • Reversed ASCII vowel trick: Because the 10 ASCII vowels (a, e, i, o, u, A, E, I, O, U) combine with dozens of marks, pairs with an ASCII vowel starter are stored reversed (keyed by the combining mark in the trie, with the ASCII vowel in linear16), bounding every linear scan in Unicode to $\le 10$ elements. Within each slice, pairs are ordered by corpus frequency (provider/source/data/nfc_statistics.json, generated by a new workspace tool tools/composition_freq).
  3. Normalizer Fast Paths & latin1 Module (icu_normalizer):
    • Adds pub mod latin1 (gated on the latin1 feature) with lookup-table-based normalize_{nfd,nfkd,nfkc}_to and bitmask-based split_normalized_{nfd,nfkd,nfkc}.
    • In decomposing_normalize_to!, inline-expands decomposing_next when the next character does not start with a non-starter, and adds a fast path to skip over a single non-decomposing combining mark (0x8000_D8xx) followed by a starter without touching decomposition.buffer.
    • In composing_normalize_to!, uses next_with_minimum_lead(0xCC) for &str, adds a special range check (0x2013..=0x2022 except 0x2017) in UTF-16 so typographic dashes/quotes/bullets stay in the ASCII/BMP passthrough loop without a trie lookup, and makes the UTF-16 loop bimodal.
    • In datagen (provider/source/src/normalizer/mod.rs), sets NON_ROUND_TRIP_MARKER (1 << 30) on Hangul lead jamo (U+1100..U+1113) in NFD/NFKD tries so composition_passthrough_bound (0x0300) can be removed from ComposingNormalizer.
  4. Collator Fast Primary Check, Tailoring Hoisting, and Small-Trie Specialization (icu_collator):
    • Changes CollationData::trie from CodePointTrie<'data, u32> to SmallCodePointTrie<'data, u32>, and changes CollatorBorrowed::tailoring from Option<&'a CollationData<'a>> to &'a CollationData<'a> (pointing to self.root when untailored).
    • Adds a quick primary check at the first code unit difference (both when head.is_empty() before compare_impl and right after the identical-prefix loop inside compare_impl), comparing primary weights directly for simple CE32s, Tag::LongPrimary, Tag::Expansion (for Japanese Hiragana), Tag::Digit (when non-numeric), and inline Hangul syllable comparison.
    • To make the quick primary check sound without consulting the NFD trie, datagen (rewrite_reorderable in provider/source/src/collator/mod.rs) rewrites CE32s for characters with ccc != 0:
      • Tag::LongPrimary $\to$ Tag::LongPrimaryForReorderable (repurposing unused tag 11 U0000).
      • Tag::Expansion $\to$ Tag::ExpansionForReorderable (repurposing unused tag 13 LeadSurrogate).
      • Simple CE32s with common secondary/tertiary weights $\to$ Tag::LongPrimaryForReorderable.
      • Remaining simple CE32s with primary != 0 and ccc != 0 (34 chars in Unicode 17) $\to$ moved into ce32s behind a 1-element Tag::Expansion32.
    • To allow the quick primary check to look up only self.tailoring.trie, datagen (rebuild_data) hoists self-contained root CE32s into tailoring tries for ASCII (0..0x80), Cyrillic, Armenian, Hebrew, Arabic, 7-bit Brahmic script blocks, Hiragana (for ja), and CJK punctuation (for ja/und).

(Note on Henri's PR comment "IIRC, @Manishearth previously said he'd write this bit": that comes from the Jan 29, 2026 notes on #7526, where Henri asked for help writing the serde/versioning glue for the NormalizerNfcV1 $\to$ NormalizerNfcV2 data struct transition.)


2. Data Policies, Versioning, and Robustness Issues

This is the area with the most significant architectural issues relative to ICU4X conventions.

A. Misuse of cfg!(feature = "serde") as a Proxy for "Untrusted / Old Data"

Throughout both icu_collator and icu_normalizer, the PR uses #[cfg(feature = "serde")] / cfg!(not(feature = "serde")) to switch runtime behavior:

  1. In icu_collator (components/collator/src/comparison.rs):
    • When cfg!(feature = "serde") is enabled, all identical-prefix skipping (split_prefix, split_prefix_u16, split_prefix_latin1, and the 'prefix: loop inside compare_impl) is completely disabled (if cfg!(feature = "serde") { return (&[], left, right); }).
    • When cfg!(feature = "serde") is enabled, the quick primary check before compare_impl and inside compare_impl is completely disabled (if head.is_empty() && cfg!(not(feature = "serde"))).
    • NormTrie switches from FastCodePointTrie to untyped CodePointTrie.
  2. In icu_normalizer (components/normalizer/src/lib.rs):
    • type Trie<'trie> switches from FastCodePointTrie<'trie, u32> (not(feature = "serde")) to CodePointTrie<'trie, u32> (feature = "serde").

Why this violates ICU4X's architecture:

  • Breaks Cargo feature additivity: serde is an additive Cargo feature. If any crate in a workspace or dependency graph enables icu_collator/serde or icu_normalizer/serde (including icu4x-datagen or --all-features), even Collator::try_new / DecomposingNormalizer::new_nfd using compiled data silently loses the identical-prefix optimization, loses the quick primary check, and falls back to untyped tries.
  • _unstable constructors don't require serde: As you noted on the PR, a caller can supply old or custom data via try_new_unstable (implementing DataProvider<M>) without enabling the serde Cargo feature. So cfg!(not(feature = "serde")) does not even guarantee that data came from current baked data.
  • Misunderstanding of ICU4X's GIGO policy for Ord: The PR description explains that identical-prefix skipping and the quick primary check are disabled under serde "to ensure that the comparison functions implement a total order even with bogus data". Under ICU4X's GIGO policy, untrusted/bogus data must never cause memory unsafety (UB) or panics, and must terminate—it does not require Collator::compare to uphold mathematical total-order axioms when given self-contradictory collation tables (and Rust's slice::sort guarantees memory safety regardless of Ord consistency).

B. Unversioned Breaking Semantics in Existing V1 Markers (2.x Wire Compatibility)

Several existing V1 data markers have had their invariants/encoding changed without bumping their marker versions or providing compatibility handling:

  1. CollationRootV1 & CollationTailoringV1:
    • New Datagen $\to$ Old 2.x Code (Forward Compatibility Broken): rewrite_reorderable emits Tag::LongPrimaryForReorderable (tag 11, previously Tag::U0000) and Tag::ExpansionForReorderable (tag 13, previously Tag::LeadSurrogate). In ICU4X 2.0–2.4, hitting tag 11 or tag 13 in 'ce32loop triggers debug_assert!(false); // GIGO case and returns FFFD_CE. Thus, blobs generated by new icu_provider_source will fail or miscompare on existing 2.x icu_collator clients whenever a character with ccc != 0 is encountered.
    • Old 2.x Data $\to$ New Code (Backward Compatibility Broken): If an existing 2.x blob is loaded into the new collator, ccc != 0 characters still use Tag::LongPrimary / Tag::Expansion / simple primary CE32s, and tailoring tries do not have root ASCII/script blocks hoisted into them. If the quick primary check runs on old 2.x data, it will treat reorderable characters as non-reorderable and miss untailored characters in tailored locales.
  2. NormalizerNfdDataV1 & NormalizerNfkdDataV1:
    • Old 2.x Data $\to$ New Code: Datagen now sets NON_ROUND_TRIP_MARKER (1 << 30) on Hangul lead jamo (U+1100..U+1113), and ComposingNormalizer removes composition_passthrough_bound (0x0300), relying on U+1100..U+1113 having NON_ROUND_TRIP_MARKER set. If old 2.x NormalizerNfdDataV1 (where U+1100..U+1113 have trie value 0) is loaded into new ComposingNormalizer, Hangul L jamo will be treated as potential_passthrough() == true!
    • Additionally, DecomposingNormalizer::try_new_{nfd,nfkd}_unstable now rejects passthrough_cap < 0x80 and (when not(feature = "serde")) rejects Small tries via <&FastCodePointTrie>::try_from, whereas 2.x NormalizerNfkdDataV1 previously used a Small trie prior to PR Always generate fast-mode data for NFD and NFKD tries #7222.

C. Incomplete NormalizerNfcV1 $\to$ NormalizerNfcV2 Migration

  • In components/normalizer/src/lib.rs, Henri wrote CanonicalCompositionsPayload::{Current(DataPayload<NormalizerNfcV2>), Legacy(DataPayload<NormalizerNfcV1>)} and CanonicalCompositionsBorrowed::{Current, Legacy} with full fallback lookup logic for Char16Trie in Legacy—but Legacy is never constructed anywhere in the PR (it is dead code!).
  • Meanwhile, icu_normalizer::provider::MARKERS includes both NormalizerNfcV1::INFO and NormalizerNfcV2::INFO, so baked data (icu_normalizer_data) currently bakes both tables.
  • All try_new_*_unstable constructors (and try_new_*_with_buffer_provider) were switched directly to NormalizerNfcV2, which means:
    1. Existing 2.x postcard blobs (which only contain normalizer/nfc/v1) will fail try_new_nfc_with_buffer_provider with MarkerNotFound.
    2. Every runtime compose / compose_starter call pays an enum match branch between Current and Legacy even though Legacy is unreachable!
  • How we should handle NormalizerNfcV1 / NormalizerNfcV2 in ICU4X:
    • Either keep try_new_*_unstable on NormalizerNfcV1 (or add V2 constructors while keeping the V1 ones / having with_buffer_provider try V2 and fall back to V1 using CanonicalCompositionsPayload::Legacy), or do a custom Deserialize on the existing marker (like YearNamesV1 / MonthNamesV1 in icu_datetime), and gate the Legacy enum variant on #[cfg(feature = "serde")] (or when V1 is used) so compiled_data doesn't pay for or carry Char16Trie fallback logic or bake NormalizerNfcV1 when unused.

3. Public API Changes & ICU4X Style Issues

  1. Hard SemVer Breaking Change on Stable Uts46Mapper::try_new:
    • In components/normalizer/src/uts46.rs, Uts46Mapper::try_new (which is a stable method, not _unstable!) changes its trait bound from D: DataProvider<NormalizerNfcV1> to D: DataProvider<NormalizerNfcV2>.
    • ComposingNormalizer::try_new_{nfc,nfkc,uts46}_unstable, CanonicalComposition::try_new_unstable, and icu_experimental::transliterate::Transliterator::try_new_unstable also change their bounds from NormalizerNfcV1 to NormalizerNfcV2.
  2. Issues with the New icu_normalizer::latin1 Module:
    • Broken Cargo feature dependency (latin1 without utf16_iter): In components/normalizer/Cargo.toml, latin1 = [] does not enable dep:write16, even though components/normalizer/src/latin1.rs unconditionally imports use write16::Write16;. Building icu_normalizer --no-default-features --features latin1 fails to compile! It must be latin1 = ["dep:write16"].
    • Confusing / Footgun Signature on latin1::normalize_{nfd,nfkd,nfkc}_to:
      pub fn normalize_nfd_to<W: Write16 + ?Sized>(text: &[u16], sink: &mut W) -> core::fmt::Result
      Despite being in pub mod latin1 and documented as "Normalize Latin1 text to NFD UTF-16 written to sink", text is &[u16], not &[u8]! And if a caller passes a &[u16] containing any non-Latin1 code units (>= 0x0100), table.get(c.wrapping_sub(0xC0) as usize) returns None and silently copies the un-normalized UTF-16 code units to sink as if they were already normalized! (It appears this was written for a Gecko path that zero-extends &[u8] to &[u16] first, or expects the caller to guarantee all u16s are < 0x100, which is neither checked nor documented).
    • ICU4X API style: In ICU4X, normalization operations are methods on DecomposingNormalizerBorrowed / ComposingNormalizerBorrowed rather than standalone functions in a submodule, and feature-gated items need ✨ *Enabled with the latin1 Cargo feature.* doc annotations.
  3. Accidental Public Items & Unhidden datagen Exports:
    • pub fn starter_and_decomposes_to_self_except_replacement(trie_val: u32) -> bool in components/normalizer/src/lib.rs:767 is accidentally made pub at the crate root (when feature = "utf8_iter" is enabled). It should be private (fn).
    • In components/collator/src/elements.rs, internal types pub enum Tag and pub struct CollationElement32 are made pub, and re-exported in components/collator/src/provider.rs (pub use crate::elements::{COMMON_SEC_AND_TER_CE32, CollationElement32, LONG_PRIMARY_FOR_REORDERABLE_CE32_LOW_BYTE, MAX_INDEX, SPECIAL_CE32_LOW_BYTE, Tag};) and components/collator/src/lib.rs (pub use elements::is_self_contained;) under #[cfg(feature = "datagen")] without #[doc(hidden)]. Because datagen is a public Cargo feature enabled in docs.rs and --all-features, these leak into the public API documentation.
    • In components/collator/src/provider.rs:2519, a new public function #[cfg(all(feature = "compiled_data", feature = "unstable"))] pub fn list_locales() -> impl Iterator<Item = (DataLocale, tinystr::TinyAsciiStr<8>)> is added along with a new optional tinystr dependency in components/collator/Cargo.toml (unstable = ["dep:tinystr"]). This isn't mentioned in the PR description and exposes raw DataLocale and TinyAsciiStr<8> marker attribute internals.
    • provider::CanonicalCompositionsNew in components/normalizer/src/provider.rs:211 uses the suffix New instead of ICU4X's standard V2 naming (CanonicalCompositionsV2).
  4. Datagen Hygiene (panic!, log::info!, en-US-posix, nfc_statistics.json):
    • provider/source/src/collator/mod.rs uses panic!(...) instead of returning DataError in rewrite_reorderable, and has 7 log::info!("Hoist ...") calls in rebuild_data that will spam stdout/logs during icu4x-datagen runs (should be log::trace! or log::debug!).
    • provider/source/src/collator/mod.rs:164 filters out en-US-posix (.filter(|name| !name.contains("POSIX"))). While desirable per Investigate use cases for en-US-posix collation data adjust datagen ergonomics accordingly #6511, bundling it here deletes en-US-posix.json across the test data and adds noise; it should be a tiny standalone PR.
    • Adds tools/composition_freq to the root workspace Cargo.toml and checks in provider/source/data/nfc_statistics.json without license headers or documentation on how/when it should be regenerated.

4. unsafe Code & Soundness Audit

I audited every unsafe block added or modified in the PR:

  1. GIGO Soundness Bug (UB in str::from_utf8_unchecked) in DecomposingNormalizerBorrowed::normalize_utf8_to:
    • Look at the new single-combining-mark fast path in normalize_utf8_to (components/normalizer/src/lib.rs:2613–2660 in the PR):
      if likely(trie_value_indicates_non_decomposing_non_starter(trie_val)) {
          loop {
              if let Some((after_mark, after_mark_trie_value)) = decomposition.delegate.next() {
                  if likely(starter_and_decomposes_to_self_except_replacement(after_mark_trie_value)) {
                      continue 'fast;
                  }
                  if likely(!decomposition_starts_with_non_starter(after_mark_trie_value)) {
                      upcoming = after_mark;
                      trie_val = after_mark_trie_value;
                      break;
                  }
                  // `len_utf8` is OK, since knowing that we have two combining marks
                  // means that neither is U+FFFD, so we didn't have a UTF-8 error.
                  debug_assert_ne!(upcoming, '\u{FFFD}');
                  debug_assert_ne!(after_mark, '\u{FFFD}');
                  #[expect(clippy::indexing_slicing)]
                  let consumed_so_far_slice = &pending_slice[..pending_slice.len()
                      - decomposition.delegate.as_slice().len()
                      - upcoming.len_utf8()
                      - after_mark.len_utf8()];
                  sink.write_str(unsafe { str::from_utf8_unchecked(consumed_so_far_slice) })?;
    • Why this is unsound under GIGO data:
      • DecomposingNormalizer::try_new_{nfd,nfkd,uts46_decomposed}_unstable only validates:
        if (decompositions.get().trie.get(char::REPLACEMENT_CHARACTER) & NON_ROUND_TRIP_MARKER) == 0
      • However, decomposition_starts_with_non_starter(trie_val) checks (trie_val & 0x8001_FF00) == 0x8000_D800, which ignores bit 30 (NON_ROUND_TRIP_MARKER = 0x4000_0000)!
      • If a GIGO trie sets trie.get(U+FFFD) = 0xC000_D801 (which has NON_ROUND_TRIP_MARKER set, so try_new_nfd_unstable accepts it!), and normalize_utf8_to is called on ill-formed UTF-8 such as b"\xC3\x9F\xCC\x81\xE2\x82" ('ß' + \u{0301} + 2-byte ill-formed sequence 0xE2 0x82):
        1. upcoming is '\u{0301}' (len_utf8() == 2).
        2. after_mark decodes the 2-byte error 0xE2 0x82 into '\u{FFFD}' (after_mark.len_utf8() == 3, even though only 2 bytes were consumed from pending_slice!).
        3. decomposition_starts_with_non_starter(0xC000_D801) returns true!
        4. pending_slice.len() (6) - as_slice().len() (0) - 2 - 3 = 1, slicing &pending_slice[..1] = b"\xC3" (the first byte of 2-byte 'ß') and passing it to unsafe { str::from_utf8_unchecked(b"\xC3") } $\to$ Immediate Undefined Behavior!
    • Fix: In try_new_{nfd,nfkd,uts46_decomposed}_unstable, validate that decompositions.get().trie.get(char::REPLACEMENT_CHARACTER) == NON_ROUND_TRIP_MARKER (or check (val & (NON_ROUND_TRIP_MARKER | BACKWARD_COMBINING_MARKER)) == NON_ROUND_TRIP_MARKER), which guarantees U+FFFD can never pass decomposition_starts_with_non_starter or trie_value_indicates_non_decomposing_non_starter.
  2. Hangul L + V Composition char::from_u32_unchecked (components/normalizer/src/lib.rs:3572, 3669):
    • The code does:
      if packed >= HANGUL_L_TRIE_VAL_BASE { // 0xD6A7
          let v = u32::from(second).wrapping_sub(HANGUL_V_BASE);
          if v < HANGUL_V_COUNT { // 21
              let lv = u32::from(packed - HANGUL_L_TRIE_VAL_BASE) + v * HANGUL_T_COUNT;
              return Some(unsafe { char::from_u32_unchecked(HANGUL_S_BASE + lv) });
          }
      }
    • Soundness: This is actually sound even for arbitrary GIGO packed: u16, because HANGUL_L_TRIE_VAL_BASE (0xD6A7 = 54951) was chosen such that 0xFFFF - 0xD6A7 = 10584 = 18 * 588 (the exact max offset for L = 18). Thus, for any packed: u16, HANGUL_S_BASE + lv <= 0xAC00 + (0xFFFF - 0xD6A7) + 20 * 28 = 0xD788 < 0xD800.
    • Style: The // SAFETY: comment should explicitly write out that arithmetic (0xAC00 + (0xFFFF - 0xD6A7) + 20 * 28 = 0xD788 < 0xD800) rather than just saying "Notably packed cannot have been above 0xFFFF, since it came from u16".
  3. UTF-16 Raw Pointer Arithmetic in DecomposingNormalizerBorrowed::normalize_utf16_to (components/normalizer/src/lib.rs:2812–2880):
    • Uses ptr: *const u16 and end: *const u16 to peek at after_mark_code_unit, backing up via ptr = ptr.sub(1) if it's a surrogate (0xD800..=0xDFFF), and calling char::from_u32_unchecked(u32::from(after_mark_code_unit)) only after verifying it is not in 0xD800..=0xDFFF.
    • Soundness: Sound; pointer bounds (ptr != end before .add(1), ptr <= end from the same slice before end.offset_from(ptr)) and surrogate checks are all upheld.

It also suggested this breakdown for splitting this into reviewable changes:

Because this PR mixes data format changes, API additions, datagen changes, and algorithmic fast paths across two components, splitting it into smaller PRs will make it much easier to land cleanly:

  1. PR 1 (Small fix): provider/source cache invalidation fix (with_collation_root_han clearing requests_cache + SerdeCache::get_or_compute) and excluding en-US-posix (Investigate use cases for en-US-posix collation data adjust datagen ergonomics accordingly #6511).
  2. PR 2 (Mechanical refactor): Adopt the fused CodePointTrie iterators (from Add iterators over UTF/Latin1 slices that fuse a CodePointTrie lookup #8658) in icu_normalizer and icu_collator without changing data formats.
  3. PR 3 (Normalizer fast paths): Decomposition single-combining-mark fast path (with the U+FFFD constructor validation fix), UTF-16 dash/quote range check, and latin1 helpers (fixing Cargo.toml dep:write16 and clarifying/fixing &[u16] vs &[u8]).
  4. PR 4 (Normalizer NormalizerNfcV2 data change): Introduce the linear composition table with proper V1/V2 compatibility and without breaking Uts46Mapper::try_new.
  5. PR 5 (Collator fast primary check + tailoring hoisting): Introduce the quick primary check and reorderable CE32 / tailoring hoisting with proper data versioning and without disabling optimizations when feature = "serde" is enabled.

@hsivonen

Copy link
Copy Markdown
Member Author

Loading of old-style canonical composition data isn't implemented although code for consuming old-style data is present. (IIRC, @Manishearth previously said he'd write this bit.)

I don't recall offering this. Maybe I offered something else? I don't understand what is going on here well enough to do this, but I might be misunderstanding your ask.

The PR here has

#[derive(Debug)]
pub(crate) enum CanonicalCompositionsPayload {
    Current(DataPayload<NormalizerNfcV2>),
    #[cfg(feature = "serde")]
    Legacy(DataPayload<NormalizerNfcV1>),
}

but nothing in the PR constructs the Legacy variant. IIRC, when we talked about this in a meeting previously, you offered to write the constructor part that accepts old data and puts it into the Legacy enum variant.

(plus a small commit from Mike Hommey in components/collator/src/comparison.rs initializing upcoming to ['\0', '\u{FFFF}', '\u{FFFF}'] to avoid a memset_pattern16 call on macOS)

That's #8677. It shows up here only because that PR hasn't landed, yet.

A. Misuse of cfg!(feature = "serde") as a Proxy for "Untrusted / Old Data"
...
In icu_normalizer (components/normalizer/src/lib.rs):
type Trie<'trie> switches from FastCodePointTrie<'trie, u32> (not(feature = "serde")) to CodePointTrie<'trie, u32> (feature = "serde").

We previously discussed this at length in a meeting an agreed on this approach: This improves performance when serde isn't enabled but does not take away performance that serde users currently have without this patch. The alternative was excessive monomorphizing and code clutter.

Misunderstanding of ICU4X's GIGO policy for Ord: The PR description explains that identical-prefix skipping and the quick primary check are disabled under serde "to ensure that the comparison functions implement a total order even with bogus data". Under ICU4X's GIGO policy, untrusted/bogus data must never cause memory unsafety (UB) or panics, and must terminate—it does not require Collator::compare to uphold mathematical total-order axioms when given self-contradictory collation tables (and Rust's slice::sort guarantees memory safety regardless of Ord consistency).

The issue is that the comparison functions exhibiting GIGO in a way that doesn't implement a total order has worse outcomes than GIGO usually has.

With Rust slice::sort_by, it means that GIGO in ICU4X can make slice::sort_by panic, which means denial of service. As a policy lawyering matter, the panic isn't inside ICU4X, but the outcome is in practice as bad as ICU4X itself panicing with bogus data.

With FFI and the C++ standard library collections GIGO leading to comparison not implementing total order leads to memory-unsafety.

I suppose we could just do GIGO anyway and document the potential risks.

B. Unversioned Breaking Semantics in Existing V1 Markers (2.x Wire Compatibility)
...
CollationRootV1 & CollationTailoringV1:
New Datagen → Old 2.x Code (Forward Compatibility Broken)

Listed as open issue.

Old 2.x Data → New Code (Backward Compatibility Broken):

The PR tries to use the serde check to turn the quick primary check off to avoid this compatibility issue.

NormalizerNfdDataV1 & NormalizerNfkdDataV1:
Old 2.x Data → New Code: Datagen now sets NON_ROUND_TRIP_MARKER (1 << 30) on Hangul lead jamo (U+1100..U+1113),

I had forgotten about this. I have already forgotten what perf measurements I did here.

Additionally, DecomposingNormalizer::try_new_{nfd,nfkd}_unstable now rejects passthrough_cap < 0x80

We've never generated data that could be rejected, and we've never suggested customizability of normalizations in a way that would be this large a departure from how the specced normalizations behave for characters that have been assigned forever.

Incomplete NormalizerNfcV1 → NormalizerNfcV2 Migration

Listed as open issue.

It appears this was written for a Gecko path that zero-extends &[u8] to &[u16] first, or expects the caller to guarantee all u16s are < 0x100, which is neither checked nor documented).

I benchmarked this a lot, and to get proper perf out of this, it made sense to do SIMD expansion first intead of having this method do one-by-one ALU expansion. Needs docs, sure. (Notably, doing SIMD expansion on a per-subslice basis did not perform well enough. IIRC, I even tried to write more than logically necessary in order not to fall back to ALU when the subslice length wasn't a multiple of the SIMD stride.)

ICU4X API style: In ICU4X, normalization operations are methods on DecomposingNormalizerBorrowed / ComposingNormalizerBorrowed rather than standalone functions in a submodule

Unicode stability policies mean that how the Latin1 range normalizes never changes, so dynamic data loading does not make sense.

@Manishearth

Copy link
Copy Markdown
Member

but nothing in the PR constructs the Legacy variant. IIRC, when we talked about this in a meeting previously, you offered to write the constructor part that accepts old data and puts it into the Legacy enum variant.

Ah. Yes, I can help with that but the PR really needs to be split into much smaller ones if I am to have a hope of understanding things sufficiently.

I also do not know if I currently will have enough time for this, that offer was made quite a while ago with the expectation that this was coming in a few months.

The issue is that the comparison functions exhibiting GIGO in a way that doesn't implement a total order has worse outcomes than GIGO usually has.

With Rust slice::sort_by, it means that GIGO in ICU4X can make slice::sort_by panic, which means denial of service. As a policy lawyering matter, the panic isn't inside ICU4X, but the outcome is in practice as bad as ICU4X itself panicing with bogus data.

With FFI and the C++ standard library collections GIGO leading to comparison not implementing total order leads to memory-unsafety.

I suppose we could just do GIGO anyway and document the potential risks.

Hmm, interesting point, we should probably discuss this as a WG.

I am inclined towards using GIGO anyway and documenting.

One thought is that we can keep this as a separate "serde_total_order_guarantee" feature where you can opt in to that guarantee for serde mode.

We previously discussed this at length in a meeting an agreed on this approach: This improves performance when serde isn't enabled but does not take away performance that serde users currently have without this patch. The alternative was excessive monomorphizing and code clutter.

Since the last time we have discussed this we have realized that this type of hack is not really very pleasant and causes problems: #8576

So I'm a bit wary. I'd also like to have serde users get this optimization if they'd like.

@Manishearth

Manishearth commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Path forward:

  • We document the uncertainty of total ordering on collator (manish)
  • We may add a serde_no_gigo feature later (manish to file a followup)
  • We add a V2 marker that maps to the same struct, except has the reordering invariant. Serde must check invariant and regen data (expensive) on data load of V1. If henri gives me code for mapping structs i'm happy to contribute a patch that fixes data loading
  • There is a preexisting issue around contraction invariant, Henri to file an issue if needed, this PR need not worry about it
  • Henri to feed the PR to robots who will cut it into tiny little pieces for review
  • Henri says that the normalizer Legacy variant is already interpreted, just needs data loading code. Manish to have a look once there is a smaller PR.

@sffc

sffc commented Nov 12, 2026

Copy link
Copy Markdown
Member

My understanding from discussion with @hsivonen:

There are multiple invariants that the collator data structs need to uphold in order for them to produce a total ordering. Here are two that Henri explained:

  1. Contractions, required for the identitical prefix optimization. This invariant is not currently possible to validate at runtime, but could be with more work.
  2. Primary reorderings, required for the primary reordering optimization. This invariant can be validated at runtime by walking the normalizer and collator data together.

The contractions invariant landed in d01e47e, which I verified is in 2.0, which means that the V1 struct has that invariant. When we create a V2, it will add the additional invariant. If we get V1 data in the Serde constructor, we can validate it to make it V2 data.

@hsivonen

Copy link
Copy Markdown
Member Author

Thought after the meeting: Upgrading bogus V2 to V2 is the same operation as upgrading V1 to V2, so if we validate dynamically-loaded V2, we might as well fix it up instead of rejecting. If we do that, there's no difference between loading V1 and V2.

@hsivonen

Copy link
Copy Markdown
Member Author

The upgrade function is rewrite_reorderable in provider/source/src/collator/mod.rs. It needs to move to the provider module of icu_collator.

@Manishearth

Copy link
Copy Markdown
Member

Thought after the meeting: Upgrading bogus V2 to V2 is the same operation as upgrading V1 to V2, so if we validate dynamically-loaded V2, we might as well fix it up instead of rejecting. If we do that, there's no difference between loading V1 and V2.

Ah, but fixing it up is expensive, right? Is it cheap to detect?

We're generally fine with "serde with V1 is slow but becomes fast if you load V2" (fast = normal performance, does not need to achieve all optimizations). We're less fine with pessimizing serde data load significantly with the current version of data.

My understanding was that the route was to have V2 be explicitly defined as having a particular invariant (that old generated data does not have). The changed marker is the signal that the data has this invariant, and we don't actually check it.

If the V1 struct already supports a cheaply-checked way to detect that the data was intended to have the reordering invariant, then yeah we don't need to have a V2. But if it requires expensive validation to detect that, we shouldn't.

@hsivonen

Copy link
Copy Markdown
Member Author

Validation is expensive (iterate though collation trie's data array and do a normalization trie lookup for most of those entries). Fix-up is validation, allocating from ZeroSlice to ZeroVec, and a few bitops.

@Manishearth

Copy link
Copy Markdown
Member

@hsivonen okay, in that case we should always make sure that serde users have a way to avoid expensive iterations by generating new data.

This can either mean that we generate V1 data with some bit set that indicates that it is "newer", or we generate V2 data and use the data marker as a disambiguator. Defaulting to assuming the latter is easier.

@hsivonen hsivonen removed the discuss-priority Discuss at the next ICU4X meeting label Nov 12, 2026
@hsivonen

Copy link
Copy Markdown
Member Author

OK.

@hsivonen

Copy link
Copy Markdown
Member Author

I thought about the data struct upgrade some more. With real data, I expect rewriting the data array to be possible but theoretically wrong. It's both easier and theoretically right to use the new builder code instead.

If we are OK with running the builder code, we could give serde users the fast normalizer by rewriting those old tries upon loading as well. (Perhaps this is also the semver-non-breaking path to fixing #6885.)

@Manishearth

Copy link
Copy Markdown
Member

Yes, I am okay with running the builder code in cases where serde users are loading old data (not new data)

@hsivonen

Copy link
Copy Markdown
Member Author

#8694 now runs the builder for NFD/NFKD.

@Manishearth
Manishearth marked this pull request as draft November 13, 2026 17:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Land normalizer perf improvements

4 participants