Skip to content

Python·5 мин чтения

Парсинг логов матчей CS2 за 2 секунды: почему pandas обошел Polars в KLR.gg

Сравнение структуры памяти Apache Arrow и непрерывных блоков NumPy при расчете метрик Glicko-2 и HLTV Rating 2.0 в аналитическом движке KLR.gg.

Анатолий НовогродскийОснователь и CEO, GlanitОпубликовано 27 августа 2026

Почему в 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 мс.

Метрики бенчмарков при обработке стандартного лога матча CS2 на 30 раундов (15 000 тиковых событий)
Метрика / Операцияpandas 2.2 (бэкенд NumPy)Polars 1.0 (бэкенд Arrow)Влияние на движок
Считывание MatchZy CSV310 мс85 мсПарсер Polars на Rust работает в 3,6 раза быстрее на сыром I/O
Группировка по раундам и фильтры120 мс40 мсВыражения Polars нативно выполняются в несколько потоков
Вычисление Glicko-2 и Rating 2.014 мс (через Numba ptr)380 мс (через map_batches)Непрерывный блок pandas избегает штрафа конвертации Arrow
Накладные расходы конвертации буфера памяти0 мс (указатель zero-copy)165 мс (выделение массива to_numpy)Polars требует материализации массива для кастомных C-циклов
Сквозная задержка пайплайна на карту1,84 s2,45 spandas/NumPy выигрывает 610 мс на общем расчете матча
Метрики бенчмарков при обработке стандартного лога матча CS2 на 30 раундов (15 000 тиковых событий)

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.

Частые вопросы

Ещё статьи

ИИ и машинное обучение ·

Почему гибридный поиск обошел чистые эмбеддинги в нашем пайплайне Invoice AI

Чистые плотные эмбеддинги не справились с буквенно-цифровыми серийными номерами в нашем пайплайне Invoice AI. Объединение полнотекстового поиска PostgreSQL tsvector с косинусным расстоянием pgvector подняло точность совпадений top-1 с 61,4% до 94,8%.

6 мин чтения

ИИ и машинное обучение ·

Почему pgvector заменил Qdrant в нашем пайплайне Feedback AI

Мы перенесли 5 миллионов векторных эмбеддингов из кластера Qdrant в PostgreSQL 16 с pgvector 0.7. Рассказываем, как сократили накладные расходы на инфраструктуру, сохранив задержку поиска в пределах 50 мс.

5 мин чтения

ИИ и машинное обучение ·

Как работают ИИ-агенты: архитектура продакшн-агента

Агент — это модель, обёрнутая в восприятие, память, инструменты, оркестрацию и мониторинг. Практический разбор шести слоёв: что ломается в каждом и что мы с этим делаем.

5 мин чтения