Привет, Хабр!

В прошлой статье мы, команда разработки платформы для А/Б экспериментов в компании Okko, начали рассказывать историю создания одного из компонентов этой платформы— сервис сплитования трафика.

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

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

Предыстория

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

У каждого эксперимента есть свой сегмент пользователей, определяющий, кто должен получить тестовый опыт. Сервис должен уметь одновременно вести 100 экспериментов, а значит, будет не менее 100 сегментов. Однако, зная жизненный цикл экспериментов, описанный в статье, можно смело утверждать, что вычислять сегменты потребуется и после официального завершения эксперимента. Надо быть готовым на кратное увеличение сегментов.

На клиенте стоит таймаут всего в 10 мс. Значит, сервис без учёта сети и прочих инфраструктурных задержек должен отвечать быстрее. Ставим себе условный таргет в 5 мс на расчёт всех сегментов. Цель есть.

Собственно, а что такое сегмент? Сегменты пользователей бывают абсолютно разными. Сегмент — это алгебрологическое выражение с использованием информации о пользователе и его устройстве.

Примеры некоторых сегментов:

  • мужчина

  • мужчина старше 18

  • мужчина старше 18 и с активной подпиской

  • мужчина старше 18 и с активной подпиской на платформе Smart TV

  • мужчина старше 18 и с активной подпиской на платформе Smart TV c версией клиента 42 и более

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

Инструмент был найден довольно быстро — json-logic. Это те же выражения, но в формате json, а json’ы писать в базу и кидать между сервисами мы все умеем :)

Пример json-logic выражения для сегмента «возраст 18 и старше и с устройства на iOS»:

{
  "and": [
    {">=": [{"var": "age"}, 18]},
    {"==": [{"var":  "client_os"}, "IOS"]}
  ]
}

GitHub solution

За первую версию вычислялки сегментов взяли одну из реализаций json-logic, найденную в GitHub. Всё завелось, сегменты считаются, сервис работает. Надо оценить качество работы — за сколько отвечает сервис.

12 мс — неприемлемо много. Сразу же стали искать ботлнек.

Как видно из флеймграфа, почти 70% времени уходит только на расчёт сегментов, а значит, надо ускоряться в этом месте

Локальный замер показал, что время расчета сегментов очень быстро растёт с ростом количества сегментов. Уже при каких-то 100 сегментах расчёт занимает 5 мс.

Ленивые вычисления

Чтобы легко и удобно работать с json в других процессах платформы, было решено зафиксировать структуру построения сегментов таким образом:

segment ::= user_segments AND device_segments
user_segments ::= user_segment_1 AND user_segment_2 AND … AND user_segment_N
device_segments ::= device_segment_1 OR device_segment_2 OR … OR device_segment_N,
  • user_segment_i — произвольный сегмент о пользователе, проверяющий наличие подписки, дату регистрации, пол, возраст  и т.п.

  • device_segment_i — произвольный сегмент об устройстве, проверяющий платформу, вендора, версию и т.п.

Сегменты часто проводятся не над одной платформой пользователей. Бывает сразу  Android и iOS, и всякие телевизоры. Когда надо проверить несколько раз, какая у пользователя платформа, по правилам алгебры логики достаточно найти только первое истинное утверждение, а значит, остальные можно не считать.

Такая оптимизация известна нашему сообществу как «Ленивые вычисления».

И по ожиданиям, при равномерном распределении пользователей по платформам и типам устройств, оптимизация должна была кратно уменьшить количество операций в json-logic. Что и произошло:

Статические оптимизации выражений

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

Одна из таких закономерностей: иногда встречались операции, у которых один или несколько операндов не зависели от информации о пользователе и его устройстве. 

Например, когда не важен сегмент о пользователе, но важен сегмент об устройстве. Тут два пути: ломать структуру выражения и придумывать, как адаптировать другие компоненты платформы, либо искать пути упрощения выражения при исполнении.

Так появился этап статических оптимизаций, при котором все выражения сегментов максимально упрощались, а затем можно было вычислять «в бою» уже оптимизированные выражения.

В список оптимизаций попали:

  • предрассчитывать ветви выражения, которые не зависят от информации о пользователе;

  • убирать недостижимые ветви путем оптимизации логических операций, когда результат может быть однозначно определен заранее.

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

Динамические оптимизации выражения

Пока заводились сегменты, заметили другую интересную особенность — часть экспериментов проводятся над одними и теми же группами пользователей. Получается, когда приходит запрос, надо вычислить 100 сегментов, среди которых несколько раз проверить, что пользователь с подпиской, и несколько раз проверить, что версия его телевизора не ниже 42.

