TL;DR: декодер изображений PNG из стандартной библиотеки языка программирования Wuffs работает в 1.22–2.75 раза быстрее, чем libpng (широко используемая реализация PNG декодера на C с открытым исходным кодом), C-библиотеки libspng, lodepng и stb_image, а также самые популярные библиотеки для работы с PNG на Go и Rust.

Декодирование PNG с помощью Wuffs обсуждалось на Hacker News №1, Hacker News №2, /r/programming, /r/rust и lobste.rs.

Вступление

PNG (Portable Network Graphics) — это один из самых распространенных форматов для сжатия изображений без потерь, основанный на алгоритме сжатия zlib. Он был изобретен в 1990-х годах, когда доминировали 16-битные CPU, а максимальный размер оперативной памяти в 64 Кб все еще был насущной проблемой. Более современные форматы изображений (например, WebP) и алгоритмы сжатия (например, Zstandard) могут сжимать изображения более эффективно при сравнимых скоростях декодирования, но миллиарды существующих PNG затрудняют миграцию. По одной из метрик PNG по-прежнему является наиболее используемым форматом изображений в Интернете. Кроме того, телеметрия Mozilla IMAGE_DECODE_SPEED_XXX (выборка от 03.04.2021, Firefox Desktop nightly 89) ставит PNG на второе место после JPEG:

В большинстве случаев для декодирования PNG применяется libpng - широко распространенная рефернсная open-source реализация энкодера и декодера PNG, построенная на базе библиотеки zlib, аналогично широко распространенной реализации формата (алгоритма) сжатия zlib.

Wuffs - это memory-safe язык программирования XXI века со стандартной библиотекой, написанной на нем самом. Он предназначен для безопасной работы с "небезопасными" форматами файлов (Wrangling Untrusted File Formats Safely).

Прим. пер.: Wuffs можно использовать либо как самостоятельный язык программирования (Wuffs-the-language), либо как C-бибилиотеку, так как исходный код на Wuffs транспилируется в C (Wuffs-the-library). В статье будет идти речь об использовании Wuffs как C библиотеки.

На ноутбуке средней ценовой категории с x86_64 CPU (актуально на 2021 год - прим. пер.) Wuffs может декодировать PNG изображения в 1.5-2.75 раза быстрее, чем libpng:

libpng_decode_19k_8bpp                            58.0MB/s ± 0%  1.00x
libpng_decode_40k_24bpp                           73.1MB/s ± 0%  1.00x
libpng_decode_77k_8bpp                             177MB/s ± 0%  1.00x
libpng_decode_552k_32bpp_ignore_checksum           146MB/s ± 0%  (*)
libpng_decode_552k_32bpp_verify_checksum           146MB/s ± 0%  1.00x
libpng_decode_4002k_24bpp                          104MB/s ± 0%  1.00x

libpng                                                  1.00x to 1.00x

----

wuffs_decode_19k_8bpp/clang9                       131MB/s ± 0%  2.26x
wuffs_decode_40k_24bpp/clang9                      153MB/s ± 0%  2.09x
wuffs_decode_77k_8bpp/clang9                       472MB/s ± 0%  2.67x
wuffs_decode_552k_32bpp_ignore_checksum/clang9     370MB/s ± 0%  2.53x
wuffs_decode_552k_32bpp_verify_checksum/clang9     357MB/s ± 0%  2.45x
wuffs_decode_4002k_24bpp/clang9                    156MB/s ± 0%  1.50x

wuffs_decode_19k_8bpp/gcc10                        136MB/s ± 1%  2.34x
wuffs_decode_40k_24bpp/gcc10                       162MB/s ± 0%  2.22x
wuffs_decode_77k_8bpp/gcc10                        486MB/s ± 0%  2.75x
wuffs_decode_552k_32bpp_ignore_checksum/gcc10      388MB/s ± 0%  2.66x
wuffs_decode_552k_32bpp_verify_checksum/gcc10      373MB/s ± 0%  2.55x
wuffs_decode_4002k_24bpp/gcc10                     164MB/s ± 0%  1.58x

(*) В "упрощенном API" libpng нет возможности отключить проверку контрольной суммы, поэтому для libpng в кейсах *_ignore_checksum мы используем результаты для кейсов *_verify_checksum.

В бенчмарках используется следующая нотация: 77k_8bpp означает PNG изображение шириной 160 пикселей, высотой 120 пикселей и цветовой моделью размером 8 бит (размер индекса палитры) на один пиксель. При декодировании в формат 32bpp BGRA получается 160 × 120 × 4 = 76800 байт, сокращенно 77k. Другие примеры (dst - выходной формат, src - входной формат):

Обработка 4002k байт со скоростью 104 Мб/с или 164 Мб/с означает, что libpng или Wuffs требуется около 38 миллисекунд или 24 миллисекнуды соответственно для декодирования данного изображения размером 1165 × 859.

Замеры для некоторых других реализаций PNG (libspng, lodepng, stb_image, image/png из стандартной библиотеки языка Go и реализации PNG на Rust) представлены в Приложении.

Примеры команд из статьи получены с помощью примеров кода, доступных в репозитории Wuffs.

Используемые техники оптимизации

Сначала пара слов о том, как вообще устроен формат PNG. Процесс декодирования PNG состоит из следующих основных этапов (приведены самые ресурсозатратные операции):

  1. Два алгоритма хэширования для вычисления контрольных сумм - CRC-32 и Adler-32. Оба создают 32-битные хэши, но сами алгоритмы отличаются.

  2. Декомпрессия с помощью алгоритма DEFLATE.

  3. 2D фильтрация (своего рода подготовка данных для сжатия, позволяющая учесть двумерность данных - прим. пер.): зачастую эффективнее сжимать разность цветов соседних пикселей нежели сами цвета пикселей.

