FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Fast Unicode normalization functions by dzfrias · Pull Request #999 · simdutf/simdutf · GitHub

Fast Unicode normalization functions - #999

Open
dzfrias wants to merge 51 commits into
simdutf:masterfrom
dzfrias:normalization
Open

Fast Unicode normalization functions#999
dzfrias wants to merge 51 commits into
simdutf:masterfrom
dzfrias:normalization

Conversation

dzfrias commented Jul 16, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

NFD, NFC, NFKD, and NFKC normalization

@lemire Following up on our email conversation.

Only the arm64 and fallback kernels have been implemented so far. UTF-16 normalization has not been ported over yet. Normalization can be enabled/disabled with SIMDUTF_FEATURE_NFD, SIMDUTF_FEATURE_NFC, etc.

Related: #553.

Usage

size_t normalize_utf8_to_FORM(const char *input, size_t input_length, char *output);
bool normalize_utf8_to_FORM_check(const char *input, size_t input_length, size_t *output_length);

FORM can be nfd, nfc, nfkd, or nfkc.

The _check functions return a value that, if true, means that normalization can be skipped for the input. They also write an upper bound output_length for the size of the output buffer.

Tests

tests/normalization_test.cpp verifies that the algorithms pass the NormalizationTest.txt file that the Unicode Character Database gives us. It also tests the vectorized algorithms by copying each input 128 times.

Fuzzing has not yet been set up yet, but definitely should be eventually.

Benchmarks

check function's runtime is not included in the measurements for these benchmarks. Their quick check values are discarded.

