Мгновенное открытие больших изображений
Первое, что замечаешь в просмотрщике изображений, — задержка первого кадра: те десятки и сотни миллисекунд между двойным щелчком и появлением картинки. Почти всё это время уходит на «декодирование» — превращение сжатых байтов на диске обратно в пиксели, которые может нарисовать экран.
GuoheView не ограничивается тем, чтобы подключить одну готовую библиотеку декодирования. Для каждого формата выбирается самый быстрый путь, а для самых распространённых — JPEG и TIFF — сделаны собственные оптимизации параллельной обработки. Эта статья — только о декодировании.
Как изображение превращается в пиксели
Возьмём JPEG. Декодирование проходит примерно в три этапа, как на конвейере:
Последние два этапа (IDCT и преобразование цвета) — независимые вычисления над блоками пикселей, они естественно ложатся на параллельные SIMD-инструкции. Сложность — в первом этапе, энтропийном декодировании: это битовый поток переменной длины, где позиция следующего значения зависит от длины предыдущего, и кажется, что читать его можно только бит за битом по порядку. Как мы увидим, именно здесь декодеры и расходятся.
JPEG: три пути декодирования
Встроенный в Windows WIC
WIC (Windows Imaging Component) — встроенный в Windows фреймворк кодеков изображений в виде COM-компонентов. Его плюсы — никаких зависимостей и поддержка почти всего (JPEG / PNG / GIF / BMP / TIFF…): один вызов системного API — и пиксели у вас.
Цена — «универсальность»: ему приходится учитывать все форматы и все сценарии, поэтому довести до предела работу с каким-то одним форматом он не может и сам по себе не загружает все ядра процессора. Поэтому в GuoheView WIC — только запасной вариант: ему достаются форматы без собственного быстрого пути (BMP, GIF, ICO и т. п.), где он прост и надёжен.
libjpeg-turbo: ускорение за счёт SIMD
libjpeg-turbo — отраслевой стандарт среди библиотек JPEG. Её главное улучшение по сравнению с исходной libjpeg — переписанные на SIMD-инструкциях (SSE2 / AVX2) попиксельные горячие точки: IDCT, повышающая дискретизация, преобразование цвета. Одна инструкция обрабатывает сразу 8 или 16 значений, а не по одному.
Но SIMD в libjpeg-turbo ускоряет только два последних этапа конвейера. Энтропийное декодирование остаётся однопоточным и последовательным — если только в файле JPEG нет маркеров RST (restart), делящих поток на независимые участки. Беда в том, что у подавляющего большинства JPEG (с телефонов, камер, скриншоты) маркеров RST нет, и энтропийное декодирование становится узким местом, которое многоядерный процессор не может разделить.
Собственный pjdec: распараллеливаем и последовательное энтропийное декодирование
Это ядро движка декодирования GuoheView. Мы хотели ответить на один вопрос: может ли энтропийное декодирование обычного JPEG без маркеров RST загрузить все ядра?
Интуитивно — нет: поток переменной длины, а яркость каждого блока данных (DC-коэффициент) хранится как разница с предыдущим блоком, всё связано в цепочку. Но мы применили «спекулятивный параллелизм»:
Метод опирается на замечательное свойство кодов Хаффмана — самосинхронизацию: даже если начать декодирование с неверной позиции, через несколько кодовых слов декодер сам «выравнивается» по настоящим границам кодовых слов. Поэтому каждое ядро вслепую начинает декодировать с границы байта в начале своего участка: сначала получается результат, «позиция которого может быть неверной», и попутно запоминается, где начинается каждый блок данных (контрольные точки).
Затем выполняется лёгкая последовательная проверка: если настоящая позиция конца предыдущего участка в точности совпадает с одной из контрольных точек следующего, то из детерминированности кодирования следует, что дальше оба результата совпадают бит в бит, — и результат следующего участка берётся как есть. Что касается связанных в цепочку разниц DC, при слепом декодировании отсчёт идёт от 0, и от истинного значения он отличается лишь на константу; после синхронизации достаточно добавить одну поправку ко всему участку (за счёт переполнения 16-битных целых — бесплатно).
Если какой-то участок так и не сходится (это очень редко), этот небольшой фрагмент просто декодируется заново по порядку — корректность всегда гарантирована, просто параллелизма чуть меньше. Итог: даже у обычного большого JPEG энтропийное декодирование загружает все ядра, и первый кадр появляется заметно быстрее.
TIFF: два пути декодирования
libtiff
libtiff — фактический стандарт для работы с TIFF с самой полной поддержкой формата (все виды сжатия, CMYK, многостраничность). GuoheView использует её как основу ради совместимости: любой TIFF, который читает libtiff, откроем и мы. Её ограничение то же: блоки данных (strip / tile) обрабатываются по одному в одном потоке.
Собственный tiff_mt: параллельно по блокам
Структура TIFF на самом деле создана для параллелизма: изображение разбито на множество независимых полос (strip) или тайлов (tile). Наш декодер tiff_mt добавляет поверх этого многопоточность:
Он идёт на два шага дальше, чем «просто запустить побольше потоков»:
- Адаптивное число потоков: ядра не задействуются бездумно все сразу. Несжатый TIFF упирается в пропускную способность памяти, и лишние потоки лишь дерутся за неё; если работы слишком мало, потоки не стоит и запускать, — число определяется динамически по количеству ядер, способу сжатия и объёму данных.
- Два вида параллелизма вместе: если TIFF сжат JPEG, но в нём лишь несколько больших блоков, простой «параллелизм по блокам» оставит большинство ядер без дела. Тогда tiff_mt переключается на параллелизм внутри потока — напрямую использует описанный выше спекулятивный параллелизм pjdec, чтобы разобрать этот большой JPEG-поток. Здесь два наших декодера и соединяются.
Наша стратегия
Внутри GuoheView есть реестр декодеров: каждый декодер объявляет, какие признаки формата он распознаёт (сигнатуры заголовка файла), и свой приоритет. При открытии файла движок точно сопоставляет заголовок и выбирает специализированный декодер с наивысшим приоритетом: JPEG идёт в pjdec, TIFF — в tiff_mt, и только если ничего не подошло, в дело вступает запасной WIC.