Comments 34
За счёт чего вы выиграли почти 3 гигабайта в размере против протобафа? Хотелось бы посмотреть на код бенчмарка, уж слишком хорошо ваш график выглядит для того, чтобы быть правдой.
https://github.com/sijokun/PyByntic/tree/test_protobuf_vs_pybyntic/protobuf_vs_pybyntic
Модель юзеров взята из реального хайлоуд проекта, я лишь убрал и переименовал часть полей, чтобы уж не совсем было явно откуда.
PyByntic – 2300 байт на юзера
Protobuf – 3500 байт на юзера
Json – порядка 10+ тысяч байт на юзера
В тесте данные рандомизируются, от запуска к запуску могут быть немного разные цифры, если не лень можете прогнать с большим семплом. В статье был тест на базе этой же модели юзера, я честно забыл какой там был параметр о количестве итемов/тасков на юзера, помню, что в районе 100, вроде с такими параметрами данные +- как в статье.
За счет чего выигрыши:
1. Вообще не сохраняется структура данных, использован абсолютный возможный минимумом байтов. Это совершенно не подходит для долгого хранения данных, например на диске, так как в байтах не сохраняется даже версия модели (это в планах добавить), но для хранения кэша в Редисе это не является большой проблемок.
2. В теории PyByntic лучше подходит для сжатия, потому что одинаковые поля хранятся рядом.
Например:
class Tag:
description = "some long text"
id = 1
class Post:
text = "long text"
tags: list[Tag]
tags = []
for i in range(1000):
tags.append(Tag(description="very long description", id=i))
post = Post(text="test", tags=tags)
После сериализации структура становится колоннообразной:
post.text = "long text"
post.tags.description = ["some long text"] * 1000
post.tags.id = [1, 2, .... 1000]Затем сохраняются только сами компактные бинарные данные. Поскольку все повторяющиеся длинные описания расположены в памяти подряд, а не разбросаны среди объектов, алгоритмы сжатия могут работать гораздо эффективнее.
Не лень.
$ time python3 test_pybyntic.py
Average serialized size over 100000 samples: 2960.68 bytes
real 1m55.465s
user 1m55.448s
sys 0m0.012s
$ time python3 test_protobuf.py
Average serialized size over 100000 samples: 3495.20 bytes
real 1m13.383s
user 1m13.328s
sys 0m0.044s
https://github.com/sijokun/PyByntic/blob/b33ea3e242f87ed246a33754f20de3dc62efa8f8/protobuf_vs_pybyntic/models.py#L20 - размер неправильный (UInt16), в user.proto uint32 item_id = 1;.
DateTime32 потерял зону - было 2106-02-07 06:28:15, стало 2106-02-07 03:28:15+00:00.
Поскольку все повторяющиеся длинные описания расположены в памяти подряд, а не разбросаны среди объектов
Честно говоря, не очень понял вот эту строчку. В протобафе не видел каких-то там разбросов, объекты ровно в том порядке, в котором должны быть: https://protogen.marcgravell.com/decode. Похоже, что у вас выигрыш за счёт того, что протобаф всегда пишет тип данных и их длину перед ними, а вы только длину.
Данные для примера:
CMDEBxIJZmlyc3ROYW1lGghMYXN0TmFtZSDAxAcolse9KDDj3rpKOQAAAAAAQb9AQiEIoLcBEgVl
bGl0ZRjEICCa0gktz9X//zXP1f//Pc/V//9CIQigtwESBWVsaXRlGMQgIJrSCS3P1f//Nc/V//89
z9X//0IhCKC3ARIFZWxpdGUYxCAgmtIJLc/V//81z9X//z3P1f//SgV0YXNrMUoFdGFzazJKBXRh
c2szUggweDFhc2RnZ13P1f//YHs=
Pybyntic:
00000090: cfd5 ffff 0305 7461 736b 3105 7461 736b ......task1.task
000000a0: 3205 7461 736b 3308 3078 3161 7364 6767 2.task3.0x1asdgg
Protobuf:
00000090: cfd5 ffff 3dcf d5ff ff4a 0574 6173 6b31 ....=....J.task1
000000a0: 4a05 7461 736b 324a 0574 6173 6b33 5208 J.task2J.task3R.
На этих данных ваша сериализация выиграла 7 байт (184 против 191 после правки размера item_id на UInt32). Почему protobuf не делает так же?
Почему protobuf не делает так же?
Потому что он добавляет метаданные и можно исторические данные с предыдущей версией схемы успешно обработать
размер неправильный (
UInt16), в user.protouint32 item_id = 1;.
В Protobuf нет ничего ниже uint32: документация.
не очень понял вот эту строчку.