И тут появилась классическая идея оптимизации — добавить кеш.

Звучит просто: появляется условная хеш-таблица, где ключом будет выражение, а значением результат вычислений в рамках текущего запроса.

Предположим пользователь и выражение сегмента такие:

user = {"platform": "android", "version": "42.1.10", "sex": "male", "subscription": True}
expression = {
    "and": [
        {"or": [
            {"==": [{"var": "platform"}, "android"]},
            {"compareVersion": [{"var": "version"}, "42.0"]}
        ]},
	    {"and": [...]},
    ]
}

Тогда кеш должен получиться таким:

{"==": [{"var": "platform"}, "android"]} => True
{"compareVersion": [{"var": "version"}, "42.0"]} => True
{"or": [{"==": [{"var": "platform"}, "android"]},{"compareVersion": [{"var": "version"}, "42.0"]}]} => True
...

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

Конечно, можно  написать свой сабкласс дикта с определенным хеш-методом, ведь экспрешены всё равно не меняются по ходу работы сервиса. Однако появился вариант приятнее в реализации и поддержке.

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

Преобразование выглядит примерно так:

{"+": [42, 123]} => ("+", (42, 123))

Занимательно, что такое преобразование тоже дало ускорение. Выходит, что итерироваться по тюплу оказывается быстрее, чем по дикту. Мелочь, а принесла ускорение.

Когда выражение упаковано в тюпл, можно реализовать и кеширование.

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

После некоторых исследований поняли, почему реальность не совпала с ожиданиями.

Оказалось, запись и проверка в кеше оказывались дольше, чем просто вычислить выражение. Ускорение получалось только в искусственном кейсе, когда части выражения дублировались неестественно много. А поскольку реальность не искусственный кейс, от изменений в таком виде отказались.

Долгим в работе с кешем оказалось вычисление хеша от выражений. Теоретически можно и его как-то вычислить заранее один раз, ведь выражения не меняются.

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

Компиляция Python

Статические оптимизации исчерпали себя, с динамическими не особо получилось. Решили прибегнуть к тёмной магии, о которой принято говорить только на собесах, чтобы блеснуть познаниями,  — компиляция Python-кода в машинные инструкции.

Немного полистав интернет, нашли три основных кандидата.

Mypyc

В кругах разработчиков на Python есть популярный анализатор кода Mypy.

Уникальность в том, что он проверяет аннотации в коде и подсвечивает несостыковки.

Мы в Okko очень любим его использовать, наш код полностью аннотирован, везде указаны типы с точностью до объектов, вложенных в дженерики.

Однако мало кто знает, что у Mypy есть подпроект — Mypyc. Это транслятор строго типизированного кода в Си, с возможностью потом его компилировать в динамические библиотеки и бесшовно линковать к Python.

По заявкам авторов проекта можно получить ускорение x4 на голом коде и x10 на коде, специально оптимизированном под транслятор. Интерфейс использования очень прост, а дополнительных изменений в Python-коде не нужно.

Сложилось ощущение, что достаточно выполнить в проекте mypyc app/utils/json_logic и всё заработает быстрее как минимум в 4 раза.

Но реальность оказалась не такой сказочной:  вместо кратного ускорения увидели кратное замедление!

Как так-то?! Звучит как бред.

Потратив немало времени и литры кофе, нашли причину.

Чтобы рассказать, как так вышло, нужно еще немного рассказать вам о Mypyc.

Транслируя код в Си, никто не обещает, что int в Python будет переложен в int32 или int64, какой есть в Си, и по аналогии float, str и bool (речь о сложных объектах пока не идёт).

В Mypyc существует собственная реализация стандартных типов данных Python, которые работают быстрее будут использованы, если транслятор однозначно определит, что ими можно пользоваться

Все эти типы также поддерживают стандартные операции арифметики и сравнения: числа можно складывать с числами, а строки со строками. 

Например, такая вот функция, которая складывает два целых числа:

Немного кода

После компиляции будет выглядеть вот так:

Немного кода

CPyTagged — представление целых чисел, о котором написано в документации к Мypyc

Наблюдательный читатель заметит, что CPython оперирует PyObject, а эта функция отдает CPyTagged. А значит, где-то в коде есть конвертация в PyObject.

И она действительно есть. Вот она:

Много кода

В ней происходят проверки, что, вызывая из python функцию, в неё передаются именно целые числа, а не что-то иное.

Для отлаженного и покрытого тестами кода эти действия кажутся лишними. Но если вычисления в целевой функции будут значительно быстрее, то накладные расходы на проверку типов могут быть нивелированы. 

