Ускоряем валидацию UTF-8 в 10 раз (>10 ГБ/с): реализация алгоритма Lemire-Keiser на Go с ARM NEON

от автора

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 категорий:

  1. ASCII (00000000…01111111)

  2. Continuation Low (10|000000…001111)

  3. Continuation (10|010000…001111)

  4. Continuation High (10|100000…111111)

  5. 2-Byte Start (110|00010…11111)

  6. 3-Byte Start Low (1110|0000)

  7. 3-Byte Start (1110|0001…1100, 1110|1110…1111)

  8. 3-Byte Surrogate (1110|1101)

  9. 4-Byte Start Low (11110|000)

  10. 4-Byte Start (11110|001…011)

  11. 4-Byte Start High (11110|100)

  12. 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() из Go

  • charcoal — оптимизированная библиотека без 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


ссылка на оригинал статьи https://habr.com/ru/articles/923242/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *