perf: ускорение и параллельное сохранение клановых сундуков (#3165)#3181
Merged
perf: ускорение и параллельное сохранение клановых сундуков (#3165)#3181
Conversation
Переработка горячей функции сохранения предметов в обход прежних узких мест: - 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
This was referenced Apr 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Контекст
Closes #3165.
После моего предыдущего PR (
1e61b3eb9, заменаtasciiна числовой формат флагов) сохранение клановых сундуков с ингредиентами на боевом сервере по-прежнему занимало заметное время:Сумма около 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) у объекта и прототипа читаются один раз в локальные ссылки.Удалены две мутации, которые сериализация делала с самим объектом:
object->set_timer(proto_timer)при превышении прототипа. Значение в файл уже к этому моменту было записано до clamp'а; та же коррекция применяется вread_one_object_newпри каждой загрузке. Дополнительноset_timerходил вObjDecayManagerи был заведомо непригоден для одновременного вызова из нескольких потоков.unset/setдляkBloodyиkNosell, чтобы не писать их в файл. Заменена на формированиеFlagDataкопией с последующимunset-- с тем же выходом байт на диск.Стенд (сундук из 500 объектов, медиана по 200 прогонам, локальная машина):
1e61b3eb9(baseline)masterпосле1e61b3eb9Сжатие на 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 % от первого коммита к каждому числу из прод-лога:
С пулом из двух потоков работает list scheduling: как только любой поток освобождается, он берёт следующую задачу. Нижняя граница wall:
wall ≥ max(самая_длинная_задача, сумма / число_потоков)Для нашего набора:
max(27, 66/2) = max(27, 33) = ~33 мс.Итого: 94 → ~33 мс, около −65 %.
Если потенциально активными станут все 12 сундуков с ненулевым содержимым
В сохранённом мире сейчас 12 файлов
.ingимеют реальное содержимое (остальные -- 18-байтовые заглушки). Если все они станут активными, оценка по той же ставке (48 нс/байт прод-времени, минус 30 %):wall ≥ max(36, 105/2) = max(36, 52) = ~52 мс.То есть линейные ~150 мс схлопываются до ~52 мс на двух ядрах.
Если расширить процессорные ресурсы
Двукратное число ядер развязывает узкое место «один большой сундук + один поток»:
С четырьмя потоками 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-- собирается.