Skip to content

Commit d20b637

Browse files
authored
Add UTF-16 toWellFormed/isWellFormed (AVX-512, AVX2, SSE, NEON) (#54)
* Add UTF-16 toWellFormed/isWellFormed with AVX-512, AVX2, SSE and NEON kernels. UTF16.ToWellFormed replaces lone surrogates by U+FFFD (JavaScript's toWellFormed); UTF16.IsWellFormed and GetPointerToFirstInvalidChar validate. The string overload returns well-formed input as is. The kernels are based on simdutf's utf16fix (Clausecker and Lemire, Fixing ill-formed UTF-16 strings with SIMD instructions, Software: Practice and Experience, 2026). Rather than loading a lookback block, they carry the high-surrogate bit across blocks, skip surrogate-free blocks with a min-based pre-check, align output stores, and handle the end of the input with masked loads (AVX-512) or overlapping windows. Other systems fall back on IndexOfAnyInRange. Tests compare every kernel, in place and out of place, against a rune-based reference. Benchmarks compare against IndexOfAnyInRange, EnumerateRunes and Encoding.Unicode. * Clarify IsWellFormed in the README; document and test the IndexOfAnyInRange fallback. The fallback runs only when no SIMD kernel applies (no ARM64 NEON, AVX-512, AVX2 or SSE4.1), so the tests now call it directly on every system.
1 parent 8828efd commit d20b637

5 files changed

Lines changed: 2416 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SimdUnicode
22
[![.NET](https://github.com/simdutf/SimdUnicode/actions/workflows/dotnet.yml/badge.svg)](https://github.com/simdutf/SimdUnicode/actions/workflows/dotnet.yml)
33

4-
This is a fast C# library to validate UTF-8 strings.
4+
This is a fast C# library to validate UTF-8 strings and to make UTF-16 strings well formed.
55

66

77
## Motivation
@@ -230,6 +230,108 @@ faster than the standard library.
230230
| Russian-Lipsum | 2.3 | 0.7 | 3.3 x |
231231

232232

233+
## Well-formed UTF-16 strings
234+
235+
.NET strings may contain lone surrogates. `SimdUnicode.UTF16.ToWellFormed` replaces each
236+
lone surrogate by the replacement character U+FFFD, like JavaScript's
237+
`String.prototype.toWellFormed()`, and `SimdUnicode.UTF16.IsWellFormed` returns true when
238+
there is no lone surrogate (like `isWellFormed()`).
239+
240+
```cs
241+
string s = UTF16.ToWellFormed("ab\uD800cd"); // "ab\uFFFDcd"
242+
// Well-formed strings are returned as is, without allocation.
243+
bool ok = UTF16.IsWellFormed(span);
244+
// Buffer to buffer, or in place (same pointer for input and output).
245+
UTF16.ToWellFormed(char* input, int length, char* output);
246+
// Returns a pointer to the first lone surrogate, or input + length.
247+
char* p = UTF16.GetPointerToFirstInvalidChar(char* input, int length);
248+
```
249+
250+
We provide AVX-512, AVX2, SSE (SSE4.1) and ARM64 (NEON) kernels, selected at runtime, based on simdutf's
251+
algorithms described in the following article:
252+
253+
- Robert Clausecker, Daniel Lemire, Fixing ill-formed UTF-16 strings with SIMD instructions, Software: Practice and Experience, 2026
254+
255+
Other systems fall back on the runtime's vectorized `IndexOfAnyInRange`.
256+
257+
We compare against the best approach we know that uses only public .NET APIs: return the input
258+
when it is well formed, otherwise copy it and fix the errors found with the vectorized
259+
`IndexOfAnyInRange('\uD800', '\uDFFF')`. It is fast on text without surrogates,
260+
but it stops at every surrogate pair, so it is slow on text such as emojis. The idiomatic
261+
`string.Concat(s.EnumerateRunes())` runs at 0.2 GB/s to 0.4 GB/s, and a round trip through
262+
`Encoding.Unicode` at 1.3 GB/s to 7 GB/s.
263+
264+
To reproduce: `dotnet run -c Release --filter "*UTF16WellFormed*"` in the `benchmark` directory.
265+
All results are in GB/s of UTF-16 input, for well-formed inputs. "validate" is `IsWellFormed`
266+
(and `ToWellFormed(string)`, which returns well-formed strings as is); "buffer" writes the
267+
output to a separate buffer; "short strings" cuts the input into strings of 1 to 64 code units
268+
and calls `ToWellFormed(string)` on each.
269+
270+
Intel Xeon Gold 6548N (.NET 10), AVX-512:
271+
272+
| data set | validate: SimdUnicode | validate: IndexOfAnyInRange | buffer: SimdUnicode | buffer: copy + IndexOfAnyInRange | plain copy | short strings: SimdUnicode | short strings: IndexOfAnyInRange |
273+
|:--|--:|--:|--:|--:|--:|--:|--:|
274+
| Twitter.json | 69 | 31 | 26 | 15 | 28 | 9.2 | 7.1 |
275+
| Arabic-Lipsum | 68 | 33 | 43 | 20 | 46 | 17 | 17 |
276+
| Chinese-Lipsum | 115 | 39 | 43 | 20 | 46 | 17 | 16 |
277+
| Emoji-Lipsum | 53 | 0.39 | 40 | 0.39 | 44 | 3.1 | 0.43 |
278+
| Hindi-Lipsum | 68 | 33 | 43 | 21 | 45 | 17 | 17 |
279+
| Japanese-Lipsum | 115 | 39 | 43 | 20 | 46 | 17 | 16 |
280+
| Korean-Lipsum | 68 | 33 | 42 | 20 | 46 | 17 | 17 |
281+
| Latin-Lipsum | 69 | 33 | 43 | 20 | 46 | 17 | 17 |
282+
| Russian-Lipsum | 69 | 33 | 43 | 19 | 46 | 17 | 17 |
283+
284+
Same machine with AVX-512 disabled (`DOTNET_EnableAVX512=0`), AVX2 kernel (Haswell level):
285+
286+
| data set | validate: SimdUnicode | validate: IndexOfAnyInRange | buffer: SimdUnicode | buffer: copy + IndexOfAnyInRange | plain copy | short strings: SimdUnicode | short strings: IndexOfAnyInRange |
287+
|:--|--:|--:|--:|--:|--:|--:|--:|
288+
| Twitter.json | 58 | 41 | 27 | 17 | 28 | 6.5 | 6.4 |
289+
| Arabic-Lipsum | 58 | 42 | 43 | 23 | 47 | 17 | 17 |
290+
| Chinese-Lipsum | 80 | 50 | 43 | 23 | 46 | 17 | 17 |
291+
| Emoji-Lipsum | 35 | 0.50 | 28 | 0.49 | 45 | 3.4 | 0.53 |
292+
| Hindi-Lipsum | 58 | 42 | 43 | 24 | 45 | 17 | 17 |
293+
| Japanese-Lipsum | 77 | 50 | 43 | 24 | 46 | 17 | 17 |
294+
| Korean-Lipsum | 58 | 42 | 43 | 23 | 46 | 17 | 17 |
295+
| Latin-Lipsum | 58 | 42 | 43 | 24 | 46 | 17 | 17 |
296+
| Russian-Lipsum | 58 | 42 | 43 | 22 | 47 | 17 | 17 |
297+
298+
Same machine with AVX disabled (`DOTNET_EnableAVX=0`), SSE kernel (Westmere level):
299+
300+
| data set | validate: SimdUnicode | validate: IndexOfAnyInRange | buffer: SimdUnicode | buffer: copy + IndexOfAnyInRange | plain copy | short strings: SimdUnicode | short strings: IndexOfAnyInRange |
301+
|:--|--:|--:|--:|--:|--:|--:|--:|
302+
| Twitter.json | 50 | 28 | 26 | 15 | 26 | 6.1 | 6.1 |
303+
| Arabic-Lipsum | 51 | 28 | 45 | 18 | 46 | 15 | 13 |
304+
| Chinese-Lipsum | 63 | 28 | 43 | 18 | 46 | 15 | 13 |
305+
| Emoji-Lipsum | 26 | 0.56 | 22 | 0.56 | 45 | 3.3 | 0.55 |
306+
| Hindi-Lipsum | 50 | 28 | 43 | 17 | 45 | 15 | 13 |
307+
| Japanese-Lipsum | 64 | 28 | 44 | 18 | 46 | 15 | 13 |
308+
| Korean-Lipsum | 50 | 28 | 43 | 18 | 46 | 16 | 14 |
309+
| Latin-Lipsum | 51 | 28 | 45 | 18 | 47 | 15 | 13 |
310+
| Russian-Lipsum | 51 | 28 | 45 | 18 | 47 | 15 | 13 |
311+
312+
Apple M4 Max (.NET 10), NEON:
313+
314+
| data set | validate: SimdUnicode | validate: IndexOfAnyInRange | buffer: SimdUnicode | buffer: copy + IndexOfAnyInRange | plain copy | short strings: SimdUnicode | short strings: IndexOfAnyInRange |
315+
|:--|--:|--:|--:|--:|--:|--:|--:|
316+
| Twitter.json | 106 | 62 | 71 | 35 | 84 | 23 | 21 |
317+
| Arabic-Lipsum | 135 | 64 | 64 | 31 | 109 | 22 | 22 |
318+
| Chinese-Lipsum | 134 | 64 | 66 | 40 | 75 | 22 | 22 |
319+
| Emoji-Lipsum | 52 | 2.0 | 52 | 1.6 | 63 | 5.0 | 0.80 |
320+
| Hindi-Lipsum | 135 | 64 | 73 | 38 | 85 | 23 | 23 |
321+
| Japanese-Lipsum | 135 | 64 | 68 | 40 | 82 | 23 | 23 |
322+
| Korean-Lipsum | 135 | 65 | 52 | 40 | 76 | 22 | 23 |
323+
| Latin-Lipsum | 102 | 63 | 80 | 32 | 82 | 23 | 23 |
324+
| Russian-Lipsum | 135 | 65 | 68 | 27 | 106 | 22 | 23 |
325+
326+
- Validation is 1.4 to 3 times faster than `IndexOfAnyInRange`, and 25 to 140 times faster
327+
on emoji-heavy text.
328+
- Buffer to buffer, on x64 we are within 10% of the speed of a plain copy (except on emojis),
329+
and 1.3 to 2.5 times faster than copying and then scanning with `IndexOfAnyInRange` on all systems
330+
(30 to 110 times faster on emojis). On the M4 Max, we do not reach the speed of a plain copy.
331+
- On short strings, we are on par (within 5%) or faster, and 6 to 8 times faster on emojis.
332+
- When the string must be fixed (100 lone surrogates per million code units), both approaches
333+
are dominated by the allocation of the new string, except on emojis.
334+
233335
## Building the library
234336

235337
```

‎benchmark/UTF16_benchmark.cs‎

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
using System;
2+
using System.IO;
3+
using System.Linq;
4+
using System.Text;
5+
using BenchmarkDotNet.Attributes;
6+
using BenchmarkDotNet.Columns;
7+
using BenchmarkDotNet.Configs;
8+
using BenchmarkDotNet.Reports;
9+
using BenchmarkDotNet.Running;
10+
using SimdUnicode;
11+
12+
namespace SimdUnicodeBenchmarks
13+
{
14+
// Speed in GB/s of UTF-16 input (2 bytes per code unit).
15+
public class Utf16Speed : IColumn
16+
{
17+
public string GetValue(Summary summary, BenchmarkCase benchmarkCase)
18+
{
19+
if (summary is null || benchmarkCase is null || benchmarkCase.Parameters is null)
20+
{
21+
return "N/A";
22+
}
23+
var ourReport = summary.Reports.First(x => x.BenchmarkCase.Equals(benchmarkCase));
24+
if (ourReport is null || ourReport.ResultStatistics is null)
25+
{
26+
return "N/A";
27+
}
28+
var fileName = (string)benchmarkCase.Parameters["FileName"];
29+
var errors = (int)benchmarkCase.Parameters["ErrorsPerMillion"];
30+
long bytes = 2L * UTF16WellFormedBenchmark.Load(fileName, errors).Length;
31+
return $"{(bytes / ourReport.ResultStatistics.Mean):#####.00}";
32+
}
33+
34+
public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyle style) => GetValue(summary, benchmarkCase);
35+
public bool IsDefault(Summary summary, BenchmarkCase benchmarkCase) => false;
36+
public bool IsAvailable(Summary summary) => true;
37+
38+
public string Id { get; } = nameof(Utf16Speed);
39+
public string ColumnName { get; } = "Speed (GB/s)";
40+
public bool AlwaysShow { get; } = true;
41+
public ColumnCategory Category { get; } = ColumnCategory.Custom;
42+
public int PriorityInCategory { get; }
43+
public bool IsNumeric { get; }
44+
public UnitType UnitType { get; } = UnitType.Dimensionless;
45+
public string Legend { get; } = "The speed in gigabytes per second (UTF-16 input)";
46+
}
47+
48+
// Replacing lone surrogates by U+FFFD (JavaScript's toWellFormed).
49+
// Run with: dotnet run -c Release --filter "*UTF16WellFormed*"
50+
[SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)]
51+
[Config(typeof(Config))]
52+
public class UTF16WellFormedBenchmark
53+
{
54+
#pragma warning disable CA1812
55+
private sealed class Config : ManualConfig
56+
{
57+
public Config()
58+
{
59+
AddColumn(new Utf16Speed());
60+
}
61+
}
62+
63+
[Params(@"data/twitter.json",
64+
@"data/Arabic-Lipsum.utf8.txt",
65+
@"data/Chinese-Lipsum.utf8.txt",
66+
@"data/Emoji-Lipsum.utf8.txt",
67+
@"data/Hebrew-Lipsum.utf8.txt",
68+
@"data/Hindi-Lipsum.utf8.txt",
69+
@"data/Japanese-Lipsum.utf8.txt",
70+
@"data/Korean-Lipsum.utf8.txt",
71+
@"data/Latin-Lipsum.utf8.txt",
72+
@"data/Russian-Lipsum.utf8.txt")]
73+
#pragma warning disable CA1051
74+
public string FileName = "";
75+
76+
// 0: well-formed input (the common case). Otherwise, about this many code units
77+
// per million are overwritten with lone surrogates.
78+
[Params(0, 100)]
79+
public int ErrorsPerMillion;
80+
81+
private string input = "";
82+
private char[] output = Array.Empty<char>();
83+
// The same content, cut into short strings of 1 to 64 code units.
84+
private string[] shortStrings = Array.Empty<string>();
85+
86+
public static string Load(string fileName, int errorsPerMillion)
87+
{
88+
char[] chars = Encoding.UTF8.GetString(File.ReadAllBytes(fileName)).ToCharArray();
89+
if (errorsPerMillion > 0)
90+
{
91+
var rand = new Random(1234);
92+
long count = (long)chars.Length * errorsPerMillion / 1_000_000 + 1;
93+
for (long k = 0; k < count; k++)
94+
{
95+
int pos = rand.Next(chars.Length);
96+
chars[pos] = (char)(rand.Next(2) == 0 ? 0xD800 + rand.Next(0x400) : 0xDC00 + rand.Next(0x400));
97+
}
98+
}
99+
return new string(chars);
100+
}
101+
102+
[GlobalSetup]
103+
public void Setup()
104+
{
105+
input = Load(FileName, ErrorsPerMillion);
106+
output = new char[input.Length];
107+
var rand = new Random(4321);
108+
var pieces = new System.Collections.Generic.List<string>();
109+
for (int i = 0; i < input.Length;)
110+
{
111+
int len = Math.Min(rand.Next(1, 65), input.Length - i);
112+
pieces.Add(input.Substring(i, len));
113+
i += len;
114+
}
115+
shortStrings = pieces.ToArray();
116+
string expected = RunesToWellFormed(input);
117+
if (UTF16.ToWellFormed(input) != expected || IndexOfAnyToWellFormed(input) != expected)
118+
{
119+
throw new InvalidOperationException("Mismatch between implementations.");
120+
}
121+
}
122+
123+
// The idiomatic version: decode rune by rune.
124+
private static string RunesToWellFormed(string s) => string.Concat(s.EnumerateRunes());
125+
126+
// The best version we know that uses only public .NET APIs: return the input
127+
// when possible and skip non-surrogates with the vectorized IndexOfAnyInRange.
128+
private static string IndexOfAnyToWellFormed(string s)
129+
{
130+
int first = NextError(s, 0);
131+
if (first < 0)
132+
{
133+
return s;
134+
}
135+
return string.Create(s.Length, (s, first), static (dst, state) =>
136+
{
137+
var (src, i) = state;
138+
src.AsSpan().CopyTo(dst);
139+
do
140+
{
141+
dst[i] = '\uFFFD';
142+
i = NextError(src, i + 1);
143+
} while (i >= 0);
144+
});
145+
}
146+
147+
private static int NextError(ReadOnlySpan<char> s, int start)
148+
{
149+
int i = start;
150+
while (true)
151+
{
152+
int k = s.Slice(i).IndexOfAnyInRange('\uD800', '\uDFFF');
153+
if (k < 0)
154+
{
155+
return -1;
156+
}
157+
i += k;
158+
if (char.IsHighSurrogate(s[i]) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1]))
159+
{
160+
i += 2;
161+
}
162+
else
163+
{
164+
return i;
165+
}
166+
}
167+
}
168+
169+
[Benchmark]
170+
[BenchmarkCategory("string")]
171+
public string StringRunes() => RunesToWellFormed(input);
172+
173+
[Benchmark]
174+
[BenchmarkCategory("string")]
175+
public string StringIndexOfAnyInRange() => IndexOfAnyToWellFormed(input);
176+
177+
// Encoding.Unicode replaces lone surrogates by U+FFFD when encoding.
178+
[Benchmark]
179+
[BenchmarkCategory("string")]
180+
public string StringEncodingRoundTrip() => Encoding.Unicode.GetString(Encoding.Unicode.GetBytes(input));
181+
182+
[Benchmark]
183+
[BenchmarkCategory("string")]
184+
public string StringSimdUnicode() => UTF16.ToWellFormed(input);
185+
186+
// Short strings (1 to 64 code units), one call per string.
187+
[Benchmark]
188+
[BenchmarkCategory("short")]
189+
public int ShortIndexOfAnyInRange()
190+
{
191+
int count = 0;
192+
foreach (string s in shortStrings)
193+
{
194+
count += IndexOfAnyToWellFormed(s).Length;
195+
}
196+
return count;
197+
}
198+
199+
[Benchmark]
200+
[BenchmarkCategory("short")]
201+
public int ShortSimdUnicode()
202+
{
203+
int count = 0;
204+
foreach (string s in shortStrings)
205+
{
206+
count += UTF16.ToWellFormed(s).Length;
207+
}
208+
return count;
209+
}
210+
211+
// Buffer to buffer: always writes the whole output.
212+
// A plain copy, as a reference for the best we can hope for.
213+
[Benchmark]
214+
[BenchmarkCategory("buffer")]
215+
public void BufferCopyOnly() => input.AsSpan().CopyTo(output);
216+
217+
[Benchmark]
218+
[BenchmarkCategory("buffer")]
219+
public unsafe void BufferScalar()
220+
{
221+
fixed (char* pIn = input)
222+
fixed (char* pOut = output)
223+
{
224+
UTF16.ToWellFormedScalar(pIn, input.Length, pOut);
225+
}
226+
}
227+
228+
[Benchmark]
229+
[BenchmarkCategory("buffer")]
230+
public void BufferCopyThenIndexOfAnyInRange()
231+
{
232+
input.AsSpan().CopyTo(output);
233+
Span<char> dst = output;
234+
int i = NextError(dst, 0);
235+
while (i >= 0)
236+
{
237+
dst[i] = '\uFFFD';
238+
i = NextError(dst, i + 1);
239+
}
240+
}
241+
242+
[Benchmark]
243+
[BenchmarkCategory("buffer")]
244+
public unsafe void BufferSimdUnicode()
245+
{
246+
fixed (char* pIn = input)
247+
fixed (char* pOut = output)
248+
{
249+
UTF16.ToWellFormed(pIn, input.Length, pOut);
250+
}
251+
}
252+
253+
// Validation only (isWellFormed).
254+
[Benchmark]
255+
[BenchmarkCategory("validate")]
256+
public bool ValidateIndexOfAnyInRange() => NextError(input, 0) < 0;
257+
258+
[Benchmark]
259+
[BenchmarkCategory("validate")]
260+
public bool ValidateSimdUnicode() => UTF16.IsWellFormed(input);
261+
}
262+
}

0 commit comments

Comments
 (0)