Почему в KLR.gg заложили бюджет в 2 секунды на карту?
В нашем проекте KLR.gg обработка одной карты матча должна укладываться в 2 секунды. Когда сервер с MatchZy завершает игру, наш Python-воркер забирает сырые CSV-файлы и логи демок по FTP, парсит события раундов и рассчитывает HLTV Rating 2.0, Round Swing и распределение рейтинга Glicko-2 для 30 раундов и примерно 15 000 боевых тиковых событий. В бенчмарках от 14 марта 2024 года pandas 2.2 с бэкендом NumPy 1.26 распарсил и оценил карты за 1,84 s сквозного выполнения. Polars 1.0 на том же пайплайне с состоянием потребовалось 2,45 s. Телеметрия матчей приходит внезапными всплесками, когда раунды турнира завершаются одновременно на десятках серверов. Профилирование показало, что 72% времени CPU воркера уходит на вычисления рейтингов с сохранением состояния, а не на считывание строк. Скорость парсинга CSV оказалась менее важной, чем совместимость структуры памяти с нашим движком расчета рейтингов.
Задержки в обработке логов приводили к устареванию данных в интерфейсе на Next.js и выводили автоматические выплаты за пределы 5-секундного SLA нашей очереди.
Как фрагментированная память Arrow замедлила циклы Numba?
Структура памяти Apache Arrow внутри Polars объединяет данные в неразобщенные фрагментированные столбцовые буферы (chunked columnar buffers), которые идеально подходят для параллельной фильтрации и агрегаций без копирования. Расчет очков в CS2 работает иначе: обновления в раунде N зависят от рейтингов игроков и состояния экономики оружия в раунде N-1, что требует последовательных мутаций состояния. Передача чанковых массивов Arrow в C-расширения или скалярные циклы Python заставляет Polars проверять границы чанков либо выделять новую память через to_numpy(). Стандартные C-непрерывные массивы float64 в NumPy для pandas хранятся едиными блоками, позволяя передавать указатели памяти напрямую в функции, скомпилированные через Numba.
Многофрагментный макет Arrow означает, что элементы одного столбца часто находятся по нерасположенным подряд адресам памяти. При расчете отклонений Glicko-2 для 10 игроков на протяжении 30 раундов шаблон доступа к памяти определяет задержку выполнения. Передача столбца Polars в цикл Python требует либо вызова to_numpy() (который выделяет новую память), либо использования оберток выражений Polars, которые добавляют накладные расходы C-слоя на каждой итерации.
Штраф в 380 мс: UDF в Polars против непрерывной памяти NumPy
Расчет Glicko-2 и дисперсии Round Swing требует пошагового обновления параметров игроков вдоль последовательных событий матча. Polars эффективно обрабатывает встроенные векторизованные выражения, но пользовательские функции (UDF) переходят на выполнение в Python или требуют написания плагинов на Rust. Перенос промежуточных данных из Rust в объекты Python для итеративного обновления рейтингов создал штраф в 340 мс на карту в Polars 0.20 и 380 мс в Polars 1.0. Использование pandas с массивами float64 NumPy позволило передавать сырые указатели напрямую в JIT-скомпилированные циклы @numba.njit без выделения памяти в куче.
22 марта 2024 года мы попытались написать плагин на Rust для Polars, чтобы рассчитать Round Swing, но поддержка Rust-биндингов при частых изменениях правил подсчета очков стоила нам трех дней разработки и не дала преимущества перед простой итерацией по указателям NumPy.
Мы протестировали три подхода: встроенные выражения Polars с map_batches(), чистые векторизованные методы pandas и извлеченные массивы pandas, переданные в Numba. Подход с Numba, работающий поверх плоских буферов NumPy, выполнился за 14 миллисекунд на карту по сравнению с 380 миллисекундами для map_batches() в Polars. Обновление Glicko-2 требует пошаговой мутации матрицы на основе рейтингов соперников, поэтому стандартные реляционные соединения и маппинги столбцов в Polars не могли выразить этот алгоритм без перехода к итеративному выполнению на Python.
pandas 2.2 против Polars 1.0 при считывании логов CS2
Выбор фреймворка зависит от того, на что расходуется время процессора. Polars парсит сырые CSV-файлы в 3,6 раза быстрее pandas благодаря многопоточным парсерам на Rust (85 мс против 310 мс на лог из 30 раундов). Однако первичный парсинг CSV занимает менее 15% от общего времени обработки карты в нашем пайплайне. Pandas в сочетании с C-непрерывными массивами NumPy выполняет невекторизуемые циклы расчета рейтинга за 14 мс, выигрывая у map_batches() в Polars 366 мс.
| Метрика / Операция | pandas 2.2 (бэкенд NumPy) | Polars 1.0 (бэкенд Arrow) | Влияние на движок |
|---|---|---|---|
| Считывание MatchZy CSV | 310 мс | 85 мс | Парсер Polars на Rust работает в 3,6 раза быстрее на сыром I/O |
| Группировка по раундам и фильтры | 120 мс | 40 мс | Выражения Polars нативно выполняются в несколько потоков |
| Вычисление Glicko-2 и Rating 2.0 | 14 мс (через Numba ptr) | 380 мс (через map_batches) | Непрерывный блок pandas избегает штрафа конвертации Arrow |
| Накладные расходы конвертации буфера памяти | 0 мс (указатель zero-copy) | 165 мс (выделение массива to_numpy) | Polars требует материализации массива для кастомных C-циклов |
| Сквозная задержка пайплайна на карту | 1,84 s | 2,45 s | pandas/NumPy выигрывает 610 мс на общем расчете матча |
14 миллисекунд в Numba: код, который мы выкатили в продакшен
Чтобы уложиться в лимит обработки карты до 2 секунд, мы разделили структурную группировку и математический расчет рейтингов. Pandas отвечает за парсинг файлов и фильтрацию событий, а JIT-скомпилированное ядро Numba рассчитывает импакт игроков, Round Swing и обновления Glicko-2 напрямую по одномерным массивам NumPy. Передача представлений памяти (memory views) базовых Series из pandas в скомпилированный код исключает создание объектов Python во внутренних циклах.
import numba as nb
import numpy as np
@nb.njit(fastmath=True)
def compute_round_swing(kills, deaths, damage, player_ids, out_rating):
n_events = kills.shape[0]
for i in range(n_events):
p_id = player_ids[i]
impact = (kills[i] * 2.13) - (deaths[i] * 0.98) + (damage[i] * 0.008)
out_rating[p_id] += impact * 0.15
# Zero-copy extraction from pandas block memory
kills_buf = df['kills'].to_numpy(dtype=np.float64, copy=False)
deaths_buf = df['deaths'].to_numpy(dtype=np.float64, copy=False)
damage_buf = df['damage'].to_numpy(dtype=np.float64, copy=False)
p_ids_buf = df['player_id'].to_numpy(dtype=np.int64, copy=False)
out_buf = np.zeros(10, dtype=np.float64)
compute_round_swing(kills_buf, deaths_buf, damage_buf, p_ids_buf, out_buf)Использование copy=False внутри to_numpy() гарантирует, что Numba получает прямой доступ к указателям памяти блоков pandas DataFrame. Polars хранит столбцы как чанковые массивы Arrow, поэтому вызов to_numpy() в Polars принудительно копирует память, если столбец охватывает несколько чанков Arrow.
С какими проблемами мы столкнулись при выборе pandas вместо Polars?
Отказ от Polars в пользу pandas 2.2 означал потерю масштабирования на многоядерных CPU при считывании сырых CSV-файлов и необходимость вручную контролировать выравнивание памяти. Polars автоматически параллелит фильтрацию и чтение CSV по ядрам процессора с помощью движка на Rust, в то время как pandas работает в один поток на процесс. Мы решили узкое место при массовой загрузке исторических данных через пулы процессов Python с помощью concurrent.futures, закрепив задачу по каждой карте за отдельным процессором воркера.
Мы также столкнулись с повышением пикового потребления памяти при сложных слияниях DataFrame. Pandas создает копии в памяти при полном объединении DataFrame, если выравнивание индексов не контролируется строго. В нашем пайплайне узлы воркеров с 4 ГБ RAM на ядро справляются с пиковыми нагрузками без ухода в swap. Задачи пайплайна, выполняющие чистую векторизованную агрегацию над многогигабайтными логами без кастомных математических C-расширений, по-прежнему лучше подходят для Polars.