Каждый из этих этапов может быть оптимизирован.

Вычисление и проверка контрольных сумм

CRC-32

В статье 2009 года "Fast CRC Computation for Generic Polynomials Using PCLMULQDQ Instruction" рассказывается о том, как реализовать вычисление CRC-32 используя x86_64 SIMD-инструкции. Реализация этой техники в Wuffs выглядит примерно так. Код для ARM еще проще, так как ARM имеет отдельные инструкции для вычисления CRC-32.

Для оценки производительности различных реализаций хэш-функций существует набор тестов и бенчмарков SMHasher. Согласно этим бенчмаркам, наша реализация CRC-32 с SIMD в 47 раз быстрее, чем наивная реализация CRC-32 в SMHasher.

Что касается реальной производительности, то пример example/crc32 из Wuffs более-менее эквивалентнен программе /bin/crc32 из Debian с точки зрения функциональности, но при этом этом он в 7.3 раз быстрее (0.056 секунд против 0.410 секунд) на этом файле размером 178 Мб:

$ ls -lh linux-5.11.3.tar.gz | awk '{print $5 " " $9}'
178M linux-5.11.3.tar.gz
$ g++ -O3 wuffs/example/crc32/crc32.cc -o wcrc32
$ time ./wcrc32   /dev/stdin < linux-5.11.3.tar.gz
05b309fb
real    0m0.056s
$ time /bin/crc32 /dev/stdin < linux-5.11.3.tar.gz
05b309fb
real    0m0.410s

Adler-32

Об ускорении вычисления хэш-функции Adler-32 с помощью SIMD-инструкций не написано статей (актуально на 2021 год - прим. пер.), однако это возможно: вот реализации для ARM и x86_64 в Wuffs.

Реализация Adler-32 из Wuffs примерно в 6.4 раза быстрее (11.3 ГБ/с против 1.76 ГБ/с), чем реализация из zlib (указана как mimic_*) согласно benchstat:

$ cd wuffs
$ # ¿ is just an unusual character that's easy to search for. By
$ # convention, in Wuffs' source, it marks build-related information.
$ grep ¿ test/c/std/adler32.c
// ¿ wuffs mimic cflags: -DWUFFS_MIMIC -lz
$ gcc -O3 test/c/std/adler32.c -DWUFFS_MIMIC -lz
$ # Run the benchmarks.
$ ./a.out -bench | benchstat /dev/stdin
name                      speed
wuffs_adler32_10k/gcc10   11.3GB/s ± 0%
wuffs_adler32_100k/gcc10  11.6GB/s ± 0%
mimic_adler32_10k/gcc10   1.76GB/s ± 0%
mimic_adler32_100k/gcc10  1.72GB/s ± 0%

Игнорируем контрольные суммы

Очевидно, что самая быстрый способ проверить контрольную сумму - это не проверять ее вообще, пропуская эти значения при чтении PNG файла. Согласно результатам бенчмарка для *_ignore_checksum против *_verify_checksum, приведенных ранее, разница в производительности составляет около 4%. Разница не слишком большая, однако, как было уже сказано, отключение проверки контрольных сумм в libpng составляет проблему, но для Wuffs это можно сделать одной строкой. Однако, если вы используете альтернативный декодер PNG (не Wuffs), то отключение проверки контрольных сумм может ускорить декодирование даже больше, чем на 4% в случае, если декодер не использует SIMD-оптимизированную реализацию хэш-функций. При этом следует помнить, что отключение проверки контрольных сумм - это компромисс: мы теряем возможность обнаружить повреждение данных и таким образом, вообще говоря, отклоняемся от спецификации PNG, которая прямо предписывает это делать.

Декомпрессия DEFLATE

Основная часть данных после сжатия с помощью DEFLATE состоит из последовательности кодов: либо "символьных" кодов (фактически исходных данных), либо кодов копирования. Существует 256 возможных символьных кодов, по одному на каждый возможный распакованный байт. Каждый код копирования состоит из длины (сколько байт нужно скопировать, от 3 до 258 включительно) и расстояния (с какого места в "истории" или в ранее распакованном выводе нужно скопировать, от 1 до 32768 включительно).

Например, слово "banana" может быть сжато в виде такой последовательности:

  • Символ 'b'

  • Символ 'a'

  • Символ 'n'

  • Скопировать 3 байта, начиная с 2 байта назад: "ana"

Коды кодируются по Хаффману, что означает, что они занимают переменное (но целое) число бит (от 1 до 48 включительно) и не обязательно начинаются или заканчиваются на границах байтов. Символьные коды всегда порождают один байт информации. Копирующие коды же выдают до 258 байт. Таким образом, максимальное количество выходных байт одного кода (будь то символьный код или код копирования) равно 258 (запомните это число, мы вернемся к нему позже).

Wuffs v0.2 предоставлял реализацию DEFLATE, аналогичную библиотеке zlib, и показывал сопоставимую производительность, по крайней мере, на x86_64. В Wuffs v0.3 были добавлены две существенные оптимизации для современных процессоров, поддерживающих 64-битной записи и чтения из памяти без выравнивания: ввод и вывод чанками по 8 байт.

Ввод чанками по 8 байт

