UTF-8 валидация — одна из базовых операций при работе с текстом, которая выполняется миллионы раз в секунду в современных приложениях. Стандартная реализация в Go, хоть и корректная, далека от оптимальной по производительности. В этой статье расскажу, как мне удалось ускорить валидацию UTF-8 в 10 раз, используя SIMD‑инструкции ARM NEON и алгоритм из статьи «Validating UTF-8 In Less Than One Instruction Per Byte» Джона Кейзера и Дэниела Лемира.
Проблема стандартной валидации
Стандартная реализация utf8.Valid()
в Go имеет несколько фундаментальных ограничений:
Множественные условные переходы
Последовательная обработка
Кэш-промахи lookup таблиц
Избыточные проверки границ
Измерения производительности stdlib показывают:
На тестовых данных 4 КБ - 256 МБ: ~1.2-1.3 ГБ/с
Производительность стабильна независимо от размера данных
Основное ограничение: последовательная обработка и branch misprediction
На больших объемах (256 МБ): небольшая деградация до ~1.25 ГБ/с
Эти результаты показывают, что стандартная реализация далека от теоретического предела пропускной способности памяти, оставляя значительное пространство для оптимизации
Алгоритм Lemire-Keiser
Концептуальная основа
Алгоритм Lemire-Keiser представляет радикально новый подход к валидации UTF-8, основанный на векторизованной классификации и принципе "проверки без ветвлений". Ключевая идея заключается в том, что практически все ошибки UTF-8 можно детектировать, анализируя только первые два байта каждой последовательности.
Статьи подробно расматривабшие алгоритм:
Математическая основа
Алгоритм базируется на теории конечных автоматов и битовых масках. Каждая комбинация двух соседних байтов может быть классифицирована в одну из 12 категорий:
ASCII (00000000...01111111)
Continuation Low (10|000000...001111)
Continuation (10|010000...001111)
Continuation High (10|100000...111111)
2-Byte Start (110|00010...11111)
3-Byte Start Low (1110|0000)
3-Byte Start (1110|0001...1100, 1110|1110...1111)
3-Byte Surrogate (1110|1101)
4-Byte Start Low (11110|000)
4-Byte Start (11110|001...011)
4-Byte Start High (11110|100)
Invalid (все остальные)
Lookup таблицы реализуют функцию классификации f: {0..255} → {0..255}, где результат кодирует битовую маску принадлежности к различным категориям ошибок.
Моя реализация на Go с ARM NEON
//go:build !purego
#include "textflag.h"
// func validateNEON(p []byte) bool
// Функция валидации UTF-8 строки с использованием ARM64 NEON SIMD инструкций
TEXT ·Valid(SB),NOSPLIT,$0-25
// Загружаем параметры функции из стека
MOVD s_base+0(FP), R10 // Указатель на начало строки в R10
MOVD s_len+8(FP), R11 // Длина строки в R11
CBZ R11, valid // Если длина = 0, строка валидна
CMP $16, R11
BLT small // Если длина < 16 байт, обрабатываем отдельно
// Инициализация маски для проверки ASCII символов (бит 7 = 1 означает не-ASCII)
VMOVQ $0x8080808080808080, $0x8080808080808080, V0
ascii_loop:
// Быстрая проверка на ASCII символы (оптимизация для чисто ASCII строк)
CMP $16, R11
BLT small // Если осталось < 16 байт, переходим к обработке остатка
VLD1 (R10), [V1.B16] // Загружаем 16 байт в SIMD регистр V1
VCMTST V1.B16, V0.B16, V2.B16 // Тестируем биты 0x80 (проверка на не-ASCII)
VMOV V2.D[0], R2 // Перемещаем результат в скалярные регистры
VMOV V2.D[1], R3
ORR R2, R3, R2 // Объединяем результаты
CBNZ R2, stop_ascii // Если найден не-ASCII символ, прекращаем ASCII цикл
ADD $16, R10 // Переходим к следующему блоку
SUB $16, R11 // Уменьшаем счетчик оставшихся байт
B ascii_loop // Продолжаем ASCII цикл
stop_ascii:
// Инициализация констант для алгоритма Мулы (Lemire) валидации UTF-8
// Эти константы используются в lookup таблицах для быстрой валидации UTF-8
VMOVQ $0x0202020202020202, $0x4915012180808080, V11 // Lookup таблица 1
VMOVQ $0xcbcbcb8b8383a3e7, $0xcbcbdbcbcbcbcbcb, V13 // Lookup таблица 2
VMOVQ $0x0101010101010101, $0x01010101babaaee6, V15 // Lookup таблица 3
VMOVQ $0x0F0F0F0F0F0F0F0F, $0x0F0F0F0F0F0F0F0F, V18 // Маска для младших 4 бит
VMOVQ $0x0707070707070707, $0x0707070707070707, V12 // Маска 0x07
VMOVQ $0xFFFFFFFFFFFFFFFF, $0xFFFFFFFFFFFFFFFF, V14 // Маска всех единиц
VMOVQ $0x7F7F7F7F7F7F7F7F, $0x7F7F7F7F7F7F7F7F, V16 // Маска 0x7F
VMOVQ $0xDFDFDFDFDFDFDFDF, $0xDFDFDFDFDFDFDFDF, V17 // Маска 0xDF
VMOVQ $0x0808080808080808, $0x0808080808080808, V19 // Маска 0x08
VMOVQ $0x8080808080808080, $0x8080808080808080, V20 // Маска 0x80
VMOVQ $0x0000000000000000, $0x0000000000000000, V30 // Нулевой вектор
VMOVQ $0x0000000000000000, $0x0000000000000000, V3 // Предыдущий блок данных
aligned_loop:
// Основной цикл валидации UTF-8 с использованием алгоритма Мулы
VLD1.P 16(R10), [V4.B16] // Загружаем 16 байт и увеличиваем указатель
// Сдвигаем данные для анализа переходов между байтами
VEXT $15, V4.B16, V3.B16, V5.B16 // Берем последний байт предыдущего блока + текущий
VUSHR $4, V5.B16, V6.B16 // Сдвигаем на 4 бита вправо (старшие 4 бита)
VTBL V6.B16, [V11.B16], V6.B16 // Lookup в первой таблице
VAND V5.B16, V18.B16, V7.B16 // Выделяем младшие 4 бита
VTBL V7.B16, [V13.B16], V7.B16 // Lookup во второй таблице
VUSHR $4, V4.B16, V8.B16 // Старшие 4 бита текущего блока
VTBL V8.B16, [V15.B16], V8.B16 // Lookup в третьей таблице
// Комбинируем результаты lookup'ов
VAND V6.B16, V7.B16, V9.B16
VAND V9.B16, V8.B16, V10.B16
// Дополнительные проверки для специальных случаев UTF-8
VEXT $14, V4.B16, V3.B16, V5.B16 // Проверка на позиции -2
VUSHR $5, V5.B16, V6.B16 // Сдвиг на 5 бит для проверки старших битов
VCMEQ V12.B16, V6.B16, V6.B16 // Сравнение с 0x07
VEXT $13, V4.B16, V3.B16, V5.B16 // Проверка на позиции -3
VUSHR $4, V5.B16, V9.B16 // Сдвиг на 4 бита
VCMEQ V18.B16, V9.B16, V9.B16 // Сравнение с 0x0F
VORR V6.B16, V9.B16, V9.B16 // Объединение результатов
// Финальная проверка валидности
VAND V9.B16, V20.B16, V9.B16 // Применяем маску 0x80
VSUB V9.B16, V10.B16, V9.B16 // Вычитаем из основного результата
VMOV V9.D[0], R1 // Перемещаем результат в скалярные регистры
VMOV V9.D[1], R2
ORR R1, R2, R1 // Объединяем половины результата
CBNZ R1, no_valid // Если результат не ноль, строка невалидна
VMOV V4.B16, V3.B16 // Сохраняем текущий блок как предыдущий
SUB $16, R11, R11 // Уменьшаем счетчик оставшихся байт
CMP $16, R11
BGE aligned_loop // Если осталось >= 16 байт, продолжаем цикл
B small_no_const // Переходим к обработке остатка
small:
// Обработка небольших строк (< 16 байт)
CBZ R11, valid // Если байт не осталось, строка валидна
tail_loop:
// Простая проверка по одному байту для маленьких строк
MOVBU (R10), R2 // Загружаем один байт
AND $0x80, R2 // Проверяем старший бит
CBNZ R2, check_utf8 // Если установлен, нужна полная проверка UTF-8
ADD $1, R10 // Переходим к следующему байту
SUB $1, R11 // Уменьшаем счетчик
CBNZ R11, tail_loop // Продолжаем пока есть байты
B valid // Все байты ASCII - строка валидна
check_utf8:
// Инициализация констант для полной проверки UTF-8
// (те же константы, что и выше)
VMOVQ $0x0202020202020202, $0x4915012180808080, V11
VMOVQ $0xcbcbcb8b8383a3e7, $0xcbcbdbcbcbcbcbcb, V13
VMOVQ $0x0101010101010101, $0x01010101babaaee6, V15
VMOVQ $0x0F0F0F0F0F0F0F0F, $0x0F0F0F0F0F0F0F0F, V18
VMOVQ $0x0707070707070707, $0x0707070707070707, V12
VMOVQ $0xFFFFFFFFFFFFFFFF, $0xFFFFFFFFFFFFFFFF, V14
VMOVQ $0x7F7F7F7F7F7F7F7F, $0x7F7F7F7F7F7F7F7F, V16
VMOVQ $0xDFDFDFDFDFDFDFDF, $0xDFDFDFDFDFDFDFDF, V17
VMOVQ $0x0808080808080808, $0x0808080808080808, V19
VMOVQ $0x8080808080808080, $0x8080808080808080, V20
VMOVQ $0x0000000000000000, $0x0000000000000000, V30
VMOVQ $0x0000000000000000, $0x0000000000000000, V3
small_no_const:
// Подготовка данных для обработки остатка < 16 байт
SUB $16, R10, R10 // Откатываемся на 16 байт назад
ADD R11, R10, R10 // Добавляем количество оставшихся байт
VLD1.P 16(R10), [V4.B16] // Загружаем 16 байт (включая "мусор")
// Использование таблицы переходов для маскирования лишних байт
ADR shift_table, R2 // Адрес таблицы переходов
MOVW R11, R3 // Количество валидных байт
LSL $2, R3 // Умножаем на 4 (размер инструкции)
ADD R3, R2 // Вычисляем адрес перехода
B (R2) // Переходим к соответствующему обработчику
shift_table:
// Таблица переходов для обработки 0-15 байт
B do_shift_0 // 0 байт - заполняем ASCII символами
B do_shift_1 // 1 байт валидный
B do_shift_2 // 2 байта валидных
B do_shift_3 // 3 байта валидных
B do_shift_4 // 4 байта валидных
B do_shift_5 // 5 байт валидных
B do_shift_6 // 6 байт валидных
B do_shift_7 // 7 байт валидных
B do_shift_8 // 8 байт валидных
B do_shift_9 // 9 байт валидных
B do_shift_10 // 10 байт валидных
B do_shift_11 // 11 байт валидных
B do_shift_12 // 12 байт валидных
B do_shift_13 // 13 байт валидных
B do_shift_14 // 14 байт валидных
B do_shift_15 // 15 байт валидных
do_shift_0:
// 0 валидных байт - заполняем вектор ASCII символами 'a' (0x61)
VMOVQ $0x6161616161616161, $0x6161616161616161, V4
B end_swith
do_shift_1:
// 1 валидный байт - сдвигаем на 15 позиций (маскируем 15 байт)
VEXT $15, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_2:
// 2 валидных байта - сдвигаем на 14 позиций
VEXT $14, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_3:
// 3 валидных байта - сдвигаем на 13 позиций
VEXT $13, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_4:
// 4 валидных байта - сдвигаем на 12 позиций
VEXT $12, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_5:
// 5 валидных байт - сдвигаем на 11 позиций
VEXT $11, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_6:
// 6 валидных байт - сдвигаем на 10 позиций
VEXT $10, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_7:
// 7 валидных байт - сдвигаем на 9 позиций
VEXT $9, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_8:
// 8 валидных байт - сдвигаем на 8 позиций
VEXT $8, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_9:
// 9 валидных байт - сдвигаем на 7 позиций
VEXT $7, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_10:
// 10 валидных байт - сдвигаем на 6 позиций
VEXT $6, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_11:
// 11 валидных байт - сдвигаем на 5 позиций
VEXT $5, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_12:
// 12 валидных байт - сдвигаем на 4 позиции
VEXT $4, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_13:
// 13 валидных байт - сдвигаем на 3 позиции
VEXT $3, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_14:
// 14 валидных байт - сдвигаем на 2 позиции
VEXT $2, V30.B16, V4.B16, V4.B16
B end_swith
do_shift_15:
// 15 валидных байт - сдвигаем на 1 позицию
VEXT $1, V30.B16, V4.B16, V4.B16
B end_swith
end_swith:
// Выполняем ту же валидацию UTF-8, что и в основном цикле
VEXT $15, V4.B16, V3.B16, V5.B16 // Анализ переходов между байтами
VUSHR $4, V5.B16, V6.B16 // Старшие 4 бита
VTBL V6.B16, [V11.B16], V6.B16 // Lookup таблица 1
VAND V5.B16, V18.B16, V7.B16 // Младшие 4 бита
VTBL V7.B16, [V13.B16], V7.B16 // Lookup таблица 2
VUSHR $4, V4.B16, V8.B16 // Старшие 4 бита текущих байт
VTBL V8.B16, [V15.B16], V8.B16 // Lookup таблица 3
VAND V6.B16, V7.B16, V9.B16 // Комбинирование результатов
VAND V9.B16, V8.B16, V10.B16
// Дополнительные проверки
VEXT $14, V4.B16, V3.B16, V5.B16 // Проверка позиции -2
VUSHR $5, V5.B16, V6.B16
VCMEQ V12.B16, V6.B16, V6.B16
VEXT $13, V4.B16, V3.B16, V5.B16 // Проверка позиции -3
VUSHR $4, V5.B16, V9.B16
VCMEQ V18.B16, V9.B16, V9.B16
VORR V6.B16, V9.B16, V9.B16
// Финальная валидация
VAND V9.B16, V20.B16, V9.B16
VSUB V9.B16, V10.B16, V9.B16
VMOV V9.D[0], R1 // Получаем результат
VMOV V9.D[1], R2
ORR R1, R2, R1
CBNZ R1, no_valid // Если не ноль, строка невалидна
valid:
// Строка валидна - возвращаем true (1)
MOVD $1, R0
MOVD R0, ret+24(FP)
RET
no_valid:
// Строка невалидна - возвращаем false (0)
MOVD $0, R0
MOVD R0, ret+24(FP)
RET
Результаты бенчмарков
Тестовая платформа
Процессор: Apple M1 Pro (ARM64)
Операционная система: macOS (darwin)
Go версия: 1.24.4
Сравниваемые реализации:
Stdlib — стандартная
utf8.Valid()
из Gocharcoal — оптимизированная библиотека без SIMD
SIMD — моя реализация с ARM NEON
Малые строки (10 байт)
Японский текст (UTF-8):
Stdlib |
27.78 ns/op |
1079.80 MB/s |
charcoal |
14.88 ns/op |
2036.79 MB/s |
SIMD |
5.922 ns/op |
5065.75 MB/s (4.7x быстрее stdlib) |
Средние файлы (1 КБ)
Stdlib |
893.5 ns/op |
1146.01 MB/s |
charcoal |
421.7 ns/op |
2428.44 MB/s |
SIMD |
106.2 ns/op |
9641.60 MB/s (8.4x быстрее stdlib) |
Большие файлы (1 МБ)
Stdlib |
916612 ns/op |
1143.97 MB/s |
charcoal |
416901 ns/op |
2515.17 MB/s |
SIMD |
102415 ns/op |
10238.46 MB/s (9.0x быстрее stdlib) |
Детальное сравнение по размерам данных
Размер данных |
Stdlib (MB/s) |
charcoal (MB/s) |
SIMD (MB/s) |
Ускорение SIMD/Stdlib |
4 КБ |
1280.09 |
1645.04 |
10030.39 |
7.8x |
32 КБ |
1283.78 |
1658.71 |
10239.46 |
8.0x |
64 КБ |
1282.96 |
1660.01 |
10260.09 |
8.0x |
256 КБ |
1253.47 |
1646.61 |
10268.56 |
8.2x |
4 МБ |
1218.62 |
1609.69 |
10262.59 |
8.4x |
32 МБ |
1248.65 |
1648.06 |
10233.27 |
8.2x |
128 МБ |
1244.03 |
1624.76 |
10220.27 |
8.2x |
256 МБ |
1250.01 |
1644.33 |
9319.34 |
7.5x |
абсолютная производительность: >10 ГБ/с приближается к пределам пропускной способности памяти DDR4
Практическое применение
Библиотека особенно эффективна для:
JSON-парсеров: валидация больших JSON-документов
Баз данных: валидация при вставке текстовых данных
Rust simdutf8::basic::from_utf8
Стоит отметить, что библиотека simdutf8
для Rust, показывает аналогичные результаты производительности на ARM64 платформах:
На больших данных (>1 МБ)**: ~10 ГБ/с
Перспективы развития:
- Портирование на x86-64 с использованием AVX2/AVX-512
Исходный код доступен на GitHub: https://github.com/AndreyyTs/utf8simd