Вы когда-нибудь задумывались, что скрывается за пакетом npm?
По сути, это не что иное, как сжатый gzip'ом архив. При разработке программного обеспечения исходный код почти всегда поставляется в виде файлов .tar.gz или .tgz. Сжатие gzip поддерживается каждым HTTP-сервером и веб-браузером. Но вот что самое интересное: gzip начинает устаревать, уступая место новым алгоритмам сжатия вроде Brotli и ZStandard. Теперь представьте себе мир, в котором npm использует один из этих новых алгоритмов. В этом посте я углублюсь в вопросы сжатия и исследую возможности модернизации стратегии сжатия npm.
Что насчёт конкуренции?
Двумя основными игроками в этой сфере являются Brotli и ZStandard (сокращённо zstd). Brotli был выпущен Google в 2013 году, а zstd — запрещённой соцсетью на букву F в 2016 году. С тех пор они были стандартизированы в RFC 7932 и RFC 8478 соответственно и получили широкое распространение во всей индустрии программного обеспечения.
На самом деле именно объявление Arch Linux о том, что они собираются сжимать свои пакеты с помощью zstd по умолчанию, заставило меня призадуматься. Arch Linux ни в коем случае не был первым и тем более единственным проектом. Но чтобы выяснить, имеет ли это смысл для экосистемы Node, нужно провести несколько тестов. А это значит, что нужно пробиться через tar.
Сравнительный анализ, часть 1
Я собираюсь запустить tar и посмотреть, какие параметры для сравнения я могу получить, переключаясь gzip, Brotli и zstd. Я протестирую сам пакет npm, так как он довольно популярен, в среднем его загружают более 4 миллионов раз в неделю, а ещё он большой: около 11 МБ в распакованном виде.
$ curl --remote-name https://registry.npmjs.org/npm/-/npm-9.7.1.tgz
$ ls -l --human npm-9.7.1.tgz
-rw-r--r-- 1 jamie users 2.6M Jun 16 20:30 npm-9.7.1.tgz
$ tar --extract --gzip --file npm-9.7.1.tgz
$ du --summarize --human --apparent-size package
11M package
gzip сразу даёт хорошие результаты, сжимая с 11 МБ до 2,6 МБ со степенью сжатия около 0,24. Но на что способны конкуренты? На данный момент я буду придерживаться параметров по умолчанию:
$ brotli --version
brotli 1.0.9
$ tar --use-compress-program brotli --create --file npm-9.7.1.tar.br package
$ zstd --version
*** Zstandard CLI (64-bit) v1.5.5, by Yann Collet ***
$ tar --use-compress-program zstd --create --file npm-9.7.1.tar.zst package
$ ls -l --human npm-9.7.1.tgz npm-9.7.1.tar.br npm-9.7.1.tar.zst
-rw-r--r-- 1 jamie users 1.6M Jun 16 21:14 npm-9.7.1.tar.br
-rw-r--r-- 1 jamie users 2.3M Jun 16 21:14 npm-9.7.1.tar.zst
-rw-r--r-- 1 jamie users 2.6M Jun 16 20:30 npm-9.7.1.tgz
Ух ты! Без конфигурирования и Brotli, и zstd обходят gzip, но Brotli явный победитель. Он обеспечивает степень сжатия 0,15, в то время как у zstd только 0,21. В реальном выражении это означает экономию около 1 МБ. Вроде пустяковая разница, но при 4 миллионах загрузок в неделю это даёт экономию 4 ТБ в пропускной способности в неделю.
Сравнительный анализ, часть 2: Электрическое бугалу
Степень сжатия — лишь половина дела. А на самом деле даже треть, но скорость сжатия не имеет особого значения. Сжатие пакета происходит только один раз, когда пакет публикуется, но распаковка происходит каждый раз при запуске npm install. Таким образом, именно экономия времени на распаковке пакетов повышает скорость установки или сборки.
Чтобы проверить это, я собираюсь использовать Hyperfine — инструмент для сравнительного анализа командной строки. Распаковка каждого из пакетов, которые я ранее создавал 100 раз, должна дать мне хорошее представление об относительной скорости распаковки.
$ hyperfine --runs 100 --export-markdown hyperfine.md \
'tar --use-compress-program brotli --extract --file npm-9.7.1.tar.br --overwrite' \
'tar --use-compress-program zstd --extract --file npm-9.7.1.tar.zst --overwrite' \
'tar --use-compress-program gzip --extract --file npm-9.7.1.tgz --overwrite'
Команда | Средний [мс] | Минимальный [мс] | Максимальный [мс] | Относительный |
tar –use-compress-program brotli –extract –file npm-9.7.1.tar.br –overwrite | 51,6 ± 3,0 | 47,9 | 57,3 | 1,31 ± 0,12 |
tar –use-compress-program zstd –extract –file npm-9.7.1.tar.zst –overwrite | 39,5 ± 3,0 | 33,5 | 51,8 | 1.00 |
tar –use-compress-program gzip –extract –file npm-9.7.1.tgz –overwrite | 47,0 ± 1,7 | 44,0 | 54,9 | 1,19 ± 0,10 |
На этот раз вперёд вырывается zstd, за ним следуют gzip и Brotli. Это логично, поскольку «сжатие в реальном времени» — одна из главных функций, рекламируемых в документации zstd. Хотя Brotli на 31% медленнее по сравнению с zstd, в реальном выражении это всего лишь 12 мс. И по сравнению с gzip он медленнее всего на 5 мс.
На практике это означает, что вам понадобится соединение со скоростью более 1 Гбит/с, чтобы компенсировать потерю 5 мс при распаковке по сравнению с 1 МБ, который будет сэкономлен за счёт размера пакета.
Сравнительный анализ, часть 3. На этот раз все серьёзно
До сих пор я брал настройки Brotli и zstd по умолчанию, но у обоих есть множество крутилок и регуляторов, которые вы можете настроить, чтобы изменить степень сжатия, а также скорость сжатия или распаковки. Здесь мне помог отраслевой стандарт lzbench. Он может протестировать каждый архиватор и в конце выдать красивую таблицу со всеми данными.
Здесь я должен вас предостеречь от нескольких вещей. Во‑первых, lzbench не может сжимать весь каталог, например tar, поэтому для этого теста я решил использовать lib/npm.js. Во‑вторых, в состав lzbench не входит инструмент gzip. Вместо этого он использует zlib, базовую библиотеку gzip. И, в‑третьих, версии каждого архиватора не совсем актуальны. Последняя актуальная версия zstd — 1.5.5, выпущенная 4 апреля 2023 г., тогда как lzbench использует версию 1.4.5, выпущенную 22 мая 2020 г. Последняя версия Brotli — 1.0.9, выпущенная 27 августа 2020 г., тогда как lzbench использует версию, выпущенную 1 октября 2019 года.
$ lzbench -o1 -ezlib/zstd/brotli package/lib/npm.js
Архиватор | Сжатие | Распаковка | Размер файла | Соотношение | Имя файла |
memcpy | 117330 MB/s | 121675 MB/s | 13141 | 100.00 | package/lib/npm.js |
zlib 1.2.11 -1 | 332 MB/s | 950 MB/s | 5000 | 38.05 | package/lib/npm.js |
zlib 1.2.11 -2 | 382 MB/s | 965 MB/s | 4876 | 37.11 | package/lib/npm.js |
zlib 1.2.11 -3 | 304 MB/s | 986 MB/s | 4774 | 36.33 | package/lib/npm.js |
zlib 1.2.11 -4 | 270 MB/s | 1009 MB/s | 4539 | 34.54 | package/lib/npm.js |
zlib 1.2.11 -5 | 204 MB/s | 982 MB/s | 4452 | 33.88 | package/lib/npm.js |
zlib 1.2.11 -6 | 150 MB/s | 983 MB/s | 4425 | 33.67 | package/lib/npm.js |
zlib 1.2.11 -7 | 125 MB/s | 983 MB/s | 4421 | 33.64 | package/lib/npm.js |
zlib 1.2.11 -8 | 92 MB/s | 989 MB/s | 4419 | 33.63 | package/lib/npm.js |
zlib 1.2.11 -9 | 95 MB/s | 986 MB/s | 4419 | 33.63 | package/lib/npm.js |
zstd 1.4.5 -1 | 594 MB/s | 1619 MB/s | 4793 | 36.47 | package/lib/npm.js |
zstd 1.4.5 -2 | 556 MB/s | 1423 MB/s | 4881 | 37.14 | package/lib/npm.js |
zstd 1.4.5 -3 | 510 MB/s | 1560 MB/s | 4686 | 35.66 | package/lib/npm.js |
zstd 1.4.5 -4 | 338 MB/s | 1584 MB/s | 4510 | 34.32 | package/lib/npm.js |
zstd 1.4.5 -5 | 275 MB/s | 1647 MB/s | 4455 | 33.90 | package/lib/npm.js |
zstd 1.4.5 -6 | 216 MB/s | 1656 MB/s | 4439 | 33.78 | package/lib/npm.js |
zstd 1.4.5 -7 | 140 MB/s | 1665 MB/s | 4422 | 33.65 | package/lib/npm.js |
zstd 1.4.5 -8 | 101 MB/s | 1714 MB/s | 4416 | 33.60 | package/lib/npm.js |
zstd 1.4.5 -9 | 97 MB/s | 1673 MB/s | 4410 | 33.56 | package/lib/npm.js |
zstd 1.4.5 -10 | 97 MB/s | 1672 MB/s | 4410 | 33.56 | package/lib/npm.js |
zstd 1.4.5 -11 | 37 MB/s | 1665 MB/s | 4371 | 33.26 | package/lib/npm.js |
zstd 1.4.5 -12 | 27 MB/s | 1637 MB/s | 4336 | 33.00 | package/lib/npm.js |
zstd 1.4.5 -13 | 20 MB/s | 1601 MB/s | 4310 | 32.80 | package/lib/npm.js |
zstd 1.4.5 -14 | 18 MB/s | 1582 MB/s | 4309 | 32.79 | package/lib/npm.js |
zstd 1.4.5 -15 | 18 MB/s | 1582 MB/s | 4309 | 32.79 | package/lib/npm.js |
zstd 1.4.5 -16 | 9.03 MB/s | 1556 MB/s | 4305 | 32.76 | package/lib/npm.js |
zstd 1.4.5 -17 | 8.86 MB/s | 1559 MB/s | 4305 | 32.76 | package/lib/npm.js |
zstd 1.4.5 -18 | 8.86 MB/s | 1558 MB/s | 4305 | 32.76 | package/lib/npm.js |
zstd 1.4.5 -19 | 8.86 MB/s | 1559 MB/s | 4305 | 32.76 | package/lib/npm.js |
zstd 1.4.5 -20 | 8.85 MB/s | 1558 MB/s | 4305 | 32.76 | package/lib/npm.js |
zstd 1.4.5 -21 | 8.86 MB/s | 1559 MB/s | 4305 | 32.76 | package/lib/npm.js |
zstd 1.4.5 -22 | 8.86 MB/s | 1589 MB/s | 4305 | 32.76 | package/lib/npm.js |
brotli 2019-10-01 -0 | 604 MB/s | 813 MB/s | 5182 | 39.43 | package/lib/npm.js |
brotli 2019-10-01 -1 | 445 MB/s | 775 MB/s | 5148 | 39.18 | package/lib/npm.js |
brotli 2019-10-01 -2 | 347 MB/s | 947 MB/s | 4727 | 35.97 | package/lib/npm.js |
brotli 2019-10-01 -3 | 266 MB/s | 936 MB/s | 4645 | 35.35 | package/lib/npm.js |
brotli 2019-10-01 -4 | 164 MB/s | 930 MB/s | 4559 | 34.69 | package/lib/npm.js |
brotli 2019-10-01 -5 | 135 MB/s | 944 MB/s | 4276 | 32.54 | package/lib/npm.js |
brotli 2019-10-01 -6 | 129 MB/s | 949 MB/s | 4257 | 32.39 | package/lib/npm.js |
brotli 2019-10-01 -7 | 103 MB/s | 953 MB/s | 4244 | 32.30 | package/lib/npm.js |
brotli 2019-10-01 -8 | 84 MB/s | 919 MB/s | 4240 | 32.27 | package/lib/npm.js |
brotli 2019-10-01 -9 | 7.74 MB/s | 958 MB/s | 4237 | 32.24 | package/lib/npm.js |
brotli 2019-10-01 -10 | 4.35 MB/s | 690 MB/s | 3916 | 29.80 | package/lib/npm.js |
brotli 2019-10-01 -11 | 1.59 MB/s | 761 MB/s | 3808 | 28.98 | package/lib/npm.js |
Это в значительной степени подтверждает то, что я показал ранее. zstd способен обеспечить более высокую скорость распаковки, чем gzip или Brotli, а также немного превосходит gzip по степени сжатия. Brotli, с другой стороны, имеет сопоставимую скорость распаковки и сопоставимую степень сжатия с gzip на низких уровнях качества, но на уровнях 10 и 11 он способен дать более высокую степень сжатия, чем gzip и zstd.
Всё уже было в Симпсонах
Теперь, когда я закончил тестирование производительности, нужно вернуться назад и взглянуть на мою первоначальную идею о замене gzip на другой стандарт сжатия npm. Как выяснилось, у Эвана Хана в 2022 году возникла аналогичная идея, которую он изложил на собрании npm RFC. Он предложил использовать Zopfli, обратно совместимую библиотеку сжатия gzip и старшего (и более крутого