Pull to refresh

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.proto uint32 item_id = 1;.

В Protobuf нет ничего ниже uint32: документация.

не очень понял вот эту строчку.

Protobuf юзера
Protobuf юзера

В протобаф весь нестед объект лежит подряд, вот каждый итем, сначала его ид, потом тип, потом остальные поля и так по кругу. Я же раскрываю в "колонки", будут сначала все айди нестед итемов подряд, потом все типы и т.д. Если, например, одно текстовое поле с одинаковым текстом "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 ей распарсить (нужно оставить только сами данные без хедера).

Только типы? А сам 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 за пару ночей для срочного решения задачи, я попробовал привести до публикации ее в более приличный вид, но еще не все исправил. Комментарии читаю, записываю на листочек и буду делать – так же приветствую пулл реквесты.

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

Первая версия кода была сделана в феврале, после этого этот же код был партирован в другой наш проект. Пока полет нормальный и все это время жили на том, что было написано тогда в режиме ASAP. Я не писал статью сразу – велосипед как вино, ему нужно настоятся и оправдать себя.

Многих фичей в изначальной версии не было, например Nullable, они нам были не нужны. Для публичной версии я добавил побольше типов, скоро планирую еще Variant добавить, чтобы уж совсем универсально стало.

С другой стороны - да, кто ищет - тот обрящет.
Дай бог, может и взлетит.

Когда-то похожую проблему решил использованием DER (Distinguished Encoding Rules, ITU-T X.690), судя по содержимому проекта на GitHub автор изобрёл довольно похожий формат.

Для себя подобную проблему решал с помощью bsdf.

https://bsdf.readthedocs.io/

Позволяет элегантно сериализовать хоть чёрта на куличках и без танцев с бубном.

Проект далеко не новый, и проверен временем. Портирован на Python и Js.

Protobuf тоже хорош, но плохо подходит для нетипизированных языков с динамическими структурами. Придётся постоянно согласовывать и пересобирать сериализатор, и потом ловить ошибки в реал-тайме, когда что-то забыл.

Было бы неплохо, если бы @sijokun добавил в бенчмарк и эту либу :)

Sign up to leave a comment.

Articles