Мгновенное открытие больших изображений

Первое, что замечаешь в просмотрщике изображений, — задержка первого кадра: те десятки и сотни миллисекунд между двойным щелчком и появлением картинки. Почти всё это время уходит на «декодирование» — превращение сжатых байтов на диске обратно в пиксели, которые может нарисовать экран.

GuoheView не ограничивается тем, чтобы подключить одну готовую библиотеку декодирования. Для каждого формата выбирается самый быстрый путь, а для самых распространённых — JPEG и TIFF — сделаны собственные оптимизации параллельной обработки. Эта статья — только о декодировании.

Как изображение превращается в пиксели

Возьмём JPEG. Декодирование проходит примерно в три этапа, как на конвейере:

① Энтропийное декодирование битовый поток Хаффмана ② Деквантование + IDCT частоты → пиксели ③ Преобразование цвета YCbCr → RGB Строго последовательно Параллелится через SIMD Параллелится через SIMD

Последние два этапа (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 значений, а не по одному.

Скалярно по одному, N итераций SIMD одна инструкция — целая пачка

Но SIMD в libjpeg-turbo ускоряет только два последних этапа конвейера. Энтропийное декодирование остаётся однопоточным и последовательным — если только в файле JPEG нет маркеров RST (restart), делящих поток на независимые участки. Беда в том, что у подавляющего большинства JPEG (с телефонов, камер, скриншоты) маркеров RST нет, и энтропийное декодирование становится узким местом, которое многоядерный процессор не может разделить.

Собственный pjdec: распараллеливаем и последовательное энтропийное декодирование

Это ядро движка декодирования GuoheView. Мы хотели ответить на один вопрос: может ли энтропийное декодирование обычного JPEG без маркеров RST загрузить все ядра?

Интуитивно — нет: поток переменной длины, а яркость каждого блока данных (DC-коэффициент) хранится как разница с предыдущим блоком, всё связано в цепочку. Но мы применили «спекулятивный параллелизм»:

Традиционно: один поток, по порядку . Одно ядро читает от начала до конца, остальные простаивают pjdec: делим на участки, каждое ядро декодирует «вслепую» со своего начала Ядро 1 Ядро 2 Ядро 3 Ядро 4 Пунктир = вход вслепую по границе байта (позиция может быть неверной) ● = точка синхронизации: через несколько блоков декодирование «сходится», дальше всё точно Проверка ОК → берём результат после синхронизации; не сошлось → досчитываем по порядку

Метод опирается на замечательное свойство кодов Хаффмана — самосинхронизацию: даже если начать декодирование с неверной позиции, через несколько кодовых слов декодер сам «выравнивается» по настоящим границам кодовых слов. Поэтому каждое ядро вслепую начинает декодировать с границы байта в начале своего участка: сначала получается результат, «позиция которого может быть неверной», и попутно запоминается, где начинается каждый блок данных (контрольные точки).

Затем выполняется лёгкая последовательная проверка: если настоящая позиция конца предыдущего участка в точности совпадает с одной из контрольных точек следующего, то из детерминированности кодирования следует, что дальше оба результата совпадают бит в бит, — и результат следующего участка берётся как есть. Что касается связанных в цепочку разниц DC, при слепом декодировании отсчёт идёт от 0, и от истинного значения он отличается лишь на константу; после синхронизации достаточно добавить одну поправку ко всему участку (за счёт переполнения 16-битных целых — бесплатно).

Если какой-то участок так и не сходится (это очень редко), этот небольшой фрагмент просто декодируется заново по порядку — корректность всегда гарантирована, просто параллелизма чуть меньше. Итог: даже у обычного большого JPEG энтропийное декодирование загружает все ядра, и первый кадр появляется заметно быстрее.

TIFF: два пути декодирования

libtiff

libtiff — фактический стандарт для работы с TIFF с самой полной поддержкой формата (все виды сжатия, CMYK, многостраничность). GuoheView использует её как основу ради совместимости: любой TIFF, который читает libtiff, откроем и мы. Её ограничение то же: блоки данных (strip / tile) обрабатываются по одному в одном потоке.

Собственный tiff_mt: параллельно по блокам

Структура TIFF на самом деле создана для параллелизма: изображение разбито на множество независимых полос (strip) или тайлов (tile). Наш декодер tiff_mt добавляет поверх этого многопоточность:

Тайлы / полосы TIFF независимы Ядро 1Ядро 2Ядро 3Ядро 4 Ядро 1Ядро 2Ядро 3Ядро 4 Число потоков — адаптивно: по числу ядер, типу сжатия и объёму работы Один большой JPEG-блок → параллелизм pjdec внутри потока

Он идёт на два шага дальше, чем «просто запустить побольше потоков»:

  • Адаптивное число потоков: ядра не задействуются бездумно все сразу. Несжатый TIFF упирается в пропускную способность памяти, и лишние потоки лишь дерутся за неё; если работы слишком мало, потоки не стоит и запускать, — число определяется динамически по количеству ядер, способу сжатия и объёму данных.
  • Два вида параллелизма вместе: если TIFF сжат JPEG, но в нём лишь несколько больших блоков, простой «параллелизм по блокам» оставит большинство ядер без дела. Тогда tiff_mt переключается на параллелизм внутри потока — напрямую использует описанный выше спекулятивный параллелизм pjdec, чтобы разобрать этот большой JPEG-поток. Здесь два наших декодера и соединяются.

Наша стратегия

Внутри GuoheView есть реестр декодеров: каждый декодер объявляет, какие признаки формата он распознаёт (сигнатуры заголовка файла), и свой приоритет. При открытии файла движок точно сопоставляет заголовок и выбирает специализированный декодер с наивысшим приоритетом: JPEG идёт в pjdec, TIFF — в tiff_mt, и только если ничего не подошло, в дело вступает запасной WIC.