Починка UTF-16 строк в C# со скоростью копирования памяти: Лемир перенёс алгоритм из Chrome в .NET
Строки в C# хранятся в UTF-16. Обычный символ занимает одну 16-битную единицу, а всё, что лежит за пределами базовой многоязычной плоскости Unicode, включая эмодзи, кодируется парой: сначала старший суррогат (от U+D800 до U+DBFF), за ним младший (от U+DC00 до U+DFFF). Если у суррогата неправильный сосед или соседа нет совсем, строка считается некорректной (ill-formed).
Получить такую строку проще, чем кажется: достаточно обрезать текст посередине эмодзи или склеить куски из разных источников. Отправлять её на диск или в сеть нельзя, об этом напоминает Даниэль Лемир, профессор TÉLUQ и один из авторов simdjson и simdutf.
В JavaScript для этого давно есть две быстрые функции. String.prototype.toWellFormed() заменяет каждый одиночный суррогат на символ U+FFFD, а isWellFormed() сообщает, нужна ли замена вообще. В стандартной библиотеке C# прямого аналога нет, поэтому Лемир добавил обе функции в свою библиотеку SimdUnicode в pull request 54. Алгоритм тот же, что команда Лемира ранее передала в JavaScript-движок V8, так что Chrome уже чинит строки именно этим способом.
Пользоваться просто:
string s = UTF16.ToWellFormed(input); // вернёт тот же экземпляр, если строка уже корректнаbool ok = UTF16.IsWellFormed(span);
Если на входе корректная строка, ToWellFormed возвращает её как есть и ничего не аллоцирует.
Привычный способ починки в C# выглядит так: скопировать данные в буфер, через IndexOfAnyInRange найти ближайший символ из диапазона суррогатов, проверить, есть ли у него пара, и при необходимости заменить на U+FFFD. Потом искать дальше. На обычном тексте это работает сносно, а на тексте с эмодзи поиск останавливается на каждой паре и перезапускается заново.
SimdUnicode опирается на SIMD, инструкции процессора, которые сравнивают сразу много значений за одну операцию. У свежих x64 процессоров AMD и Intel векторные возможности сейчас шире, чем у ARM-чипов, хотя мощные инструкции есть у обеих архитектур.
Теперь цифры. На Intel Xeon Gold 6548N (Emerald Rapids) с AVX-512 проверка латинского текста идёт на 69 ГБ/с против 33 ГБ/с у IndexOfAnyInRange. На китайском тексте разрыв почти трёхкратный: 115 против 39 ГБ/с. На twitter.json 69 против 31 ГБ/с. Самый показательный случай с эмодзи: вход корректный и целиком состоит из суррогатных пар. Стандартный поиск проседает до 0,4 ГБ/с, а проверка SimdUnicode держит 53 ГБ/с, это больше чем в сто раз быстрее.
С починкой картина похожая. Буферная версия ToWellFormed записывает каждую единицу: либо копию входа, либо U+FFFD. Для корректной строки это по сути копирование памяти, поэтому Лемир сравнивает её с простым копированием. На латинице копирование даёт 46 ГБ/с, SimdUnicode 43 ГБ/с, связка из копирования и IndexOfAnyInRange 20 ГБ/с. На эмодзи расклад 44, 40 и 0,4 ГБ/с. Получается, что починка строки стоит почти столько же, сколько её копия.
На Apple M4 Max результаты похожие, хотя отрыв от стандартного подхода скромнее, чем на Intel. Замеры делались на .NET SDK 10.0.400 под Linux и 10.0.103 под macOS.
Метод подробно разобран в статье R. Clausecker и D. Lemire «Fixing ill-formed UTF-16 strings with SIMD instructions» в журнале Software: Practice and Experience (2026), препринт доступен на arXiv. Исходный код лежит в репозитории SimdUnicode.