А что будет, когда заявляется, что функция может сложить две переменные, которые могут быть либо целыми числами, либо вещественными?

В такой ситуации Mypyc не знает на этапе компиляции, какой выбрать тип. Он сможет динамически выбирать? Давайте исследовать!

Оригинальная функция:

Много кода
Оригинальная функция
Оригинальная функция

Из неё получаются две функции на Си:

Очень много кода
функция проверки входных параметров
функция проверки входных параметров
функция с самой логикой
функция с самой логикой

В них всё так же:  проверка входных параметров на соответствие заявленным типам, затем вызов функции PyNumber_Add, которая, вероятно, и складывает числа. И опять проверка возвращаемого значения на соответствие заявленным типам.

Вроде также, но что это за функция? Первая строка в гугле и переходим к документации к CPython!

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

Потому на каждую арифметическую или логическую операцию в json-logic идёт бонусом валидация. Это и производит такую значительную задержку.

Второй раз за проект натыкаемся на универсальную либу, которая ввиду своей универсальности нас только тормозит.

Cython

Второй популярный инструмент для ускорения python кода — Cython. Что это такое и как его использовать довольно подробно описано в других статьях. Скажу только, что он тоже умеет компилировать нативный код, а также имеет свой собственный синтаксис, переписав на который нативный код, можно получить еще большее ускорение.

Естественно, первым шагом было попробовать скормить компилятору код «как есть», надеясь, что он нас правильно поймет, и получится хорошее ускорение.

Но нет, такой вариант не прокатил, компилятор не смог его скомпилировать.

Вторым этапом стала разбивка кода на модули по несколько функций-операторов и частичная компиляция их. Тут уже какой-то успех появился, получилось скомпилировать операторы. Немного  и не самые используемые операторы, которые к работают только с одним типом данных.

Третий этап — попытка что-то переписать на синтаксис Cython. С этим инструментом пришлось взаимодействовать впервые, поэтому попытки хоть как-то ускорить работу операторов обламывались на невозможность описать полиморфный код на таком языке. Либо не получалось добиться ускорения больше, чем получили на втором этапе.

Четвертый этап. Пытаемся собрать основную функцию библиотеки json-logic, которая производит вызов операторов и рекурсивно вычисляет подвыражения. К собственном удивлению, это получилось. Функция скомпилировалась и стала работать быстрее. Правда, всего на 10-15%.

Небольшое, но ускорение. Однако! Если решиться его использовать в проекте, значит, заносим зависимость от компилятора, усложняем процесс сборки, а часть проекта становится вообще на другом языке. Учитывая это, решили пока повременить с этим инструментом.

Pypy

Помимо привычного нам CPython существуют и другие интерпретаторы языка, которые чем-то лучше и чем-то хуже CPython. Один из таких интерпретаторов — PyPy. Его уникальная особенность в обладании  JIT-компилятором, то есть во время работы сервиса, самые используемые места компилируются из байт-кода в машинные инструкции.

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

Тестовые замеры библиотеки json-logic показали, что PyPy ускоряет код. На некоторых примерах до x2. Теоретически можно использовать РуРу  как решение проблемы скорости, однако есть нюанс!

Нельзя использовать РуРу только для одной какой-то библиотеки, как Cython или Mypyc. Если переезжать на РуРу, то всем проектом. А это значит, необходимо тщательное тестирование остальных этапов сплитования и других процессов, проверка на совместимость с РуРу используемых библиотек.  Главное, появляется риск, что РуРу отличается от Cython в каких-то узких вопросах, например, в работе с GIL или GC, что может выстрелить в ногу.

GIL и GC нам и так мешали в проекте. С ними тоже связано много исследований, но об этом будет в следующей статье. Ставьте лайк, подписку, колокольчик, чтоб не пропустить статью.

Было решено использовать РуРу, только если не будет менее рискованных способов ускориться.

Компиляция json

Как часто вам приходится думать о Римской империи о деревьях? Примечательно, что json выражения имеют древовидную структуру. То есть в узле дерева находится операция, а листья — примитивы.

По определению это abstract syntax tree или AST, а построение ast дерева — один из этапов, которые проходит Python-код при запуске программы.

Тогда для любого json-выражения должно быть Python-выражение, которое всегда имеет тот же результат вычислений. Но Python-выражение должно быть быстрее интерпретации json-logic.

Оценим это на примере.
Экспериментальный сегмент в виде json:

Он же записанный в виде Python-выражения:

Замеряем время исполнения:

Результат невероятный — в 30 раз быстрее!