Как было отмечено выше, коды копирования DEFLATE занимают от 1 до 48 бит. Реализация процедуры обработки одного такого кода в zlib считывает входные биты в нескольких местах цикла. В inffast.c имеется 7 экземпляров hold += (unsigned long)(*in++) << bits; bits += 8;, загружающих входные биты по 1 байту (8 бит) за раз. Вместо этого мы можем читать по 64 бита за раз. Некоторые из прочитанных битов будут отброшены, если в буфере уже есть необработанные биты, но это не страшно: чтение этих битов приведет к битовому сдвигу нулями, а побитовое ИЛИ с нулями - это no-op. Более подробно эти тонкости при работе с битами рассматриваются статье Фабиана "ryg" Гизена в этом посте 2018 года.

Для Wuffs чтение 64 битов за раз увеличило производительность микробенчмарка DEFLATE в 1.30 раз.

Вывод чанками по 8 байт

Рассмотрим последовательность кодов DEFLATE для сжатия фразы TO BE OR NOT TO BE. THAT IS ETC. Второй TO BE может быть представлен кодом копирования с длиной 5 и расстоянием 13. Копирование 5 байт можно реализовать с помощью следующей последовательности из пяти команд (при этом чтение и запись мы делаем без выравнивания): чтение 4 байтов; запись 4 байтов; чтение 1 байта; запись 1 байта; out_ptr += 5. При достаточно большом расстоянии кода копирования такая последовательность команд будет работать корректно даже без выравнивания. Очевидной оптимизацией в плане количества команд будет "копировать излишние данные" (чтение 8 байт; запись 8 байт; out_ptr += 5).

: TO_BE_OR_NOT_??????????????????????
: ^            ^
: out_ptr-13   out_ptr
:
:
:              [1234567) copy 8 bytes
:              v       v
: TO_BE_OR_NOT_TO_BE_OR??????????????
:              ^    ^
:                   out_ptr += 5
:
:
:                   [) write 1 byte
:                   vv
: TO_BE_OR_NOT_TO_BE.OR??????????????
:                   ^^
:                    out_ptr += 1

Запись результатов обработки последующих кодов (например, символьного кода '.') будет делаться поверх "излишне скопированных" данных, то есть наша оптимизация не сломает алгоритм. Заметим, что API библиотеки zlib не дает возможность применить такую оптимизацию: функция inflateBack использует единый буфер для "истории" и вывода, поэтому перезапись 8 байт может изменить буфер "истории", и сделать результаты декодирования некорректными.

Для Wuffs округление длины кодов копирования до кратного 8 ускорило работу микробенчмарка DEFLATE в 1.48 раз.

Сравнение с gzip

Формат gzip - это, грубо говоря, сочетание алгоритма DEFLATE с проверкой контрольной суммы с помощью CRC-32. Как и пример example/crc32, пример example/zcat более-менее эквивалентнен программе /bin/zcat из Debian с точки зрения функциональности, но при этом этом он в 3.1 раз быстрее (2.680 секунд против 8.389 секунд) на том же файле размером 178 Мб:

$ gcc -O3 wuffs/example/zcat/zcat.c -o wzcat
$ time ./wzcat   < linux-5.11.3.tar.gz > /dev/null
real    0m2.680s
$ time /bin/zcat < linux-5.11.3.tar.gz > /dev/null
real    0m8.389s

Очевидно, что контрольная сумма, вычисленная нашей программой, должна быть равна контрольной сумме, вычисленной с помощью /bin/zcat:

$ ./wzcat   < linux-5.11.3.tar.gz | ./wcrc32   /dev/stdin
750d1011
$ /bin/zcat < linux-5.11.3.tar.gz | /bin/crc32 /dev/stdin
750d1011
$ tail --bytes=8 linux-5.11.3.tar.gz | hd
00000000  11 10 0d 75 00 78 70 3f

All-at-once декомпрессия

Для объяснения второй техники оптимизации шага декомпрессии рассмотрим следующую аналогию. Тактика бега на скорость из точки А в точку Б по ровной дороге тривиальна: нужно бежать изо всех сил. А теперь предположим, что точка Б находится на краю обрыва, внизу которого плавают голодные акулы, питающиеся бегунами. Тогда нам придется разделить наш забег на два этапа: начальный этап бега изо всех сил (обозначен синим) и этап осторожного добегания или вообще ходьбы, в котором главной целью является не свалиться с обрыва (обозначен красным).

Декомпрессия DEFLATE предполагает запись распакованных данных в некий буфер (наш забег), где вылезание за его границы представляет собой классический buffer overflow (обрыв с акулами). Чтобы избежать этого, библиотека zlib имеет две реализации декомпрессии: быструю "синюю" (используется когда, например, до конца буфера остается 258 или более байт) и медленную "красную" (используется во всех других случаях).

Кроме того, libpng выделяет два буфера (для текущего и предыдущего ряда пикселей) и обращается к zlib N раз, где N - высота изображения в пикселях. Каждый раз буфер назначения имеет размер ровно в одну строку (ширина в пикселях, умноженная на количество байт на пиксель, плюс байт для хранения конфигурации фильтра), что означает, что zlib распаковывает последние 258 или более байт каждой строки с помощью медленной "красной" реализации. Для наглядности, это примерно четверть пикселей RGB изображения размером 300 × 200, а с точки зрения скорости декодирования потери будут еще больше.

Алгоритм декомпресии формата zlib в Wuffs также использует это разделение на медленную и быструю реализации, так как это критично для безопасности, но в отличие от libpng, декомпрессия выполняется сразу для всего изображения (all-at-once), а не построчно. Другими словами, почти все (более 99% пикселей RGB изображения размером 300 × 200) пиксели теперь находятся в быстрой "синей" зоне. Это хорошо уже само по себе, но кроме того позволяет избежать кэш-промахов и branch misprediction при чередовании "синего" и "красного" кода.

