Как случайно написать самый быстрый CSV-парсер на C#
Байтовые оптимизации, SIMD-интринсики и zero-copy parsing привели к 4,8 ГБ/сек на одном ядре. Обошёл xsv, csvkit и даже CsvHelper с Span<T>.
, отредактировано
На рождественских каникулах 2025 года автор библиотеки FourLambda.Csv ехал в автобусе и читал про UTF-8. Обнаружив, что все ASCII-символы в UTF-8 занимают ровно один байт, он начал экспериментировать с подсчётом символов на максимальной скорости. Через несколько дней у него был готовый CSV-парсер на C#, который обогнал все существующие библиотеки в большинстве бенчмарков. Вот как это произошло.
Ключевые выводы
- SIMD-инструкции (SSE2, AVX2) позволяют обрабатывать 16-32 байта за одну операцию
- В UTF-8 все управляющие символы CSV (запятая, кавычка, перенос строки) занимают ровно один байт и совпадают с ASCII
- Минималистичный код (16 КБ) побеждает оптимизированный, но раздутый (163 КБ) за счёт давления на L1-кэш процессора
- Компилятор и рантайм умеют оптимизировать лучше, если им не мешать микрооптимизациями
Наивная реализация — считаем запятые
Прежде чем строить парсер, поставим простую задачу: как быстрее всего подсчитать, сколько раз символ , встречается в большом UTF-8-файле? Тестируем на CSV-файле размером 500 МБ.
Базовая реализация: 135,305 мс
Простейший вариант: перебираем каждый байт и сравниваем с искомым. Можно ли лучше?
Unsafe-указатели: 128,683 мс
Каждый раз, когда вы обращаетесь к массиву через data[i], рантайм .NET выполняет проверку границ (bounds checking) — убеждается, что индекс не выходит за пределы массива. Если вместо этого использовать unsafe-указатели, мы обещаем рантайму, что не выйдем за границы. Экономия — 7 мс.
Развёртка цикла (unrolling 4x): 105,320 мс
Механика цикла for обманчиво дорога: на каждой итерации нужно проверить условие выхода и выполнить переход в начало. Если обрабатывать по 4 элемента за итерацию, накладные расходы снижаются в четыре раза. «Хвост» данных, не кратный четырём, обрабатывается обычным побайтовым циклом.
Проблема — UTF-8 и Unicode ломают всё
Чтобы понять, почему мы можем сканировать текст настолько быстро, нужно разобраться, как текст хранится в памяти компьютера.
Наименьшая единица данных — байт (числа 0-255). Но человечество использует гораздо больше 256 символов. Ранние системы решали это через кодовые страницы — у каждого региона была своя таблица. Японские системы использовали Shift-JIS, в Китае по закону обязателен GB 18030. Если открыть файл в неправильной кодировке, вы увидите «крокозябры» (mojibake) — бессмысленный набор символов.
Unicode решил эту проблему в 90-х: каждому символу присваивается номер (codepoint), а разные кодировки (UTF-8, UTF-16, UTF-32) определяют, как эти номера записываются в байты.
Вот как UTF-8 кодирует символы в зависимости от количества байтов:
Ключевой факт: байты 0x00-0x7F в UTF-8 полностью совпадают с ASCII. Это значит, что управляющие символы CSV — запятая (,), кавычка ("), перевод строки (\n) — всегда занимают ровно один байт, независимо от того, какие Unicode-символы находятся в данных. И это можно использовать для невероятно быстрого сканирования.
SIMD — ускорение через параллельные инструкции
До сих пор мы обрабатывали по одному байту за раз. Представьте сложение двух массивов по 8 элементов — нужно 8 отдельных операций сложения. Можно распараллелить это по потокам, но управление потоками создаёт накладные расходы и не добавляет эффективности.
SIMD (Single Instruction, Multiple Data) — это набор процессорных инструкций, которые выполняют одну операцию сразу над несколькими элементами данных. Вся магия происходит в железе, поэтому результат приходит за то же время, что и обычная операция над одним элементом.
Аналогия: сложить два однозначных числа (8 + 2) — просто. Сложить два семизначных — нужно перебирать разряды. А теперь представьте, что вы можете «увидеть» сумму двух семизначных чисел мгновенно, с той же лёгкостью, что и однозначных. Это и есть SIMD.
Вот как выглядит SIMD-сложение 4 элементов за одну операцию:
Мы делаем в 4 раза больше работы за то же усилие.
SSE2: 46,663 мс
SSE2 (появился в x86-процессорах в 2000 году) работает с 128-битными регистрами — 16 байтов за раз. Алгоритм такой:
- Загрузить 16 байтов данных в SIMD-вектор
- Сравнить каждый байт с искомым (
Sse2.CompareEqual) — на позициях совпадений появятся байты 255, на остальных — 0 - Извлечь старшие биты из каждого байта (
Sse2.MoveMask) — получаем 16-битное число, где каждый установленный бит означает совпадение - Подсчитать установленные биты
Для строки "Hello, I, like, commas." это выглядит так:
AVX2: 42,220 мс
AVX2 (с 2011 года) расширяет регистры до 256 битов — 32 байта за итерацию. Код аналогичен SSE2, но с Vector256 вместо Vector128.
Прирост скромный — значит, узкое место сместилось на цикл обработки маски. Нужно ускорить подсчёт битов.
AVX2 + TZCNT: 16,630 мс
BitOperations.TrailingZeroCount — аппаратная инструкция TZCNT, которая мгновенно находит позицию младшего установленного бита. Вместо того чтобы перебирать биты маски по одному, мы прыгаем сразу к следующему совпадению и сбрасываем его.
AVX2 + POPCNT: 13,673 мс
А есть ещё лучше. BitOperations.PopCount — аппаратная инструкция POPCNT, которая за одну операцию считает количество установленных битов в числе. Именно то, что нам нужно:
Мы достигли потолка производительности для подсчёта символов. Ускорение в 10 раз по сравнению с наивной реализацией — отличный результат. Пора применить это к парсингу CSV.
Поиск разделителей через SIMD
При парсинге CSV нас интересуют три управляющих символа: запятая (,), кавычка (") и перевод строки (\n). Правила обработки:
- Запятая — конец текущего поля
- Перевод строки — конец текущей строки
- Кавычка — начало или конец экранированного блока (внутри которого запятые и переносы игнорируются). Две кавычки подряд — это экранированная кавычка в значении
Парсинг разбивается на два этапа. Первый — определение позиций и длин полей через SIMD (функция DetermineFields). Второй — фактическое извлечение и дeкодирование значений, только когда пользователь их запрашивает.
Вот упрощённый код первого этапа (без обработки краевых случаев):
Несколько важных деталей:
- Здесь используется
TrailingZeroCountвместоPopCount, потому что нам важна позиция каждого найденного символа, а не только их количество - Одна и та же схема
CompareEqual+MoveMaskприменяется для каждого управляющего символа, а затем маски объединяются через OR. Отдельные маски сохраняются, чтобы потом определить тип найденного символа - Мы не касаемся содержимого полей — если пользователю нужен только один столбец, незачем декодировать остальные
Обработка кавычек и экранирования
Когда поле содержит кавычки, нужно выполнить декодирование вручную — убрать обрамляющие кавычки и заменить двойные кавычки на одинарные. Кроме того, C# внутри использует UTF-16 для всех строк (char — это 16-битная единица кода), поэтому при чтении UTF-8 приходится конвертировать.
Если кавычек в поле нет, используется быстрая встроенная конвертация Encoding.UTF8.GetChars. Но если кавычки есть, быстрее выполнить конвертацию и удаление кавычек за один проход:
Хитрость здесь в том, что 2- и 3-байтовые UTF-8-последовательности всегда дают одну единицу кода UTF-16, поэтому кодпоинт можно просто привести к char. Четырёхбайтовые символы (эмодзи и редкие иероглифы) требуют суррогатных пар.
Для UTF-16-строк (внутренний формат C#) парсер использует трюк с Avx2.PackUnsignedSaturate: два 256-битных вектора 16-битных символов «сжимаются» в один 256-битный вектор 8-битных байтов. ASCII-символы сохраняются как есть, а все не-ASCII насыщаются до 255 (или 0 для отрицательных при знаковой интерпретации). После перестановки блоков через Avx2.Permute4x64 можно применять ту же маску, что и для UTF-8.
Бенчмарки — результаты
Существующие бенчмарки CSV-парсеров на C# тестируют только один файл — по сути, они измеряют не парсинг CSV, а производительность на одном конкретном сценарии. Поэтому автор создал собственный набор тестов с разными типами данных:
- Реальные данные Reddit — широкий датасет с разными типами данных и большим количеством текста (из Reddit ArcticShift)
- Международный текст — CSV с названиями предметов из Pokemon на множестве языков (кириллица, японский, корейский)
- Финансовые данные — числа с минимумом текста
- Синтетические тесты — числовые поля, короткие файлы (50 строк), файлы с обязательным экранированием, «стресс-тест» с массой кавычек
Полные результаты опубликованы на bepis.io/csv-benchmark.
Итог: FourLambda.Csv оказался самым быстрым в 41 из 70 тестов. Не абсолютная победа, но убедительное лидерство.
Для сравнения, вот как менялась скорость подсчёта символов на файле 500 МБ по мере оптимизации:
Почему FourLambda.Csv быстрее Sep
До этого самым быстрым CSV-парсером на C# считалась библиотека Sep. Вот несколько причин, почему FourLambda.Csv выигрывает:
UTF-8 без промежуточной конвертации. Sep внутри конвертирует UTF-8 в UTF-16 даже если данные не нужны пользователю. FourLambda.Csv работает напрямую с UTF-8.
Давление на кэш. L1-кэш процессора — около 32-64 КБ, и он хранит не только данные, но и исполняемый код. FourLambda.Csv весит ~16 КБ, а Sep — ~163 КБ. Разница в 10 раз означает, что Sep может вытеснять данные из L1-кэша, вызывая медленные обращения к памяти.
Переоптимизация. Sep тщательно контролирует, как данные перемещаются между регистрами. Теоретически это быстрее, но на практике может мешать оптимизациям рантайма. Компилятор и процессор умеют оптимизировать код, если им не указывают точный способ выполнения. Чрезмерная микрооптимизация «связывает руки» и мешает автоматическим улучшениям в новых версиях .NET.
Зависимость от оборудования. Результаты могут отличаться на разных машинах. Например, Sse.Prefetch0 (предзагрузка данных в L1) давала +10-20% на ноутбуке с DDR3, но почти нулевой эффект на десктопе с DDR5. На системах с AVX-512 и ARM у Sep есть аппаратные оптимизации, которых пока нет в FourLambda.Csv.
Частые вопросы
Можно ли использовать SIMD в обычном C#-проекте?
Да. Начиная с .NET Core 3.0, SIMD-интринсики (System.Runtime.Intrinsics) доступны из обычного управляемого кода на C#. Не нужны ни C++, ни P/Invoke. Достаточно проверять поддержку через Sse2.IsSupported или Avx2.IsSupported и иметь запасной путь для процессоров без этих инструкций.
А что с ARM и Apple Silicon?
FourLambda.Csv пока оптимизирован под x86 (SSE2/AVX2). На ARM работает generic fallback без SIMD. Sep выигрывает на ARM благодаря NEON-оптимизациям. Поддержка ARM — в планах, но без доступа к железу для тестирования.
Зачем писать свой парсер, если есть CsvHelper?
CsvHelper — отличная библиотека общего назначения, но она не заточена под максимальную производительность. Если вы обрабатываете гигабайты CSV-данных (аналитика, ETL, дата-пайплайны), разница в 5-10 раз ощутима. Для парсинга небольших файлов и файлов со сложной логикой маппинга CsvHelper по-прежнему отличный выбор.
Выводы
История этого проекта — хороший пример того, как глубокое понимание низкоуровневых деталей (кодировки, устройство кэша, SIMD-инструкции) может привести к результатам, которые превосходят годы целенаправленных оптимизаций в существующих библиотеках.
Главные уроки:
- SIMD — не магия для избранных. В C# интринсики доступны из коробки и дают кратный прирост на задачах поиска и сравнения
- UTF-8-совместимость ASCII — мощное свойство, которое можно эксплуатировать для быстрого парсинга
- Минимализм побеждает сложность: меньше кода — меньше давления на кэш — быстрее выполнение
- Не мешайте компилятору: иногда лучшая оптимизация — не оптимизировать вручную
Библиотека доступна на GitHub: bbepis/FourLambda.Csv.
Перевод и адаптация статьи How I accidentally made the fastest C# CSV parser с разрешения автора.