$ ./build/benchmarks/benchmark --procedure normalize_utf8_to_nfd --input-file ul/*.txt
We define the number of bytes to be the number of *input* bytes.
We define a 'char' to be a code point (between 1 and 4 bytes).
Using ICU version 78.3
Using iconv version 267
Compiler: Clang 17.0.0
SIMDUTF version: 9.0.0
System: arm64
===========================
testcases: 8
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 81685, iterations: 30000, dataset: ul/Arabic.txt
  10.586 ins/byte,    1.866 cycle/byte,    2.444 GB/s (10.5 %),     4.561 GHz,    5.674 ins/cycle
  18.895 ins/char,    3.330 cycle/char,    1.369 Gc/s (10.5 %)     1.78 byte/char  33417.0 ns
normalize_utf8_to_nfd+icu, input size: 81685, iterations: 30000, dataset: ul/Arabic.txt
  26.504 ins/byte,    4.898 cycle/byte,    0.918 GB/s (10.6 %),     4.495 GHz,    5.412 ins/cycle
  47.307 ins/char,    8.742 cycle/char,    0.514 Gc/s (10.6 %)     1.78 byte/char  89000.0 ns
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 69840, iterations: 30000, dataset: ul/Chinese.txt
   7.765 ins/byte,    0.909 cycle/byte,    5.157 GB/s (11.9 %),     4.687 GHz,    8.544 ins/cycle
  23.115 ins/char,    2.705 cycle/char,    1.732 Gc/s (11.9 %)     2.98 byte/char  13542.0 ns
normalize_utf8_to_nfd+icu, input size: 69840, iterations: 30000, dataset: ul/Chinese.txt
  18.338 ins/byte,    2.543 cycle/byte,    1.785 GB/s (10.2 %),     4.539 GHz,    7.212 ins/cycle
  54.592 ins/char,    7.570 cycle/char,    0.600 Gc/s (10.2 %)     2.98 byte/char  39125.0 ns
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 66495, iterations: 30000, dataset: ul/Hebrew.txt
   8.851 ins/byte,    1.624 cycle/byte,    2.825 GB/s (11.7 %),     4.586 GHz,    5.452 ins/cycle
  15.777 ins/char,    2.894 cycle/char,    1.585 Gc/s (11.7 %)     1.78 byte/char  23541.0 ns
normalize_utf8_to_nfd+icu, input size: 66495, iterations: 30000, dataset: ul/Hebrew.txt
  22.141 ins/byte,    4.016 cycle/byte,    1.125 GB/s (11.9 %),     4.516 GHz,    5.514 ins/cycle
  39.466 ins/char,    7.158 cycle/char,    0.631 Gc/s (11.9 %)     1.78 byte/char  59125.0 ns
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 87997, iterations: 30000, dataset: ul/Hindi.txt
   9.901 ins/byte,    1.447 cycle/byte,    3.166 GB/s (12.4 %),     4.583 GHz,    6.841 ins/cycle
  26.592 ins/char,    3.887 cycle/char,    1.179 Gc/s (12.4 %)     2.69 byte/char  27791.0 ns
normalize_utf8_to_nfd+icu, input size: 87997, iterations: 30000, dataset: ul/Hindi.txt
  22.022 ins/byte,    4.178 cycle/byte,    1.077 GB/s (12.1 %),     4.500 GHz,    5.271 ins/cycle
  59.143 ins/char,   11.221 cycle/char,    0.401 Gc/s (12.1 %)     2.69 byte/char  81709.0 ns
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 67808, iterations: 30000, dataset: ul/Japanese.txt
  10.235 ins/byte,    1.343 cycle/byte,    3.433 GB/s (11.7 %),     4.610 GHz,    7.623 ins/cycle
  29.691 ins/char,    3.895 cycle/char,    1.183 Gc/s (11.7 %)     2.90 byte/char  19750.0 ns
normalize_utf8_to_nfd+icu, input size: 67808, iterations: 30000, dataset: ul/Japanese.txt
  23.083 ins/byte,    3.593 cycle/byte,    1.258 GB/s (11.1 %),     4.519 GHz,    6.424 ins/cycle
  66.964 ins/char,   10.424 cycle/char,    0.434 Gc/s (11.1 %)     2.90 byte/char  53916.0 ns
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 66600, iterations: 30000, dataset: ul/Korean.txt
  24.547 ins/byte,    4.524 cycle/byte,    1.003 GB/s (10.1 %),     4.539 GHz,    5.426 ins/cycle
  60.228 ins/char,   11.099 cycle/char,    0.409 Gc/s (10.1 %)     2.45 byte/char  66375.0 ns
normalize_utf8_to_nfd+icu, input size: 66600, iterations: 30000, dataset: ul/Korean.txt
  69.790 ins/byte,   12.465 cycle/byte,    0.360 GB/s (15.1 %),     4.485 GHz,    5.599 ins/cycle
 171.236 ins/char,   30.583 cycle/char,    0.147 Gc/s (15.1 %)     2.45 byte/char 185083.0 ns
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 86940, iterations: 30000, dataset: ul/Latin.txt
   0.579 ins/byte,    0.115 cycle/byte,   40.913 GB/s (5.8 %),     4.722 GHz,    5.014 ins/cycle
   0.579 ins/char,    0.115 cycle/char,   40.913 Gc/s (5.8 %)     1.00 byte/char   2125.0 ns
normalize_utf8_to_nfd+icu, input size: 86940, iterations: 30000, dataset: ul/Latin.txt
  16.548 ins/byte,   10.170 cycle/byte,    0.436 GB/s (0.8 %),     4.430 GHz,    1.627 ins/cycle
  16.548 ins/char,   10.170 cycle/char,    0.436 Gc/s (0.8 %)     1.00 byte/char 199583.0 ns
input detected as UTF8
===========================
normalize_utf8_to_nfd+arm64, input size: 104770, iterations: 30000, dataset: ul/Russian.txt
   8.576 ins/byte,    1.543 cycle/byte,    2.951 GB/s (11.7 %),     4.554 GHz,    5.558 ins/cycle
  15.497 ins/char,    2.788 cycle/char,    1.633 Gc/s (11.7 %)     1.81 byte/char  35500.0 ns
normalize_utf8_to_nfd+icu, input size: 104770, iterations: 30000, dataset: ul/Russian.txt
  22.156 ins/byte,    4.691 cycle/byte,    0.958 GB/s (13.1 %),     4.491 GHz,    4.723 ins/cycle
  40.035 ins/char,    8.476 cycle/char,    0.530 Gc/s (13.1 %)     1.81 byte/char 109416.0 ns

NFC, NFKC, and NFKD results are similar or better.

LLM Disclosure

LLMs assisted me in doing the following, with supervision:

  • Generating benchmark code
  • Porting some functions from xxUTF to simdutf after I had given it a suitable examples
  • Generating stub implementations (that just call the scalar code) for kernels that were not the fallback or arm64 kernel

All other code and documentation was written by me.

TODO

  • I added/updated tests covering my change (if applicable)
  • Code builds locally and passes my check
  • Documentation / README updated if needed
  • Commits are atomic and messages are clear
  • I linked the related issue (if applicable)

There are a few things that are worth discussing in this PR:

  • Data table size: running scripts/normalization.py will show a size breakdown of all the tables. I have a few ideas to cut down the size by ~15kb. Lookups of code points in the BMP are done using a two-stage trie. I think its compactness relative to size is pretty good. Lookups in the supplementary plane are done using a minimal perfect hash table. Although the compactness is great, it is noticeably slower and is harder to vectorize. Suggestions to make supplementary code point lookups faster are welcome.
  • Continuing off of the previous point, the arm64 normalization algorithms fall back to scalar when supplementary code points are detected. This shouldn't be extremely difficult to fix, though, once a good strategy for looking up supplementary code points is decided on.
  • A small but impactful optimization has not yet been ported over from xxUTF: xxUTF uses a modified version of the UTF-8 code point indexing tables (used for converting UTF-8 -> UTF-16) that defines another chunk class. This chunk class is a subset of the 1-2 byte chunk class, but all chunks in this class are <= 8 bytes in size (so they are primarily ASCII). Going scalar to decompose these chunks is faster than the normal fast path. This leads to a good speed increase for Latin-alphabet languages that have a sparse distribution of decomposable code points, such as Spanish or French. I have not yet ported this over because it involves changing the existing UTF-8 code point index table. No performance changes are expected in other algorithms relying on this table, though, should this change be implemented.
  • Actually performing canonical composition is pretty slow, but the good news is that I've spent almost no time optimizing it, since it's not often that it has to be called in real world text (most text is in composed form already, so already composed chunks can be skipped). Anyway, there are a lot of low hanging fruit to pick when it comes to optimizing it (lots of things that can be cached).
  • UTF-16 normalization should be ported over. I'll wait for feedback on the design of my implementation for UTF-8 before doing that, though.
  • Code duplication: vectorized parsing UTF-8 into code points is pretty much identical to convert_utf8_to_utf32. I didn't put this logic in a helper function to remove the duplicate code, though. Not sure if that is of interest. Also, the check functions have identical implementations across composition and decomposition. I didn't deduplicate this because I plan on eventually changing the composition check functions to give tighter bounds on output length once I figure out a fast way to do that.
  • How does the API look?

dzfrias changed the title Start implementation of fast Unicode normalization functions Fast Unicode normalization functions Jul 17, 2026
Comment thread include/simdutf/scalar/normalization.h Outdated
Comment thread include/simdutf/scalar/normalization.h Outdated

lemire commented Jul 17, 2026

Copy link
Copy Markdown
Member

@dzfrias Great!

Don't worry about the clang format CI errors, we can handle that later.

Co-authored-by: Daniel Lemire <daniel@lemire.me>

dzfrias commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Sounds good. I will start work on porting UTF-16 functions!

dzfrias added 8 commits July 18, 2026 07:59
Add a new class of 12-byte chunk when parsing UTF-8 that indicates that
the size (in bytes) of the input is less than or equal to 8 bytes. In
such cases, we can know that the input is primarily ASCII. This
information lets us take a fast path for ASCII-heavy inputs. The
optimization gives a substantial throughput increase on ASCII-heavy
languages that have diacritics, such as Spanish and French.
Our scalar version is now competitive with ICU.

dzfrias commented Jul 20, 2026
edited
Loading

Copy link
Copy Markdown
Contributor Author

@lemire Finished UTF-16 normalization, which should be ready for review. Looks like we will have to wait until GitHub Actions comes alive again before seeing CI results, though.

Here are the benchmark results:

$ ./build/benchmarks/benchmark --procedure normalize_utf16le_to_nfd --input-file ul/*.utf16.txt
We define the number of bytes to be the number of *input* bytes.
We define a 'char' to be a code point (between 1 and 4 bytes).
Using ICU version 78.3
Using iconv version 267
Compiler: Clang 17.0.0
SIMDUTF version: 9.0.0
System: arm64
===========================
testcases: 9
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 91530, iterations: 30000, dataset: ul/Arabic-Lipsum.utf16.txt
   7.772 ins/byte,    1.053 cycle/byte,    4.367 GB/s (16.3 %),     4.598 GHz,    7.381 ins/cycle
  15.543 ins/char,    2.106 cycle/char,    2.184 Gc/s (16.3 %)     2.00 byte/char  20959.0 ns
normalize_utf16le_to_nfd+icu, input size: 91530, iterations: 30000, dataset: ul/Arabic-Lipsum.utf16.txt
  13.059 ins/byte,    2.337 cycle/byte,    1.960 GB/s (11.6 %),     4.579 GHz,    5.588 ins/cycle
  26.118 ins/char,    4.674 cycle/char,    0.980 Gc/s (11.6 %)     2.00 byte/char  46709.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 46922, iterations: 30000, dataset: ul/Chinese-Lipsum.utf16.txt
   6.711 ins/byte,    0.794 cycle/byte,    6.054 GB/s (11.7 %),     4.807 GHz,    8.452 ins/cycle
  13.422 ins/char,    1.588 cycle/char,    3.027 Gc/s (11.7 %)     2.00 byte/char   7750.0 ns
normalize_utf16le_to_nfd+icu, input size: 46922, iterations: 30000, dataset: ul/Chinese-Lipsum.utf16.txt
  10.612 ins/byte,    1.337 cycle/byte,    3.497 GB/s (11.7 %),     4.676 GHz,    7.936 ins/cycle
  21.224 ins/char,    2.674 cycle/char,    1.749 Gc/s (11.7 %)     2.00 byte/char  13417.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 74612, iterations: 30000, dataset: ul/Hebrew-Lipsum.utf16.txt
   6.609 ins/byte,    0.753 cycle/byte,    6.261 GB/s (17.9 %),     4.712 GHz,    8.781 ins/cycle
  13.219 ins/char,    1.505 cycle/char,    3.130 Gc/s (17.9 %)     2.00 byte/char  11917.0 ns
normalize_utf16le_to_nfd+icu, input size: 74612, iterations: 30000, dataset: ul/Hebrew-Lipsum.utf16.txt
   9.158 ins/byte,    1.619 cycle/byte,    2.816 GB/s (11.0 %),     4.558 GHz,    5.657 ins/cycle
  18.316 ins/char,    3.238 cycle/char,    1.408 Gc/s (11.0 %)     2.00 byte/char  26500.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 65532, iterations: 30000, dataset: ul/Hindi-Lipsum.utf16.txt
   8.013 ins/byte,    0.992 cycle/byte,    4.709 GB/s (15.3 %),     4.672 GHz,    8.077 ins/cycle
  16.026 ins/char,    1.984 cycle/char,    2.355 Gc/s (15.3 %)     2.00 byte/char  13916.0 ns
normalize_utf16le_to_nfd+icu, input size: 65532, iterations: 30000, dataset: ul/Hindi-Lipsum.utf16.txt
  14.728 ins/byte,    2.533 cycle/byte,    1.795 GB/s (12.0 %),     4.548 GHz,    5.814 ins/cycle
  29.456 ins/char,    5.067 cycle/char,    0.898 Gc/s (12.0 %)     2.00 byte/char  36500.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 46750, iterations: 30000, dataset: ul/Japanese-Lipsum.utf16.txt
   8.797 ins/byte,    1.242 cycle/byte,    3.778 GB/s (16.4 %),     4.691 GHz,    7.084 ins/cycle
  17.594 ins/char,    2.484 cycle/char,    1.889 Gc/s (16.4 %)     2.00 byte/char  12375.0 ns
normalize_utf16le_to_nfd+icu, input size: 46750, iterations: 30000, dataset: ul/Japanese-Lipsum.utf16.txt
  17.284 ins/byte,    2.599 cycle/byte,    1.759 GB/s (11.3 %),     4.570 GHz,    6.651 ins/cycle
  34.568 ins/char,    5.198 cycle/char,    0.879 Gc/s (11.3 %)     2.00 byte/char  26583.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 54290, iterations: 30000, dataset: ul/Korean-Lipsum.utf16.txt
  21.716 ins/byte,    4.859 cycle/byte,    0.929 GB/s (13.5 %),     4.512 GHz,    4.470 ins/cycle
  43.432 ins/char,    9.717 cycle/char,    0.464 Gc/s (13.5 %)     2.00 byte/char  58458.0 ns
normalize_utf16le_to_nfd+icu, input size: 54290, iterations: 30000, dataset: ul/Korean-Lipsum.utf16.txt
  72.205 ins/byte,   13.979 cycle/byte,    0.326 GB/s (11.8 %),     4.556 GHz,    5.165 ins/cycle
 144.410 ins/char,   27.958 cycle/char,    0.163 Gc/s (11.8 %)     2.00 byte/char 166583.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 173882, iterations: 30000, dataset: ul/Latin-Lipsum.utf16.txt
   0.668 ins/byte,    0.201 cycle/byte,   24.265 GB/s (27.7 %),     4.881 GHz,    3.323 ins/cycle
   1.337 ins/char,    0.402 cycle/char,   12.132 Gc/s (27.7 %)     2.00 byte/char   7166.0 ns
normalize_utf16le_to_nfd+icu, input size: 173882, iterations: 30000, dataset: ul/Latin-Lipsum.utf16.txt
   3.963 ins/byte,    1.131 cycle/byte,    4.009 GB/s (4.7 %),     4.534 GHz,    3.504 ins/cycle
   7.926 ins/char,    2.262 cycle/char,    2.004 Gc/s (4.7 %)     2.00 byte/char  43375.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 115962, iterations: 30000, dataset: ul/Russian-Lipsum.utf16.txt
   6.548 ins/byte,    0.729 cycle/byte,    5.738 GB/s (6.1 %),     4.184 GHz,    8.980 ins/cycle
  13.097 ins/char,    1.458 cycle/char,    2.869 Gc/s (6.1 %)     2.00 byte/char  20208.0 ns
normalize_utf16le_to_nfd+icu, input size: 115962, iterations: 30000, dataset: ul/Russian-Lipsum.utf16.txt
   9.251 ins/byte,    1.712 cycle/byte,    2.648 GB/s (13.5 %),     4.533 GHz,    5.404 ins/cycle
  18.502 ins/char,    3.424 cycle/char,    1.324 Gc/s (13.5 %)     2.00 byte/char  43791.0 ns
input detected as UTF16 little-endian
===========================
normalize_utf16le_to_nfd+arm64, input size: 147724, iterations: 30000, dataset: ul/Spanish-Lipsum.utf16.txt
   2.145 ins/byte,    0.355 cycle/byte,   13.229 GB/s (13.2 %),     4.701 GHz,    6.036 ins/cycle
   4.290 ins/char,    0.711 cycle/char,    6.614 Gc/s (13.2 %)     2.00 byte/char  11167.0 ns
normalize_utf16le_to_nfd+icu, input size: 147724, iterations: 30000, dataset: ul/Spanish-Lipsum.utf16.txt
   5.911 ins/byte,    1.660 cycle/byte,    2.721 GB/s (7.8 %),     4.516 GHz,    3.562 ins/cycle
  11.823 ins/char,    3.319 cycle/char,    1.360 Gc/s (7.8 %)     2.00 byte/char  54292.0 ns

NFKD, NFC, and NFKC results are similar or better.

Main points of consideration with this implementation:

  • It has many of the same issues that I mentioned above as UTF-8 normalization does (falls back to scalar on supplementary, supplementary is slow, and canonical recomposition is slow)
  • Scalar UTF-16 composition is a big weak spot. On my benchmarks, it is faster than ICU on all inputs except for Latin, in which case it is slower. It would be nice if scalar UTF-16 composition was faster than ICU in all cases.
  • I didn't optimize these functions as heavily as I did with UTF-8. I left comments in the places where I thought the implementations could be faster.

Also, in this batch of commits, I changed the UTF-8 code point index table, which resulted in a significant speed up for decomposing Spanish. This came with no changes to the other algorithms relying on this table, though (it reordered the table, but kept the existing class index boundaries the same, so the idx < 64 etc. stuff didn't change).

Lastly, I'm happy to provide more details about the implementation if there are any questions! I was also curious if there is interest in a UTF-32 implementation. I don't really see UTF-32 strings being normalized anywhere in the wild, but I could write an implementation for it. It would be almost identical to the UTF-16 implementation, but probably a bit slower.

lemire commented Jul 20, 2026

Copy link
Copy Markdown
Member

Tests should run in CI.

I will have to review all of this carefully, and possibly do some tuning, but it is a great start.

lemire added 3 commits July 23, 2026 20:40
Route the icelake NFD/NFKD/NFC/NFKC UTF-8 entry points through a native
AVX-512 outer driver instead of the pure scalar fallback. The driver finds
code-point boundaries with _mm512_cmplt_epi8_mask and takes a 63-byte
all-ASCII fast store; per-window masked kernels eagerly skip leading ASCII
runs and reuse the existing scalar decompose/compose routines for the rest.

icelake has no simd8x64 abstraction, so the outer loop is written natively
rather than reusing the portable generic/ driver used by arm64.

Speedup on Emerald Rapids vs scalar: 3-10x on ASCII-heavy text (english,
french), 1.3-2x on CJK/Cyrillic.
The normalization tables add ~432 kB to the binary -- they more than double
the size of the library -- so enabling all four forms by default makes every
simdutf user pay for a feature most never call. That is especially unwelcome
for the amalgamated build, which is often embedded directly.

SIMDUTF_FEATURE_NFD/NFKD/NFC/NFKC now default to 0. A new meta macro
SIMDUTF_FEATURE_NORMALIZATION (also 0) enables all four at once, and each
per-form macro defaults to it, so individual forms can still be selected.
New CMake options SIMDUTF_NORMALIZATION, SIMDUTF_NFD, SIMDUTF_NFKD,
SIMDUTF_NFC and SIMDUTF_NFKC set them. They are applied PUBLIC because the
declarations in simdutf/implementation.h are guarded by the same macros, so
consumers must see the values the library was built with.

normalization_test is only registered when a form is enabled, and the
benchmark code that calls the simdutf normalization API is guarded; the ICU
normalization benchmarks are untouched since they only use ICU. CI enables
SIMDUTF_NORMALIZATION for ubuntu22, macos-latest and both sanitizer jobs so
the feature stays covered on x86, arm64, ASAN and UBSAN.

Register SIMDUTF_FEATURE_NORMALIZATION in singleheader/amalgamate.py, which
validates every feature macro against a whitelist and would otherwise abort
every selective amalgamation.

This also fixes the feature combinations that did not compile. NF(K)C is
NF(K)D followed by canonical composition, so the composed forms need the
decomposition machinery, but several guards on the decomposed side tested
only NFD || NFKD, and the arm64 generic-driver guard tested NFD || NFC,
omitting NFKD. Four of the sixteen combinations failed to build; all sixteen
now compile.

Finally, the normalization tables are included only when a form is enabled.
That is a compile-time change only -- the tables are const and in an
anonymous namespace, so the compiler already dropped the unused ones and the
object was byte-identical -- but it avoids parsing ~18k lines and makes the
dependency explicit.
The README had no mention of normalization at all. Add a section covering
the four forms, the API (including the check-then-normalize pattern that is
the expected usage, since most text is already NFC), and the fact that the
input is assumed to be valid UTF-8 or UTF-16 as no validation is performed.

Document that normalization is opt-in and how to enable it, both from CMake
and by defining the feature macros directly, and note that the declarations
in simdutf.h are guarded by those macros: consumers that do not build
through CMake must define them for their own translation units too.

Also record in the single-header section that the normalization macros
default to 0, unlike the other features, and that a default amalgamation
leaves them overridable at compile time.

README.md is reflowed with scripts/reflow_readme.py.

lemire commented Jul 24, 2026

Copy link
Copy Markdown
Member

@dzfrias I have a few PRs that I submitted to your branch.

One source of concern is that these features will need to be disabled by default because they would, otherwise, significantly increase the binary size for everyone. That is fine because our library is designed for selective features.

icelake: AVX-512 UTF-8 normalization

dzfrias commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Added benchmarks for the check functions, since they're an important part of the API. They are currently a little bit slower than their writing counterparts on a number of non-ASCII inputs, which I think should be investigated either in this PR or later.

lemire commented Jul 26, 2026

Copy link
Copy Markdown
Member

We definitely cannot be broadly slower than UCI. At least, I would not want to release such code in simdutf. So we need to do significant more work on the performance, and, possibly, on the interface. We should at least be faster on one platform such as arm64.

Canonical composition is 2x to 3.5x slower than ICU on arm64 even with the NEON kernels active. Composing decomposed Hangul runs at 0.28x of ICU on the M-series.

Apple M-series (arm64, the branch's vectorized target), ICU 78.3, clang

sample form work? ICU MB/s simdutf MB/s ratio
korean-decomposed NFC yes 1013 280 0.28x
korean-decomposed NFKC yes 934 270 0.29x
compat-decomposed NFKC yes 387 219 0.57x
compat NFKC yes 384 221 0.57x
ascii NFD no 4284 2753 0.64x
latin1-decomposed NFKC yes 616 411 0.67x
korean-decomposed NFD no 3660 2764 0.76x
hindi NFD no 3534 2763 0.78x
latin1-decomposed NFD no 2978 2364 0.79x
latin1 NFD yes 1246 1088 0.87x
japanese NFD yes 1117 1065 0.95x
korean NFD yes 342 934 2.73x
korean NFKD yes 341 1315 3.86x
ascii NFKC no 4431 42036 9.49x
ascii NFC no 4284 43559 10.17x

These numbers drive ICU the way V8 does — quick-check, then normalize only the tail. The built-in benchmark below drives ICU unconditionally, so its ICU column runs slower and simdutf's ratios come out better than shown here; the losses are real either way.

-F is read as raw UTF-16LE — the normalize procedures reinterpret the file
bytes as char16_t (input_data.size() / 2 units), with no transcoding — so the
corpus must be UTF-16LE, not UTF-8. This Python snippet builds a composition-heavy
one with no downloads: it takes multilingual text, NFD-decomposes it so NF(K)C has
real work to do rather than settling on the quick check, and writes UTF-16LE:

python3 - <<'PY'
import unicodedata
samples = [
    "Le vieux château français était déjà là. ",  # accented Latin
    "Ἀλλὰ καὶ ὁ λόγος ",                            # Greek
    "동해물과 백두산이 ",                            # Korean (composes)
    "日本語のテキスト。ガギグ ",                      # Japanese
]
text = unicodedata.normalize("NFD", "".join(samples) * 4000)
open("corpus.utf16le", "wb").write(text.encode("utf-16-le"))
PY
git clone https://github.com/dzfrias/simdutf.git && cd simdutf
git checkout normalization
cmake -B build -DCMAKE_BUILD_TYPE=Release \
      -DSIMDUTF_NORMALIZATION=ON -DSIMDUTF_BENCHMARKS=ON
cmake --build build -j

# -P is a substring match, so each name below selects both the simdutf and the
# +icu procedure. -F takes a dataset; --show-procedures lists everything.
./build/benchmarks/benchmark -F corpus.utf16le \
  -P normalize_utf16le_to_nfc  -P normalize_utf16le_to_nfd \
  -P normalize_utf16le_to_nfkc -P normalize_utf16le_to_nfkd

dzfrias commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

@lemire Thanks for bringing up these benchmarks. I was surprised at a few of the numbers (particularly the NFD ones), but turns I forgot to update the UTF-16 NFD check functions to route to the arm64 implementation (they were still using scalar)! The other numbers, related to recomposition-heavy workloads, were expected. I haven't spent much time looking at profiles for recomposition-heavy workloads because they appeared in none of the corpus inputs I was working with, but it is a point of interest so I will work on optimizing it. Should be many easy optimizations to apply there to get on par with ICU.

You mentioned interface changes. ICU has a pattern of quick check and return the position of the code point failing the quick check (so everything before that code point can be memcpy'd), as opposed to simdutf which returns a boolean. I did not opt to copy the ICU design and I would have to do a thorough investigation to see how much it would help in real-world inputs. I'm not convinced that such an interface would result in substantial performance gains broadly. Perhaps in inputs with very sparse unstable code points across chunks. In any case, I do not believe not it was a big difference maker between ICU and simdutf in the NFD numbers you reported. The reason simdutf was slower is simply because I forgot to route the check implementation to the vectorized implementation.

lemire commented Jul 26, 2026

Copy link
Copy Markdown
Member

@dzfrias It might be. Ultimately it is an empirical matter: what happens when we replace UCI by simdutf in V8 normalization functions. It is either a performance boost or not.

dzfrias commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

That is true. I will do some experimentation with a different check interface after speeding up recomposition. I think UTF-16 NFD should be much faster than ICU as of the latest commit I just pushed regardless of which check interface we decide on. Now starting work on recomposition optimization.

lemire commented Jul 27, 2026
edited
Loading

Copy link
Copy Markdown
Member

@dzfrias

For reference. I bench String.prototype.normalize('NFC') in Node.js.

dataset units ICU ns simdutf ns ICU GB/s simdutf GB/s ratio
Japanese 23374 58462 59292 0.86 0.85 0.99x
Hindi 32765 28245 32479 2.32 2.02 0.87x
Arabic 45764 64787 75908 1.46 1.24 0.85x
Emoji 32770 17523 29837 3.74 2.20 0.59x
Korean 27144 108237 451011 1.04 0.25 0.24x
  git clone git@github.com:lemire/node.git && cd node
  git checkout simdutf-normalize
  ./configure --ninja && ninja -C out/Release -j 14      # ~35 min from cold

  git clone --depth 1 https://github.com/lemire/unicode_lipsum.git /tmp/ul

Then fetch the benchmark script:

  # simdutf (default)
  NORMBENCH_JSON=/tmp/sd.json ./out/Release/node normbench.js /tmp/ul/lipsum NFC
  # ICU, same binary
  NORMBENCH_JSON=/tmp/icu.json ./out/Release/node --no-simdutf-normalize normbench.js /tmp/ul/lipsum NFC

The perf IS better with Latin (English/ASCII) but 4x regression in the Korean case is not great.

lemire commented Jul 27, 2026

Copy link
Copy Markdown
Member

Here is the normbench.js my AI wrote:

'use strict';
// Benchmarks String.prototype.normalize over the unicode_lipsum corpus.
// Usage: node normbench.js <lipsum-dir> [form ...]
const fs = require('fs');
const path = require('path');

const dir = process.argv[2];
const forms = process.argv.length > 3 ? process.argv.slice(3)
                                      : ['NFC', 'NFD', 'NFKC', 'NFKD'];
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.utf8.txt')).sort();

function bench(str, form) {
  // Warm up and let the call site settle.
  let sink = 0;
  for (let i = 0; i < 50; i++) sink += str.normalize(form).length;

  // Pick an iteration count that keeps each timed run around 100 ms.
  const probe = process.hrtime.bigint();
  for (let i = 0; i < 20; i++) sink += str.normalize(form).length;
  const perIter = Number(process.hrtime.bigint() - probe) / 20;
  const iters = Math.max(10, Math.min(20000, Math.round(1e8 / perIter)));

  let best = Infinity;
  for (let round = 0; round < 5; round++) {
    const start = process.hrtime.bigint();
    for (let i = 0; i < iters; i++) sink += str.normalize(form).length;
    const ns = Number(process.hrtime.bigint() - start) / iters;
    if (ns < best) best = ns;
  }
  if (sink === -1) console.log('unreachable');
  return best;
}

const results = [];
for (const file of files) {
  const raw = fs.readFileSync(path.join(dir, file), 'utf8');
  // Force a flat string.
  const str = raw.slice(0);
  const bytes = str.length * 2; // UTF-16 code units
  for (const form of forms) {
    // Same-form input: the common case, where the string is already normalized.
    const same = str.normalize(form);
    const nsSame = bench(same, form);
    // Cross-form input: NFD input normalized to NFC and vice versa, so real
    // work has to happen.
    const other = form === 'NFC' || form === 'NFKC' ?
      str.normalize('NFD') : str.normalize('NFC');
    const nsWork = bench(other, form);
    results.push({
      file: file.replace('.utf8.txt', ''),
      form,
      units: str.length,
      alreadyNs: nsSame,
      alreadyGBs: (same.length * 2) / nsSame,
      workNs: nsWork,
      workGBs: (other.length * 2) / nsWork,
    });
  }
}

const pad = (s, n) => String(s).padEnd(n);
const padS = (s, n) => String(s).padStart(n);
console.log(`node ${process.version}  v8 ${process.versions.v8}`);
console.log(`${pad('dataset', 18)}${pad('form', 6)}${padS('units', 8)}` +
            `${padS('already ns', 12)}${padS('GB/s', 9)}` +
            `${padS('work ns', 12)}${padS('GB/s', 9)}`);
for (const r of results) {
  console.log(`${pad(r.file, 18)}${pad(r.form, 6)}${padS(r.units, 8)}` +
              `${padS(r.alreadyNs.toFixed(0), 12)}${padS(r.alreadyGBs.toFixed(2), 9)}` +
              `${padS(r.workNs.toFixed(0), 12)}${padS(r.workGBs.toFixed(2), 9)}`);
}
fs.writeFileSync(process.env.NORMBENCH_JSON || '/dev/null',
                 JSON.stringify(results, null, 1));

(Not reviewed by me.)

lemire commented Jul 27, 2026

Copy link
Copy Markdown
Member

Ultimately, I will review the benchmarks manually. For now, I would just want to see positive benchmarks on arm64 over a wide range of inputs.

Afterward, we can get fancier.

Moving to x64 and others should not be too hard if we get arm64 working well.

dzfrias commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, this is extremely helpful. I will use these benchmarks as I work to speed up composition.

dzfrias added 8 commits July 30, 2026 08:43
Normalization forms are not preserved under concatenation. This means
streaming normalization (a common use-case) cannot be implemented
without extra work. The relevant functions have been added to the public
API so that streaming normalization can be implemented correctly and
efficiently.

Some rudimentary tests for these functions have also been added.
Supplementary lookups now use a multi-stage trie. arm64 kernel is now
enabled for UTF-16 NFD check functions. Composition is much faster.

dzfrias commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@lemire Made a substantial amount of optimizations:

  • Performing composition is now much faster than before. It is now on par with ICU in the node normbench.js script you gave me, and sometimes faster (it is now faster for Korean). It seems not very worthwhile to try to vectorize the procedure of composition, so I thought on par with ICU (which is itself well optimized) in recomposition heavy workloads is fine. If we want to be substantially faster, we should try to optimize the scalar path.
  • I fixed the supplementary code point problem with a multi-stage trie. Supplementary code point benchmarks like Emoji are almost as good as ICU, but the parsing latency in the vectorized path is causing us to be a little bit slower. I will try to work on this when I can.
  • Performance on Hindi-Lipsum is not perfect yet. I have some ideas for speeding this one up, though.
  • The data tables are now around 70kb smaller than before by virtue of sharing tables between NFD and NF(K)D. The only slowdown induced here could be in cache performance.

I was somewhat avant-garde with these changes and I think they should be fuzzed pretty thoroughly. Especially the new composition algorithm. Composition is a pretty complex procedure and the Unicode Character Database leaves no room for untouched edge cases...

These changes were LLM assisted. Particularly for generating boilerplate code, making propagating breaking changes I made across multiple files (such as when I changed the trie value format), copying some elements of ICU's fast scalar composition algorithm into simdutf, and generating test code. All changes were reviewed by me.

lemire commented Jul 31, 2026

Copy link
Copy Markdown
Member

Thanks.

Will review.

dzfrias commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

I sped up Hindi-Lipsum a little bit. But I also noticed that its NFKC performance is much worse than its NFC performance even though the input has no compatibility decompositions. I need to do a thorough investigation to see why this is happening. Same instructions per byte.

dzfrias commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Found a fix for the NFKC on Hindi but it really isn't pretty. I at first thought it was memory cache performance, but reading the profile on macOS instruments showed that it is related to the instruction cache and instruction alignment. I was able to fix the performance difference by manually aligning certain instructions that had different alignments between NFC and NFKC, but this is obviously extremely suboptimal from a code perspective as it is a compiler and CPU-specific detail. I'll try to fix this by speeding up the algorithm in some way as opposed to fixing the machine code manually...

This speeds up Hindi-Lipsum.utf16.txt under NFC and NFKC and by
reducing scalar register usage and reducing number of reductions.

dzfrias commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Made some small optimizations that fixed performance for Hindi under NFC and NFKC

dzfrias added 3 commits August 1, 2026 13:19
Also keeps more information in vector registers instead of mixing scalar
and vector. Results in decent throughput increase.

dzfrias commented Aug 1, 2026
edited
Loading

Copy link
Copy Markdown
Contributor Author

As of the latest commit, simdutf is better than ICU (on Apple M-series) for all inputs tested by normbench.js across NFC, NFKC, NFD, and NFKD, both when there is work and when there is not

lemire commented Aug 3, 2026

Copy link
Copy Markdown
Member

@dzfrias Thanks. I will review.

(This is complex code so be patient.)

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.

2 participants


Back | FazBrowse Home | New Git URL