Очевидно, что такой метод требует O(ширина изображения × высота изображения) промежуточной памяти вместо O(ширина изображения) памяти, но это не очень страшно, так как если изображение декодируется в оперативную память, а не на диск, то это само по себе уже требует O(ширина изображения × высота изображения) памяти. Кроме того, API Wuffs предоставляет вызывающей стороне некоторый выбор в отношении использования памяти. Вместо запроса "мне нужно M байт памяти для декодирования этого изображения" Wuffs говорит "мне нужно от M0 до M1 (включительно) байт памяти, и чем больше вы мне дадите, тем быстрее я буду работать". В текущей версии (0.3.0 - прим. пер.) Wuffs всегда устанавливает M0 равным M1 (выбора нет, all-at-once декомпрессиия используется всегда), но в будущих версиях может быть реализована возможность построчной декомпрессии, предлагая выбор между реализациями.

Фильтрация

И Wuffs, и libpng используют SIMD для ускорения 2D фильтрации. Например, вот реализация фильтрации в Wuffs для x86. Однако, на этом этапе libpng может показывать немного бóльшую производительность, поскольку, в отличии от Wuffs, он может гарантировать, что все строки изображения выровнены по оптимальным для SIMD границам. Wuffs же дает куда меньше гарантий относительно выравнивания, отчасти потому, что Wuffs не может самостоятельно выделять память, но в основном потому что all-at-once декомпрессия требует отказа от возможности, например, выравнивать начало каждой строки по 4 байта. Тем не менее, профилирование показывает, что на шаг декомпрессии тратится намного больше времени, чем на фильтрацию, так что преимущества all-at-once оптимизации перевешивают оверхед на фильтрацию без выравнивания.

Почему бы не оптимизировать так же уже существующие реализации PNG декодеров вместо создания еще одной с нуля?

В теории это можно было сделать, но есть ряд причин, почему стоило написать все с нуля.

Почему нет смысла патчить libpng?