В протобаф весь нестед объект лежит подряд, вот каждый итем, сначала его ид, потом тип, потом остальные поля и так по кругу. Я же раскрываю в "колонки", будут сначала все айди нестед итемов подряд, потом все типы и т.д. Если, например, одно текстовое поле с одинаковым текстом "text" повторяется в каждом итеме, то в моем формате будет texttexttexttexttext, для алгоритмов компрессии это выгоднее. В бенчмарке мы справниваем без компрессии, поэтому к нему это не относилось, а просто комментарий в общем о формате.
DateTime32 потерял зону - было
2106-02-07 06:28:15, стало2106-02-07 03:28:15+00:00
Зоны мы не сохраняем, но планируется добавить опцию timezone-aware. Это можно через кастомный тип сделать. Для нас этой необходимости небыло, мы на бэкенде храним все даты в UTC.
"1970-01-01T00:00:01.000000"
DateTime32— это всего 32 бита
То есть вы потеряли микросекунды и получили проблему 2038 (или 2106) года?
В библиотеке есть DateTime64 – это основная суть библиотеки, возможность самому выбрать максимально точно сколько байт тебе нужно для твоих данных.
В моем случае я не думаю, что структура которая сегодня сохраняется на пару часов в редисе будет актуальна через сколько-то там десятков лет.
Типы DateTime и Date полностью позаимстованы из ClickHouse, со всеми их минусами и плюсами.
Присоединюсь, что то вы очень странное за протобаф написали. Может вы просто не умеете их готовить? Все ж таки протобафы пришли из си и для правильной работы с ним нужно понимать за память и типы данных. Правильная инициализация, в нужном порядке и тд.
Что то мне кажется что вы там насоздавали велосипедов, напрямую переведя бесконечное json-дерево в итерируемые вложенные протобафы, не сильно заморачиваясь типами, вот и получили что получили.
Погуглил за то что вы использовали - это просто по сути найтивная либа что все делает за вас. То есть действительно, вы просто криво использовали протобаф, иначе бы разница меж вашим текущим решением была б минимальная по памяти, а не такая огромная.
У протобафов есть свои проблемы, тут я не буду спорить (те же bool которые занимают байт) но прото остается до сих пор самым широко используемым мировым grpc стандартом для связи как на уровне сообщений (запросов) так и на уровне методов (собственно grpc)
А вы пробовали просто писать JSON в zip? Подозреваю, что выигрыш мог бы получиться сопоставимый с вашим решением.
А потом героическое решение проблем с производительностью?
Average size PyByntic: 2157.96 bytes
Average size PyByntic+gzip: 1178.82 bytes
Average size JSON+gzip: 2364.51 bytes
Сжатый JSON все еще хуже PyByntic, а если сжать PyByntic, то разница в два раза. Ну и CPU вы на gzip туда сюда будете заметно больше тратить, чем на запись и чтение байт.
Есть другие (быстрые) алгоритмы сжатия, которые для swap используются, например. Но в целом, согласен, что они тут не к месту.
Разница в 10%. И откуда такая уверенность, что gzip сильно нагружает cpu? Вы делали бенчмарки? Предполагаю, что гораздо дороже обходилась сериализация/десериализация JSON, но вы как-то с ней жили, судя по статье проблема именно в размере JSON, а не CPU (хотя реализация json в стандартной библиотеке не блещет производительностью)
Затраты CPU на компрессию зачастую компенсируются снижением времени на передачу данных (например content-encoding: gzip включен практически везде на крупных веб-ресурсах, видимо неспроста).
.
Основной минус ИМХО в том, что на выходе получается кусок непонятного бинаря, вместо самоаннотированных данных в JSON, которые потом можно прочитать чем угодно.
Ворчу, потому что сейчас как раз работаю над задачей чтения данных из легаси системы, где часть данных хранится в бинарниках с кастомной сериализацией (описание структур данных вендор не предоставил естественно )))
Так никто вроде не гарантирует, что в том же JSON не будет применена "оптимизация", где поля по одной букве и прочее.
Но даже в таком "оптимизированном" json'е есть шанс сориентироваться, хотя бы по содержимому полей. В бинарнике без знания структуры шансов практически нет.
По поводу "оптимизации" - сокращенные поля это еще не предел. Я, например, видел JSON внутри которого было поле в base64, в котором лежал xml сжатый zip ))). И это всё еще было лучше, чем бинарь.
О, сам решал похожую задачку по уменьшению данных для кэша в Редисе лет 10 назад (правда c#, а не Python), тогда сжатие данных съедало слишком много ЦПУ и плохо влияло на производительность, интересно как сейчас с этим обстоит дело?
Если уж опустились на уровень битов, то почему бы не сравнить с традиционными БД, которые могут жать временные ряды до нескольких битов на отметку?
Раскрою секрет: Все типы моей библиотеке взяты и полностью совместимы с нативным форматом ClickHouse.
Ради интереса можно даже бинарные даты от SELECT ... FORMAT Native ей распарсить (нужно оставить только сами данные без хедера).
Добавлю к отписавшимся выше: protobuf-ом пользоваться не сильно сложнее чем json-ном, один раз разобраться с простым синтаксисом для написания .proto a для кодогенерации у популярных либ обычно несложный api. Единственно что бесит это когда кастомные типы завернуты в Option<T> (пишу на Rust), и без понимания всей архитектуры может начаться холивар - вводить слой валидации либо размазывать обработку ошибок по логике приложения, etc.
Проблема в том, что приходится поддерживать схему в двух местах – Pydyntic с которым работаем внутри питона и одельно .proto схему + кодген по нему. Плюс PyByntic в том, что все поля задаются один раз в одном месте.
Для сложных систем, где много микросервисов протобаф конечно удобнее, можно передать схему другому разработчику и он сам в своем языке с ней разберетс. Цель моего проекта – эффективно кэшировать объекты в рамках одного сервиса.
Фактически всё сводится к сжатию данных. Тогда уж эффективнее будет прикрутить сюда своё сжатие со статическим словарём. Тогда все ключи уйдут в этот словарь и итоговые данные будут ещё меньше.
Люблю Habr. За инициативу в OSP, пусть даже наивную, могут легко в панамку напихать. Хотя, кмк, надо такие инициативы холить, люлеить и всячески поддерживать.
Я очень приветствую попытки в OSP, и рад, что очередной разработчик таки осмелился на кодовый эксгибиционизм :) , который может стать полезным, при определенных условиях. Потому - @sijokun все нормально ты делаешь. Просто учти замечания из комментариев, там и правда по делу есть:
Потеря TZ или микросекунд это прям очень не хорошо: вот я очаровался идеей проекта PyБантик, применяю его у себя... и мой клиент начал резко по пасифику логиниться. Но данные меняться не должны были, и таких подстав я не жду от сторонней библиотеки.
Со стороны внутрянки кода, например, непонятно, зачем используется "function composition" в def dump() и почему методы def is_buffer_readable; def _is_buffer_empty; a не property, и зачем название дублирует имя класса Buffer.is_buffer_readable(); Buffer._is_buffer_empty(), когда Buffer.readable, Buffer.empty
Успехов в дальнейшем развитии проекта!
Спасибо!
Библиотека пока в ранней версии, поэтому 0.1.3, а не 1.0.0. Изначально она была сделана ASAP за пару ночей для срочного решения задачи, я попробовал привести до публикации ее в более приличный вид, но еще не все исправил. Комментарии читаю, записываю на листочек и буду делать – так же приветствую пулл реквесты.
https://github.com/sijokun/PyByntic/releases/tag/v0.1.4
типы для datetime с таймзонами добавил.
Есть подозрение, что поддержка этого велосипеда (в ресурсах - деньгах и времени) обойдется в сопоставимое с 7 раз больше того же protobuf.
Первая версия кода была сделана в феврале, после этого этот же код был партирован в другой наш проект. Пока полет нормальный и все это время жили на том, что было написано тогда в режиме ASAP. Я не писал статью сразу – велосипед как вино, ему нужно настоятся и оправдать себя.
Многих фичей в изначальной версии не было, например Nullable, они нам были не нужны. Для публичной версии я добавил побольше типов, скоро планирую еще Variant добавить, чтобы уж совсем универсально стало.
Когда-то похожую проблему решил использованием DER (Distinguished Encoding Rules, ITU-T X.690), судя по содержимому проекта на GitHub автор изобрёл довольно похожий формат.
Для себя подобную проблему решал с помощью bsdf.
Позволяет элегантно сериализовать хоть чёрта на куличках и без танцев с бубном.
Проект далеко не новый, и проверен временем. Портирован на Python и Js.
Protobuf тоже хорош, но плохо подходит для нетипизированных языков с динамическими структурами. Придётся постоянно согласовывать и пересобирать сериализатор, и потом ловить ошибки в реал-тайме, когда что-то забыл.
Как мы перестали хранить Pydantic в JSON и в 7 раз сократили расход памяти в Redis