Как будто то, что искали. Если найти способ автоматизированным путём переводить любое json-выражения в Python байт-код, его можно будет использовать без риска для остального проекта, как было бы с PyPy или Cython.

И такой способ был найден. Команда реализовала транслятор, который превращал json-logic выражение в Python callable-объект, который можно вызывать в коде как обычную функцию.

Много кода
В рантайме создается и компилируется искусственный модуль с целевой функцией внутри
В рантайме создается и компилируется искусственный модуль с целевой функцией внутри

Сравнение AST-компилятора с предыдущими версиями json-logic:

Невероятное ускорение! И это не предел, его можно сочетать с предыдущими идеями, хоть они и не дают такого же фееричного эффекта.

Цель в 5 мс и менее для 100 сегментов таким способом была достигнута.

Получается, самый быстрый способ вычислять выражения — переписать их на наш любимый Python. Чтобы добиться невероятной скорости в этой задаче, нет необходимости ни в магических компиляторах Python в С, ни в оптимизациях библиотеки вычисления json-выражений. 

PS: В ходе работы над проектом получилось де факто написать собственную реализацию библиотеки json-logic, заточенную под максимальную производительность. Если у вас есть потребность, напишите, и, если желающих будет много, мы выложим нашу реализацию в Open-Source

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


  1. sgjurano
    15.07.2024 13:55
    +1

    Любопытно :)

    Кажется, что раз уж вы всё равно взялись свою либу пилить, то оптимальный вариант — это либа на компилируемом языке, по аналогии с каким-нибудь numpy, только помельче.


    1. komissar_off_andrey Автор
      15.07.2024 13:55
      +5

      Думаю, в этом нет большой необходимости.

      • json-logic с GitHub по сути самостоятельная виртуальная машина, которая исполняет выражения как есть. Скорость расчета сегментов зависит прямо и от кода в либе, и от скорости Python

      • реализация, которую мы сделали в команде - не виртуальная машина, а скорее компилятор для Python VM. В этом случае на время расчета сегмента может повлиять только скорость самого Python

      Однако можно попробовать из этой схемы убрать Python в целом, оставить только вызов чего-то написанного на другом языке. В статье не говорится, но подобные исследования мы проводили до того, как начать пилить свой велосипед :)

      В частности сравнивали с либой на rust, и наше решение оказывается на порядки быстрее.

      На картинках ниже сравнение:

      выражение и данные о пользователе для тестов
      выражение и данные о пользователе для тестов
      сравнение работы двух реализаций json-logic
      сравнение работы двух реализаций json-logic


      1. sgjurano
        15.07.2024 13:55

        Интересный результат, а почему так, не разбирались? 80 микросекунд против 300 наносекунд — это не похоже на время исполнения.


        1. komissar_off_andrey Автор
          15.07.2024 13:55

          Исследование проводили. Была гипотеза, что требуется много времени на конвертацию объектов для передачи из python в rust и обратно. (Это, кстати, и правда занимает существенное время)

          Если json-logic на rust быстрее python, то при очень больших выражениях, требующих много вычислений, rust должен начать обгонять python.

          Для проверки этой гипотезы, провели замеры времени для выражений разной "жирности".

          График отношения времени работы варианта python к варианту rust
          График отношения времени работы варианта python к варианту rust

          На небольших выражениях разница в скорости небольшая, в 1k раз, а на больших выражениях уже в 2k раз и продолжает расти.

          Вывод, к которому мы пришли, что операторы для выполнения json-logic, реализованные на rust, сами по себе требуют времени больше, чем аналогичные операции в CPython.


          1. sgjurano
            15.07.2024 13:55

            Почитал по диагонали код rust-либы — похоже основная причина выигрыша в том, что код на python и код на rust делают разные вещи.

            Rust на каждый вызов полноценно разбирает json, а Python результаты разбора кэширует и дальше просто выполняет операции :)

            Хорошая оптимизация!


  1. Fardeadok
    15.07.2024 13:55
    +4

    Вместо json можно это выражение сразу записывать в виде кода например js, lua или питона или любого скриптового языка. Недавно кодил нечто похожее, фильтры для json типа select(fields).where(condition)and(condition)filter и тд Получилось 450 000 json строк/сек или 450 строк/мс


    1. YeeaaahMan
      15.07.2024 13:55

      И запускать через eval?

      Пожалуйста, расскажите подробнее про реализацию.


  1. Zexyl
    15.07.2024 13:55
    +2

    Вставлять код картинками, в 2024 году? 0_о


  1. s13nder
    15.07.2024 13:55

    Любопытно, только тюпл глаз режет, есть же название на русском - кортеж


    1. VADemon
      15.07.2024 13:55

      Порежу еще: и произноситься должно как "тапл". Это по-немецки "тупель".


      1. PiaFraus
        15.07.2024 13:55

        Допустимо произносить и как "тупл", и как "тапл".
        Гвидо вон шутил, что зависит от дня недели: https://twitter.com/gvanrossum/status/86144775731941376


  1. ptr128
    15.07.2024 13:55
    +2

    Может я что-то не понял, но почему нельзя было загнать всё это, например, в ClickHouse, и вытаскивать оттуда обычными SQL запросами? Он из сотен миллионов записей весьма сложные выборки за единицы миллисекунд у нас выдает.


    1. hackteck
      15.07.2024 13:55

      После прочтения вступления возникли точно такие же мысли, но вместо ClickHouse подумал про MongoDB. Даже в примере когнитивная нагрузка большая, а это очень простая выборка


      1. ptr128
        15.07.2024 13:55

        Тут типичная OLAP нагрузка, где MongoDB - не лучший выбор. Я не уверен, что Монга сможет даже из десятков миллионов, а не сотен, как ClickHouse, выбрать тысячу записей по десятку фильтров за 5 мс.


        1. komissar_off_andrey Автор
          15.07.2024 13:55

          Честно не представляю, как использовать какую-либо БД в данной задаче:
          Если считаем, что каждый сегмент - отдельная запись, то в этой записи как-то будет описано правило, по которому пользователь попадет в этот сегмент.

          А в самом запросе соответственно будет информация об этом пользователе.

          Как проверить, что сотни разных таких правил, не прибегая к eval - не тривиальная задача.

          Окэй, опустим, что eval - это зло, и мы сделали такое решение, но это не все тех. требования.

          Насколько решение будет масштабируемым?
          Получится держать несколько тысяч RPS в real-time?
          Будет гарантированное время ответа в до 5 мс с учетом сети от сервиса до базы?

          Лично у меня есть сомнения, что от КХ легко можно такого добиться.


          1. ptr128
            15.07.2024 13:55

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

            Даже если там будет храниться строка SQL запроса, это уже решение. Но лучше, конечно, использовать placeholders и держать уже prepared statements на клиенте.

            Насколько решение будет масштабируемым?

            ClickHouse замечательно масштабируется горизонтально.

            Получится держать несколько тысяч RPS в real-time?

            На Raspberry PI не уверен, а на нормальном железе, даже в примерах CH RPS свыше 60 миллионов. И это далеко не предел.

            Будет гарантированное время ответа в до 5 мс с учетом сети от сервиса до базы?

            Бессмысленный вопрос. Например, если сервис с БД общается через спутниковый интернет, то и секунда задержки возможна. А если через 40-гигабитку в одном ЦОД, то больше 1 мс получите, разве что, на массивах свыше 100 миллионов записей на каждом хосте в кластере.


            1. komissar_off_andrey Автор
              15.07.2024 13:55

              Нет, вопрос не бессмысленный. Если есть требования к сервису, то они фиксируются для него в целом, а не на часть компонентов. Потому весь выбираемый набор инструментов и их конфигураций должно работать достаточно слажено, чтобы выполнять требования.

              Нельзя сказать, что API работает быстро, когда код в сервисе супер быстрый, база отвечает мгновенно, а сеть между ними едва существует.

              А для low-latency сервисов, как сервис сплитования, шумы в сети и малозаметные задержки на балансерах / ингрессах могут повлиять на времени ответа из API в процентилях 99.9% и даже 99% катастрофически.

              Хороший поинт задумываться о таких мелочах до реализации важных и сложных решений.
              В одной из следующих статьей, мой коллега расскажет, как мы столкнулись с такого рода проблемой, и как ее решали :)


              1. ptr128
                15.07.2024 13:55

                Нет, вопрос не бессмысленный. Если есть требования к сервису, то они фиксируются для него в целом, а не на часть компонентов.

                Вы можете хоть об фиксироваться, но если сервис связывается с БД через геостационарный спутник, то пинг меньше 600 мс не получите физически. Так как не сможете превысить скорость света. Потому, без указания расстояния между сервисом и БД, а так же технических возможностях канала связи, говорить о "гарантированном времени ответа с учетом сети от сервиса до базы" - бессмысленно.

                Тем более о 5 мс, которые даже от Москвы до Камчатки никогда не получите. 43 мс теоретический минимум.

                Фактически, когда Вы приплели к теме сравнения Вашего решения с ClickHouse сетевые задержки, Вы занялись откровенной демагогией. Так как в любом случае наличие или отсутствие CH на сетевые задержки ну никак повлиять не может.