А точнее, почему это бесперспективно. Как минимум по нескольким причинам:

  1. libpng написан на C, а значит он скорее всего имеет проблемы с memory safety. Более того, его API для обработки ошибок построен на setjmp и longjmp, а сотни goto усложняют статический или формальный анализ.

  2. Несмотря на то, что формат файла практически не менялся с 1999 года (версия 1.2 была формализована в 2003 году, а APNG - это неофициальное расширение), в libpng было обнаружено 74 CVE с 2002 по 2021 год, 9 из которых - с 2018 года.

  3. В исходниках libpng встречаются такие вещи как, например, лаконичный комментарий "TODO: WARNING: TRUNCATION ERROR: DANGER WILL ROBINSON", добавленный больше десяти лет назад. Думаю, не нужно пояснять, что здесь не так.

  4. Код libpng на самом деле невероятно сложен. Например грубая прикидка с помощью команды wc -l .c arm/.c intel/*.c в репозитории libpng даст нам 35182 строки кода (без учета заголовочных файлов). Аналогичная команда для Wuffs покажет нам 2110 строк. Конечно, libpng также реализует энкодер PNG, но даже с учетом этого остается разница примерно на порядок.

Попытки пропатчить zlib

Я пытался пропатчить zlib несколько лет назад, но это оказалось сложнее, чем я думал, из-за упомянутой выше проблемы с API inflateBack. Так или иначе, существуют форки zlib zlib-ng/zlib-ng, и cloudflare/zlib которые решают часть проблем с производительностью с помощью похожих оптимизаций. Однако, использование, например zlib-ng вместо ванильного zlib в libpng дает совсем небольшой прирост производительности:

libpng_decode_19k_8bpp                            58.0MB/s ± 0%  1.00x
libpng_decode_40k_24bpp                           73.1MB/s ± 0%  1.00x
libpng_decode_77k_8bpp                             177MB/s ± 0%  1.00x
libpng_decode_552k_32bpp_ignore_checksum           146MB/s ± 0%  (†)
libpng_decode_552k_32bpp_verify_checksum           146MB/s ± 0%  1.00x
libpng_decode_4002k_24bpp                          104MB/s ± 0%  1.00x

libpng                                                  1.00x to 1.00x

----

zlibng_decode_19k_8bpp/gcc10                      63.8MB/s ± 0%  1.10x
zlibng_decode_40k_24bpp/gcc10                     74.1MB/s ± 0%  1.01x
zlibng_decode_77k_8bpp/gcc10                       189MB/s ± 0%  1.07x
zlibng_decode_552k_32bpp_ignore_checksum/gcc10                 skipped
zlibng_decode_552k_32bpp_verify_checksum/gcc10     177MB/s ± 0%  1.21x
zlibng_decode_4002k_24bpp/gcc10                    113MB/s ± 0%  1.09x

zlibng                                                  1.01x to 1.21x

Почему стоит попытаться пропатчить Go и Rust

И Go, и Rust - это современные memory safe языки, однако для существующих проектов на C/C++ проще внедрить Wuffs, который транспилируется в C. Тем не менее, вполне возможно, что внедрение оптимизаций, описанных в этой статье в реализации декодера PNG на Go или Rust имеет смысл. Например, ни в Go, ни в Rust реализация Adler-32 не использует SIMD. Возможно, также стоит попробовать оптимизировать DEFLATE: реализация DEFLATE в Go читает только один байт за раз, а реализация miniz_oxide в Rust хоть и читает 4 байта за раз, но это все еще хуже, чем читать по 8 байт. Кроме того, ни в Go, ни в Rust декодер PNG не использует all-at-once декомпрессию.

Однако стоит отметить, что в отличие от Go и Rust, memory safety в Wuffs обеспечивается во время компиляции, а не с помощью проверок в рантайме (например, что индекс i при доступе к a[i] не выходит за границы массива или что (x + y) не переполняет u32). Компиляторы Go и Rust выполняют часть этих проверок во время компиляции (например, при итерации по элементам массива), но часть проверок все равно делается в рантайме, например, при декодировании DEFLATE-кодов. Очевидно, такие проверки могут значительно влиять на производительность. В этом плане мне нравится девиз языка Zig “Performance and Safety: Choose Two”, но, в отличие от Zig, в Wuffs нет отдельных режимов сборки "Release Fast" и "Release Safe": единственный режим сборки (компиляция кода на C с флагом -O3) дает и безопасность, и производительность.

Заключение

Только что вышла версия Wuffs 0.3.0-beta.1, которая содержит самый быстрый и безопасный в мире декодер PNG. Примеры использования из C и C++ доступны здесь. PNG декодер пока не поддерживает цветовые пространства и гамма-коррекцию (см. Issue 39), но даже без этого Wuffs может представлять некоторую ценность.

Прим. пер.: состояние Wuffs на 2023 год обсуждается ниже в абзаце "Комментарий переводчика".

Приложение

Подробные результаты бенчмарков

libpng здесь - это /usr/lib/x86_64-linux-gnu/libpng16.so из дистрибутив Debian Bullseye.

libpng_decode_19k_8bpp                            58.0MB/s ± 0%  1.00x
libpng_decode_40k_24bpp                           73.1MB/s ± 0%  1.00x
libpng_decode_77k_8bpp                             177MB/s ± 0%  1.00x
libpng_decode_552k_32bpp_ignore_checksum           146MB/s ± 0%  (†)
libpng_decode_552k_32bpp_verify_checksum           146MB/s ± 0%  1.00x
libpng_decode_4002k_24bpp                          104MB/s ± 0%  1.00x

libpng                                                  1.00x to 1.00x

----

wuffs_decode_19k_8bpp/clang9                       131MB/s ± 0%  2.26x
wuffs_decode_40k_24bpp/clang9                      153MB/s ± 0%  2.09x
wuffs_decode_77k_8bpp/clang9                       472MB/s ± 0%  2.67x
wuffs_decode_552k_32bpp_ignore_checksum/clang9     370MB/s ± 0%  2.53x
wuffs_decode_552k_32bpp_verify_checksum/clang9     357MB/s ± 0%  2.45x
wuffs_decode_4002k_24bpp/clang9                    156MB/s ± 0%  1.50x

wuffs_decode_19k_8bpp/gcc10                        136MB/s ± 1%  2.34x
wuffs_decode_40k_24bpp/gcc10                       162MB/s ± 0%  2.22x
wuffs_decode_77k_8bpp/gcc10                        486MB/s ± 0%  2.75x
wuffs_decode_552k_32bpp_ignore_checksum/gcc10      388MB/s ± 0%  2.66x
wuffs_decode_552k_32bpp_verify_checksum/gcc10      373MB/s ± 0%  2.55x
wuffs_decode_4002k_24bpp/gcc10                     164MB/s ± 0%  1.58x

wuffs                                                   1.50x to 2.75x

----

libspng_decode_19k_8bpp/clang9                    59.3MB/s ± 0%  1.02x
libspng_decode_40k_24bpp/clang9                   78.4MB/s ± 0%  1.07x
libspng_decode_77k_8bpp/clang9                     189MB/s ± 0%  1.07x
libspng_decode_552k_32bpp_ignore_checksum/clang9   236MB/s ± 0%  1.62x
libspng_decode_552k_32bpp_verify_checksum/clang9   203MB/s ± 0%  1.39x
libspng_decode_4002k_24bpp/clang9                  110MB/s ± 0%  1.06x

libspng_decode_19k_8bpp/gcc10                     59.6MB/s ± 0%  1.03x
libspng_decode_40k_24bpp/gcc10                    77.5MB/s ± 0%  1.06x
libspng_decode_77k_8bpp/gcc10                      189MB/s ± 0%  1.07x
libspng_decode_552k_32bpp_ignore_checksum/gcc10    223MB/s ± 0%  1.53x
libspng_decode_552k_32bpp_verify_checksum/gcc10    194MB/s ± 0%  1.33x
libspng_decode_4002k_24bpp/gcc10                   109MB/s ± 0%  1.05x

libspng                                                 1.02x to 1.62x

----

lodepng_decode_19k_8bpp/clang9                    65.1MB/s ± 0%  1.12x
lodepng_decode_40k_24bpp/clang9                   72.1MB/s ± 0%  0.99x
lodepng_decode_77k_8bpp/clang9                     222MB/s ± 0%  1.25x
lodepng_decode_552k_32bpp_ignore_checksum/clang9               skipped
lodepng_decode_552k_32bpp_verify_checksum/clang9   162MB/s ± 0%  1.11x
lodepng_decode_4002k_24bpp/clang9                 70.5MB/s ± 0%  0.68x

lodepng_decode_19k_8bpp/gcc10                     61.1MB/s ± 0%  1.05x
lodepng_decode_40k_24bpp/gcc10                    62.5MB/s ± 1%  0.85x
lodepng_decode_77k_8bpp/gcc10                      176MB/s ± 0%  0.99x
lodepng_decode_552k_32bpp_ignore_checksum/gcc10                skipped
lodepng_decode_552k_32bpp_verify_checksum/gcc10    139MB/s ± 0%  0.95x
lodepng_decode_4002k_24bpp/gcc10                  62.3MB/s ± 0%  0.60x

lodepng                                                 0.60x to 1.25x

----

stbimage_decode_19k_8bpp/clang9                   75.1MB/s ± 1%  1.29x
stbimage_decode_40k_24bpp/clang9                  84.6MB/s ± 0%  1.16x
stbimage_decode_77k_8bpp/clang9                    234MB/s ± 0%  1.32x
stbimage_decode_552k_32bpp_ignore_checksum/clang9  162MB/s ± 0%  1.11x
stbimage_decode_552k_32bpp_verify_checksum/clang9              skipped
stbimage_decode_4002k_24bpp/clang9                80.7MB/s ± 0%  0.78x

stbimage_decode_19k_8bpp/gcc10                    73.3MB/s ± 0%  1.26x
stbimage_decode_40k_24bpp/gcc10                   81.8MB/s ± 0%  1.12x
stbimage_decode_77k_8bpp/gcc10                     214MB/s ± 0%  1.21x
stbimage_decode_552k_32bpp_ignore_checksum/gcc10   145MB/s ± 0%  0.99x
stbimage_decode_552k_32bpp_verify_checksum/gcc10               skipped
stbimage_decode_4002k_24bpp/gcc10                 79.7MB/s ± 0%  0.77x

stbimage                                                0.77x to 1.32x

----

go_decode_19k_8bpp/go1.16                         39.4MB/s ± 1%  0.68x
go_decode_40k_24bpp/go1.16                        46.7MB/s ± 1%  0.64x
go_decode_77k_8bpp/go1.16                         78.3MB/s ± 0%  0.44x
go_decode_552k_32bpp_ignore_checksum/go1.16                    skipped
go_decode_552k_32bpp_verify_checksum/go1.16        118MB/s ± 0%  0.81x
go_decode_4002k_24bpp/go1.16                      50.5MB/s ± 0%  0.49x

go                                                      0.44x to 0.81x

----

rust_decode_19k_8bpp/rust1.48                     89.8MB/s ± 0%  1.55x
rust_decode_40k_24bpp/rust1.48                     122MB/s ± 0%  1.67x
rust_decode_77k_8bpp/rust1.48                      158MB/s ± 0%  0.89x
rust_decode_552k_32bpp_ignore_checksum/rust1.48                skipped
rust_decode_552k_32bpp_verify_checksum/rust1.48    136MB/s ± 0%  0.93x
rust_decode_4002k_24bpp/rust1.48                   122MB/s ± 0%  1.17x

rust                                                    0.89x to 1.67x

Воспроизведение

Wuffs транспилируется в код на C, а точнее в C библиотеку без зависимостей, состоящую из одного файла. Таким образом, для запуска бенчмарков вам не нужно ничего, кроме компилятора C:

$ cd wuffs
$ # ¿ is just an unusual character that's easy to search for. By
$ # convention, in Wuffs' source, it marks build-related information.
$ grep ¿ test/c/std/png.c
// ¿ wuffs mimic cflags: -DWUFFS_MIMIC -lm -lpng -lz
$ gcc -O3 test/c/std/png.c -DWUFFS_MIMIC -lm -lpng -lz
$ # Run the tests.
$ ./a.out
$ # Run the benchmarks.
$ ./a.out -bench

Бенчмарки для Go и Rust вынесены в отдельные программы.

Используемое железо

Все метрики были получены на бюджетном ноутбуке с x86_64 CPU:

$ cat /proc/cpuinfo | grep model.name | uniq
model name: Intel(R) Core(TM) m3-6Y30 CPU @ 0.90GHz

Ниже также приведены результаты бенчмарков для Raspberry Pi 4 (32-битный armv7l), которые не столь впечатляющи как результаты для x86_64, но тем не менее разница в производительности между libpngи Wuffs все равно заметная:

libpng_decode_19k_8bpp                            44.1MB/s ± 0%  1.00x
libpng_decode_40k_24bpp                           54.6MB/s ± 0%  1.00x
libpng_decode_77k_8bpp                             123MB/s ± 0%  1.00x
libpng_decode_552k_32bpp_ignore_checksum           101MB/s ± 0%  (†)
libpng_decode_552k_32bpp_verify_checksum           101MB/s ± 0%  1.00x
libpng_decode_4002k_24bpp                         82.1MB/s ± 0%  1.00x

libpng                                                  1.00x to 1.00x

----

wuffs_decode_19k_8bpp/clang9                      82.5MB/s ± 0%  1.87x
wuffs_decode_40k_24bpp/clang9                      105MB/s ± 0%  1.92x
wuffs_decode_77k_8bpp/clang9                       303MB/s ± 0%  2.46x
wuffs_decode_552k_32bpp_ignore_checksum/clang9     180MB/s ± 0%  1.78x
wuffs_decode_552k_32bpp_verify_checksum/clang9     174MB/s ± 0%  1.72x
wuffs_decode_4002k_24bpp/clang9                    100MB/s ± 0%  1.22x

wuffs_decode_19k_8bpp/gcc8                        79.8MB/s ± 0%  1.81x
wuffs_decode_40k_24bpp/gcc8                        106MB/s ± 0%  1.94x
wuffs_decode_77k_8bpp/gcc8                         271MB/s ± 0%  2.20x
wuffs_decode_552k_32bpp_ignore_checksum/gcc8       177MB/s ± 0%  1.75x
wuffs_decode_552k_32bpp_verify_checksum/gcc8       170MB/s ± 0%  1.68x
wuffs_decode_4002k_24bpp/gcc8                      100MB/s ± 0%  1.22x

wuffs                                                   1.22x to 2.46x

Комментарий переводчика (актуальное состояние Wuffs)

Несмотря на то, что с момента публикации оригинального поста прошло больше двух лет, Wuffs не только не уступил позиций по части декодирования PNG (кто-то даже называет его "ridiculously fast"), но и обзавелся рядом новых возможностей:

Последняя версия Wuffs на момент написания поста - 0.3.3. Для нее доступны следующие интерфейсы:

Комментарии (33)


  1. Zara6502
    31.07.2023 11:18
    +1

    вопрос - а почему нельзя просто было переписать libpng?


    1. porto Автор
      31.07.2023 11:18
      +1

      Это отчасти раскрывается в абзаце "Почему нет смысла патчить libpng?". Насколько я понял, главная причина заключается в том, что в libpng очень много движущихся частей, которые сильно связаны друг с другом, то есть изолированно оптимизировать какой-то кусок не выйдет, и в итоге придется переписывать большую часть огромной библиотеки. Короче, легаси, как всегда.


      1. defecator
        31.07.2023 11:18

        Что там огромного-то в библиотеке ?


        1. VitalySh
          31.07.2023 11:18
          +1

          https://github.com/glennrp/libpng
          200к строк кода


      1. Zara6502
        31.07.2023 11:18
        +1

        я не о том чтобы пропатчить libpng для достижения результата, а о том чтобы сделать libpng2 - но быстрее-выше-сильнее, безо всяких Wuffs, тем более что это по сути тот же Си. Просто наверное есть что-то что определило такой подход?


        1. klopp_spb
          31.07.2023 11:18
          +3

          Проблема в том, что на libpng очень много всего завязано. И чтобы оно не поломалось, в этой самой гипотетической libpng2 нужно детально воспроизвести весь функционал текущей libpng, включая константы, макросы, типы и т.д. А это over do figa всего. Если же делать что-то принципиально новое, со своим API, то кому оно будет нужно? область применения будет крохотной...


          1. SergeyMax
            31.07.2023 11:18

            Проблема в том, что на libpng очень много всего завязано. И чтобы оно не поломалось

            А теперь вместо переписывания одной библиотеки потребуется переписать всё остальное, но кого это вообще волнует?!


          1. KanuTaH
            31.07.2023 11:18

            Ну а при подходе из статьи получаем сразу и соль, и матумбу - и библиотеку пришлось переписать (написав новую), и все остальное теперь нужно переписать, чтобы ей пользоваться, это ещё больше over do figa всего.


            1. klopp_spb
              31.07.2023 11:18
              +1

              Ну а при подходе из статьи

              Я что-то не уловил, в какой момент авторы wuffы заявили, что они хотят внедриться в libpng или сделать её замену. В данном топике такая мысль возникла не у них, пару часов назад, и странно что-то говорить про "их подход", который вообще ничего такого не предполагает.


              1. KanuTaH
                31.07.2023 11:18

                Я что-то не уловил, в какой момент авторы wuffы заявили, что они хотят внедриться в libpng или сделать её замену.

                Ну так в этом и проблема. В результате ей будут пользоваться 2.5 человека, потому что про libpng знают все, и работают с ней все, а про этот wuff, возможно, через определенное время и сами авторы забудут. А вот сделали бы libpng-совместимый API (хотя бы в качестве дополнительного/переходного), чтобы можно было просто линковать с этой либой уже существующий код, пусть и с какими-то минимальными изменениями - тогда было бы о чем разговаривать...


                1. porto Автор
                  31.07.2023 11:18
                  +1

                  Можно запросто обернуть реализацию на Wuffs в libpng-подобный API, никаких принципиальных ограничений для этого нет.

                  А насчет того, зачем и почему автор решил изобрести велосипед вместо того, чтобы оптимизировать имеющиеся реализации, так он это пытался пояснить в абзаце про попытки и перспективы пропатчить разные реализации:

                  1. libpng слишком сложен, чтобы его патчить.

                  2. zlib можно и нужно патчить, но это не очень сильно влияет на скорость декодирования PNG в целом из slow/fast декодирования строк.

                  3. Go и Rust можно пропатчить, но это не поможет проектам на C/C++, и memory safety в Go и Rust обеспечивается в том числе runtime механизмами, которые бьют по производительности и которые можно было бы в этой конкретной задаче не использовать совсем.

                  Кроме того, Wuffs - это не yet another PNG decoder, а инструмент для разработки быстрых и безопасных декодеров для любых форматов даных. Подробнее про это можно почитать в соответствующей части его документации.


                  1. KanuTaH
                    31.07.2023 11:18
                    +1

                    Можно запросто обернуть реализацию на Wuffs в libpng-подобный API, никаких принципиальных ограничений для этого нет.

                    Вот и удивительно, что это не сделано. Тем более, что они сами заявляют, что "Wuffs is not a general purpose programming language. It is for writing libraries, not programs."

                    libpng слишком сложен, чтобы его патчить

                    Никто не предлагает его патчить. Предлагают его переписать - сделать условную libpng2 с таким же API, как у libpng, чтобы ее бесшовно можно было вызывать из существующего C/C++/whatever кода (тем более что wuffs транспилируется в C). И доброе дело бы сделали, и этот свой wuffs прорекламировали. Приведенные пояснения, как по мне, не поясняют, почему автор(ы) этого не сделал(и). А очередной велосипед - он очередной велосипед и есть.


                    1. klopp_spb
                      31.07.2023 11:18

                      почему автор(ы) этого не сделал(и)

                      Наверное, потому, что это даже не в разы - на порядок, а может и не один, сложней и ресурсоёмче, чем написать декодер?


                      1. KanuTaH
                        31.07.2023 11:18

                        Быть, а не казаться, вообще сложно.


                      1. klopp_spb
                        31.07.2023 11:18
                        +1

                        Как-то довелось портировать банальную getline(). Из glibc, казалось бы. Пару дней вкуривания манов, ога, пишем... и... десяток юнит-тестов, неделя отладки.

                        А сколько там зависимостей может быть... Голым cpp прогнать, и то ужаснёшься.

                        Ну, и главное: мы не знаем почему они это сделали.

                        • Могут

                        • Хотят

                        • твой вариант


          1. Zara6502
            31.07.2023 11:18

            нет, проблема не в этом. ниже уже написали. вместо того чтобы один раз переписать правильно либу теперь создана ситуация когда нужно полностью переписывать код под wuff, или продолжать использовать libpng. То есть полезность wuff варианта почти нулевая.


  1. SergeyMax
    31.07.2023 11:18

     memory safety в Wuffs обеспечивается во время компиляции, а не с помощью проверок в рантайме (например, что индекс i при доступе к a[i] не выходит за границы массива

    А если значение i берётся из файла, то что делает компилятор?


    1. gresolio
      31.07.2023 11:18

      Видимо проверяет, что все возможные значения i не приводят к выходу за границы массива (тут не важно берётся i из файла, или из чего-то другого). Например если значение i as u8, значит массив должен иметь границы как минимум [0 ..= 255] или больше, в противном случае ошибка компиляции. Подробней: wuffs --> README.md --> What Does Compile Time Checking Look Like?


      1. SergeyMax
        31.07.2023 11:18

        А если значение i as u32?


        1. gresolio
          31.07.2023 11:18

          Тогда все возможные значения [0...4,294,967,295] в противном случае ошибка компиляции. Если же массив на 4 гига не нужен, то необходимо явно делать проверку входных данных на допустимые значения, как показано всё в том же примере чуть ниже.


  1. voldemar_d
    31.07.2023 11:18
    +1

    Я, наверное, кощунственную мысль озвучить попытаюсь. А в каких задачах жизненно важно ускорять декодирование png? В браузере, имхо, не будет заметно, если показ картинок будет ускорен даже в 10 раз - больше будут сами данные загружаться. Декодировать png на 178 мегабайт - где такое вообще может быть нужно, кроме тестов?


    1. Zara6502
      31.07.2023 11:18
      +1

      думаю это больше нужно для игровых движков на мобильных устройствах и для серверов вэб. дома, когда я работаю с фото полноцветными в pmg меня не сильно напрягают 1-2 секунды при открытии файла.


      1. mynameco
        31.07.2023 11:18

        Игровые движки всегда используют нативные форматы текстур. А исходные, обычно, конвертят один раз, например, если png поменялась.


      1. voldemar_d
        31.07.2023 11:18

        Я тоже думаю, что в играх обычно какие-то собственные форматы используют.


    1. grigr
      31.07.2023 11:18

      Это больше похоже на рекламу wuf


      1. voldemar_d
        31.07.2023 11:18

        Почему бы и нет. Просто пытаюсь понять, какое может быть практическое применение этого декодера. Вот есть, например, довольно быстрый кодек PicVideo Motion-JPEG. Его можно применять для сохранения видео высокого качества, без междукадровой зависимости. Тут понятно - чем быстрее кодек, тем быстрее такое видео будет обрабатываться. А здесь какой профит?


    1. porto Автор
      31.07.2023 11:18

      Например, в компьютерном зрении, причем и при обучении нейронных сетей, и при их деплое. Дело в том, что на текущий момент не существует оптимальных реализаций PNG декодера на GPU (по очевидным причинам). В результате эта работа всегда происходит на CPU, превращая шаг декодирования входных изображений в bottleneck, так как в большинстве случаев и обучение, и инференс моделей происходит на GPU. Так, например, у NVIDIA есть специальная библиотека DALI, которая как раз и нужна, чтобы минимизировать накладные расходы на декодирования и препроцессинг изображений, и библиотека nvJPEG - реализация JPEG декодера для GPU.


      1. voldemar_d
        31.07.2023 11:18

        Так тем более - лучше уж картинку использовать в том формате, который GPU поддерживает. Зачем здесь PNG?

        Можно JPG сжимать так, чтобы артефакты были минимальными. Всё равно объем файла наверняка будет меньше, чем PNG.


        1. orekh
          31.07.2023 11:18
          +1

          Не надо JPG предлагать. Это компромиссное решение: более быстрое, но жертвующее качеством.

          Я видел как нейронку обучили на jpg, потом на webp с вроде бы хорошим качеством, а потом переделывали из-за генерируемых в некоторых случаях артефактов блочности.

          Сначала нужно рассмотреть более быстрые декодеры - тот что в статье, например, без каких либо дополнительных проблем даст скорость. А уже потом другие форматы, держа в уме к каким компромиссам они приведут и допустимы ли они.


          1. voldemar_d
            31.07.2023 11:18

            Значит, на плохих JPG обучали, просто взяв их готовые. Если сжимать JPG всего раза в 2-4, увидеть артефакты на них вряд ли возможно, разве что на очень специальных картинках.

            Если хочется более быстрый декодер, и без потерь качества, проще взять вообще BMP.


            1. orekh
              31.07.2023 11:18
              +1

              Упомяну тогда и QOI, который в 3-4 раза быстрее PNG декодируется.


    1. Pppnnn
      31.07.2023 11:18

      А в текстах зачем PNG? Или я чего то не понял?


      1. voldemar_d
        31.07.2023 11:18

        Я не писал про тексты.