Skip to content

perf: ускорение и параллельное сохранение клановых сундуков (#3165)#3181

Merged
bylins merged 2 commits intomasterfrom
perf/clan-chest-save-threaded
Apr 19, 2026
Merged

perf: ускорение и параллельное сохранение клановых сундуков (#3165)#3181
bylins merged 2 commits intomasterfrom
perf/clan-chest-save-threaded

Conversation

@kvirund
Copy link
Copy Markdown
Collaborator

@kvirund kvirund commented Apr 19, 2026

Контекст

Closes #3165.

После моего предыдущего PR (1e61b3eb9, замена tascii на числовой формат флагов) сохранение клановых сундуков с ингредиентами на боевом сервере по-прежнему занимало заметное время:

saving clan chest БУ done, timer 0.0195537350
saving clan chest ХТЗ done, timer 0.0039294920
saving clan chest ОС done, timer 0.0170514890
saving clan chest ИР done, timer 0.0072886020
saving clan chest ДНЗ done, timer 0.0388016190
saving clan chest СС done, timer 0.0068911170

Сумма около 94 мс, главный поток heartbeat блокировался на это время.

Прежде чем что-либо менять, я собрал тестовый стенд (отдельный gtest-файл, в коммиты не идёт), который позволял прогонять write_one_object локально и сравнивать минимум, медиану, p95 и максимум по сотне-другой повторов. По нему я мерил каждый шаг ниже и подтверждал, что улучшения видны числами, а не верой.

Что сделано

PR разделён на два коммита.

1. Ускорение write_one_object и снятие двух мутаций объекта

В горячей функции:

  • Сырой указатель прототипа кэшируется один раз вместо повторных shared_ptr::operator->.
  • get_skills() зовётся в варианте без аргументов, возвращающем const ref на m_skills -- копирование всей std::map<ESkill,int> исчезает.
  • Таймер читается напрямую через CObjectPrototype::get_timer(), без обращения к world_objects.decay_manager() (это был неявный поход в глобальный изменяемый индекс).
  • Геттеры флагов (get_affect_flags, get_anti_flags, get_no_flags, get_extra_flags, get_wear_flags) у объекта и прототипа читаются один раз в локальные ссылки.

Удалены две мутации, которые сериализация делала с самим объектом:

  1. object->set_timer(proto_timer) при превышении прототипа. Значение в файл уже к этому моменту было записано до clamp'а; та же коррекция применяется в read_one_object_new при каждой загрузке. Дополнительно set_timer ходил в ObjDecayManager и был заведомо непригоден для одновременного вызова из нескольких потоков.
  2. Пара unset/set для kBloody и kNosell, чтобы не писать их в файл. Заменена на формирование FlagData копией с последующим unset -- с тем же выходом байт на диск.

Стенд (сундук из 500 объектов, медиана по 200 прогонам, локальная машина):

Версия serialize, медиана
До 1e61b3eb9 (baseline) 2.42 мс
master после 1e61b3eb9 1.59 мс
Этот коммит 1.08 мс

Сжатие на 32 % относительно master, на 55 % относительно baseline.

2. Преаллокация буфера и параллельное сохранение

В ClanSystem::save_ingr_chests:

  • Сохранение одного сундука вынесено в свободную функцию save_one_ingr_chest. Она ничего не пишет в глобальные структуры и не логирует.
  • Внутри добавлена преаллокация stringstream по размеру предыдущего сохранения файла (+10 % запаса). На стенде даёт около -13 % wall time для самого крупного сундука и почти ноль для мелких.
  • Для каждого сундука сохранение раздаётся в utils::ThreadPool, главный поток блокируется на WaitAll(). Размер пула ограничен min(числу задач, числу ядер).
  • Логирование результата собирается в главном потоке после WaitAll(). Добавлена итоговая строка save_ingr_chests: N chests on M threads, wall <время>, потому что сумма таймеров по сундукам при многопоточном сохранении неинформативна.

Условия безопасности при многопоточном сохранении

  • write_one_object из первого коммита больше не мутирует ObjData и не лезет в изменяемые глобальные индексы.
  • save_ingr_chests вызывается из heartbeat'а: пока он не завершился, главный поток не обрабатывает входящих от игроков команд, способных изменить содержимое сундуков.
  • Логи собираются в главном потоке, поэтому от потокобезопасности log() ничего не зависит.

Оценка ускорения на боевой машине (2 ядра, текущий активный набор)

Применяя -30 % от первого коммита к каждому числу из прод-лога:

Сундук До После
ДНЗ 39 мс ~27 мс
БУ 20 мс ~14 мс
ОС 17 мс ~12 мс
ИР 7 мс ~5 мс
СС 7 мс ~5 мс
ХТЗ 4 мс ~3 мс
Сумма 94 мс ~66 мс

С пулом из двух потоков работает list scheduling: как только любой поток освобождается, он берёт следующую задачу. Нижняя граница wall:

wall ≥ max(самая_длинная_задача, сумма / число_потоков)

Для нашего набора: max(27, 66/2) = max(27, 33) = ~33 мс.

Итого: 94 → ~33 мс, около −65 %.

Если потенциально активными станут все 12 сундуков с ненулевым содержимым

В сохранённом мире сейчас 12 файлов .ing имеют реальное содержимое (остальные -- 18-байтовые заглушки). Если все они станут активными, оценка по той же ставке (48 нс/байт прод-времени, минус 30 %):

Сундук (по убыванию) Объём После оптимизаций
sb 1.09 МБ ~36 мс
dnz 0.37 МБ ~27 мс
by 0.32 МБ ~14 мс
os 0.59 МБ ~12 мс
ir 0.49 МБ ~5 мс
ss 0.03 МБ ~5 мс
htz 0.15 МБ ~3 мс
nvo 0.05 МБ ~1.7 мс
cc 0.02 МБ ~0.5 мс
zao 0.01 МБ ~0.3 мс
sp 0.002 МБ <0.1 мс
lb 0.001 МБ <0.1 мс
Сумма ~105 мс

wall ≥ max(36, 105/2) = max(36, 52) = ~52 мс.

То есть линейные ~150 мс схлопываются до ~52 мс на двух ядрах.

Если расширить процессорные ресурсы

Двукратное число ядер развязывает узкое место «один большой сундук + один поток»:

Конфигурация Сумма Wall (нижняя оценка)
6 сундуков, pool=2 66 ~33 мс
6 сундуков, pool=4 66 ~27 мс
12 сундуков, pool=2 105 ~52 мс
12 сундуков, pool=4 105 ~36 мс

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

Изменения

  • src/engine/db/obj_save.cpp -- переработана write_one_object.
  • src/gameplay/clans/house.cpp -- save_one_ingr_chest + преаллокация + thread pool + общий wall-таймер.

Test plan

  • make tests && ./tests/tests -- 365 тестов зелёные (стенд из бенча в коммиты не входит, поэтому общее количество то же, что и до изменений).
  • make circle -- собирается.
  • Прогон на боевом сервере, сверка wall time из новой строки лога с прежней суммой.

kvirund added 2 commits April 19, 2026 07:33
Переработка горячей функции сохранения предметов в обход прежних
узких мест:

- shared_ptr прототипа кэшируется в сырой указатель, вместо
  повторного вызова operator-> через все обёртки (в профайле было
  ~4% инструкций на всех shared_ptr-обращениях).
- get_skills() вызывается в версии без аргументов, возвращающей
  константную ссылку на m_skills. Копирование всей std::map<ESkill,int>
  при каждом предмете исчезает (в профайле было ~3%).
- Таймер читается напрямую через CObjectPrototype::get_timer(),
  минуя ObjData::get_timer(), который под капотом дёргал глобальный
  world_objects.decay_manager(). Это и быстрее, и необходимое условие
  для параллельного сохранения.
- Геттеры флагов (affect/anti/no/extra/wear) у объекта и прототипа
  читаются один раз в локальные ссылки, а не дёргаются на каждом
  сравнении.

Удалены две давние мутации объекта в пути сохранения:

1. Старый clamp таймера (object->set_timer(proto_timer), когда
   таймер превосходит прототип). Значение пишется в файл до clamp'а,
   а при каждой загрузке read_one_object_new сам применяет тот же
   инвариант -- поэтому clamp на save был избыточен. Ко всему прочему
   set_timer обращался к глобальному ObjDecayManager и потому был
   потенциально небезопасен при многопоточном сохранении.

2. Пара unset/set для флагов kBloody и kNosell, чтобы не писать их
   в файл. Такие же намерения удобно выразить без мутации -- сняв
   биты на локальной копии FlagData и сравнив с прототипом.

Результат на синтетическом стенде (сундук из 500 объектов,
medianа по 200 прогонам): serialize 1.59 -> 1.08 мс, то есть -32%.
ClanSystem::save_ingr_chests раньше шёл по списку кланов
последовательно, и общее время сохранения росло линейно от их
количества. На боевом сервере с шестью активными сундуками это
выливалось в 94 мс (см. issue #3165), причём один сундук "ДНЗ"
занимал из них 39 мс.

Изменения:

1. Логика сохранения одного сундука вынесена в свободную функцию
   save_one_ingr_chest. Она ничего не пишет в глобальное состояние
   и не логирует -- предназначена для запуска в рабочем потоке.

2. Внутри save_one_ingr_chest добавлена преаллокация буфера
   stringstream по размеру предыдущего сохранения файла (+10%).
   На стенде даёт примерно -13% wall time для самого крупного
   сундука и около нуля для мелких.

3. ClanSystem::save_ingr_chests теперь раздаёт сохранение каждого
   сундука в utils::ThreadPool. Главный поток блокируется на
   WaitAll(). Размер пула ограничен min(числу задач, числу ядер).

4. Логирование результата собирается в главном потоке после
   WaitAll(), плюс новая итоговая строка "save_ingr_chests: N
   chests on M threads, wall ..." -- сумма таймеров по сундукам
   при многопоточном сохранении неинформативна, нужен общий
   wall time.

Условия безопасности:
- write_one_object из соседнего коммита больше не мутирует ObjData
  и не лезет в изменяемые глобальные индексы;
- save_ingr_chests вызывается из heartbeat, поэтому пока он не
  завершился, главный поток не обрабатывает входящих от игроков
  команд, способных изменить содержимое сундуков;
- логи по результату собираются в главном потоке, и от
  потокобезопасности log() ничего не зависит.

Оценка ускорения на боевой машине (2 ядра, 6 активных сундуков
суммой 94 мс): нижняя граница wall = max(самый_большой, total/N).
После сжатия каждого сундука на ~30% сумма = ~66 мс,
самый большой = ~27 мс, wall на двух потоках = max(27, 33) = ~33 мс.
То есть с 94 мс до ~33 мс.

Если потенциально активными станут все двенадцать сундуков с
ненулевым содержимым (общий объём ~3 МБ против нынешних 2):
ожидаемая сумма ~105 мс, wall на двух потоках = max(36, 52) = ~52 мс
вместо ~150 мс линейно.

Для дальнейшего роста (либо бо́льший мир, либо больше сундуков)
имеет смысл увеличить число ядер на сервере или дробить самый
крупный сундук на части.

Closes #3165
@bylins bylins merged commit 3877184 into master Apr 19, 2026
20 checks passed
@bylins bylins deleted the perf/clan-chest-save-threaded branch April 19, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

32. Clan system: ingredients chests saving 0.144088 seconds. time Thu Apr 16 12:31:05 2026

2 participants