Skip to content

Avoid quadratic canonical combining-class ordering - #348

Open
SantanDon wants to merge 2 commits into
JuliaStrings:masterfrom
SantanDon:perf/avoid-quadratic-canonical-ordering
Open

Avoid quadratic canonical combining-class ordering#348
SantanDon wants to merge 2 commits into
JuliaStrings:masterfrom
SantanDon:perf/avoid-quadratic-canonical-ordering

Conversation

@SantanDon

@SantanDon SantanDon commented Aug 24, 2026

Copy link
Copy Markdown

Summary

Avoid quadratic work when canonical ordering processes a long run of combining characters.

utf8proc_decompose_custom() currently restores canonical combining-class order with adjacent swaps and backtracking. Because valid Unicode can contain an unbounded number of combining marks after a starter, a run with many class inversions makes this loop quadratic in the number of marks.

This keeps short combining runs on the existing-style stable insertion path and uses a stable allocation-free LSD radix sort for long runs. The radix path partitions on the eight bits of the canonical combining class and preserves the relative order of equal-class marks.

The ordering does not cross CCC-0 starters or internal CHARBOUND markers.

Reproduction / performance

On current master (0075ed7d0adba45682ee6bf7a83b10f8fd110163), I benchmarked valid UTF-8 consisting of one starter followed by repeated U+0301 (CCC 230) / U+0327 (CCC 202) pairs, built with GCC 15.2 -O2:

input current patched
64 KB 0.747 s 0.003 s
128 KB 2.398 s 0.007 s
256 KB 10.928 s 0.020 s

The current timings approach 4x CPU when input doubles at the larger sizes. The candidate removes that amplification without adding heap allocation to the lower-level decomposition API.

For ordinary short, already ordered combining runs, repeated 1 MB benchmarks remain in the same range (0.535 s baseline vs. 0.587 s patched for 20 rounds in the latest comparison).

Correctness validation

  • CMake/CTest: 9/9 tests pass, including Unicode 17 NormalizationTest.txt and GraphemeBreakTest.txt.
  • Added a regression covering a long combining run, ordering across CCC 202/230, and stable order for two distinct CCC-230 marks.
  • Compared the patched implementation byte-for-byte against unmodified 0075ed7d on a deterministic 600 KB corpus of randomized starters and combining classes 1, 202, 220, 230, 232, and 240.
  • Baseline and patched outputs are identical for NFD, NFC, and DECOMPOSE+CHARBOUND.
  • -Wall -Wextra -Wpedantic build and git diff --check are clean.

Security relevance

This is algorithmic-complexity hardening for applications that normalize attacker-controlled text. The input used above is valid Unicode and requires only a few hundred KB to consume multiple seconds of CPU on the current implementation. I am not claiming code execution or a universal remotely reachable DoS; reachability depends on how a consumer exposes normalization.

Related discussion in #298 concerns combining-run handling for a proposed normalized-equality API. This change targets the existing canonical-ordering path used by utf8proc_map / utf8proc_decompose_custom.

@stevengj

stevengj commented Aug 24, 2026

Copy link
Copy Markdown
Member

Wouldn't it just be simpler to just call qsort?

(Alternatively, since there are only a few combining classes, a radix sort might be more efficient?)

I'm also not thrilled about how verbose this code is.

@SantanDon

Copy link
Copy Markdown
Author

Thanks — radix is a better fit here. I switched the long-run path to an allocation-free stable LSD radix sort and cut the helper code substantially. I didn't use qsort because equal combining classes need to retain their input order, while C qsort isn't stable. The updated version is byte-identical to current master on the randomized NFD/NFC/CHARBOUND differential corpus, passes 9/9 tests, and the 256 KB adversarial case is ~0.020 s vs. ~10.928 s on current master.

Comment thread utf8proc.c
return unsafe_get_property(uc)->combining_class;
}

static void canonical_order_reverse(utf8proc_int32_t *buffer, utf8proc_ssize_t first, utf8proc_ssize_t last) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These algorithms, although they look simple, have non-trivial pre- and post-conditions. I would add a few comments describing these, and also describing the loop invariants. Since the function arguments are used as indices into arrays, respective constraints should also be described.

There is a variable last here, but buffer[last] is never accessed. Presumably it's the last-plus-one index – maybe choose a different name? C++ calls it end.

Comment thread utf8proc.c
}
}

static void canonical_order(utf8proc_int32_t *buffer, utf8proc_ssize_t length) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Switch to a more descriptive function name? Of course, rearrange_into_canonical_order is a bit long. Maybe order_canonically?

Comment thread utf8proc.c
}

static void canonical_order_reverse(utf8proc_int32_t *buffer, utf8proc_ssize_t first, utf8proc_ssize_t last) {
while (first < --last) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this is a generic reverse function, and the canonical_order_ is just a prefix to avoid name collisions. This confused me at first, I thought this would produce a reverse canonical order.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe a comment in front of the function would already be enough.

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.

I would just rename it to reverse_buffer or something like that.

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.

3 participants