Skip to content

perf: обрабатывать в point_update только активных (без полного скана character_list) — шаг к #3180 #3414

Description

@bylins

Первый конкретный шаг под зонтиком #3180 (уменьшение глобального состояния): оптимизация делается без нового глобала — активный набор становится индексом внутри уже существующего реестра Characters. Заодно служит DI-примером, которого просит #3180.

Проблема

Шаг хартбита «Point updating» (heartbeat.cpp:482, вызывает только point_update() из game_limits.cpp) периодически занимает ~111 мс. По коду внутри цикла — дешёвая арифметика (in_used_zone, get_real_max_hit/move, average_day_temp, hit_gain/move_gain), никаких квадратов и тяжёлых перерасчётов. Значит спайк даёт не работа, а сам проход.

point_update() идёт по глобальному character_list (std::list ВСЕХ загруженных мобов мира — десятки тысяч) и почти всех пропускает фильтром:

// game_limits.cpp:1541
if (i->purged() || (i->IsNpc() && !i->in_used_zone())) {
    continue;
}

in_used_zone() (char_data.cpp:109) для NPC возвращает zone_table[world[in_room]->zone_rn].used, для PC — всегда false. Зона помечается used=true при входе игрока (handler.cpp:266, char_movement.cpp:797, db.cpp:after_reset_zone), снимается при деактивации (db.cpp:2094/2898).

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

Тот же паттерн в mobile_activity() (mobact.cpp:903) — полный проход character_list с фильтром purged() || !IsNpc() || !in_used_zone().

Профайлер частей (PR #3412) подтвердит прямо в логе: при просмотрено ≫ обработано и всего ≫ суммы частей время уходит на скан/пропуск, а не на работу.

Идея

Не сканировать весь мир в point_update, а обрабатывать только активный набор: PC + мобы, реально проявившие активность. mobile_activity уже определяет активного моба — там его и запоминать; point_update затем идёт по запомненному набору.

Согласование с #3180 (важно)

Наивная реализация — завести глобальный std::unordered_set<CharData*> g_active_chars. Это новый singleton-глобал, прямо против #3180. Поэтому владельца выбираем осознанно:

character_list — это объект класса Characters (world_characters.h), который УЖЕ:

  • владеет списком персонажей (std::list<CharData::shared_ptr>);
  • ведёт индекс vnum → мобы (m_vnum_to_characters_set);
  • управляет жизненным циклом — push_front() при спауне и remove() при extract (world_characters.cpp:130), где уже чистятся вспомогательные индексы.

Значит «активный набор» — это ещё один индекс внутри Characters, а не новый глобал. Когда Characters со временем станет инжектируемой в контекст мира (цель #3180), индекс уедет вместе с ней. И висячие указатели решаются даром: удаление активного цепляется к существующему Characters::remove.

Способ реализации (по шагам)

1. Новый индекс в Characters

В классе Characters (world_characters.{h,cpp}):

// внутри Characters
std::unordered_set<CharData *> m_active;            // активные с прошлого point_update
public:
void mark_active(CharData *ch) { m_active.insert(ch); }   // O(1)
const auto &active() const { return m_active; }
void clear_active() { m_active.clear(); }

В существующем Characters::remove(CharData *ch) добавить m_active.erase(ch); рядом с чисткой прочих индексов — висячих указателей не будет by design.

2. Производитель — mobile_activity (врезка строго O(1)!)

mobile_activity сам перегружен и тоже сканирует character_list, поэтому туда можно добавить только одну дешёвую вставку — без новых перерасчётов/проходов. Точка — где моб признан активным в этот пульс, сразу после ++processed_mobs (mobact.cpp, уже после in_used_zone и совпадения пульса активности):

++processed_mobs;
character_list.mark_active(ch.get());   // единственная добавленная стоимость, O(1)

Набор наполняется из уже идущего прохода — отдельного скана ради набора не появляется.

3. Игроки — по их активности, не через affect-update

PC мобакт не обрабатывает (!IsNpc() пропускается), вносим отдельно. У игрока активность трекается: idle-таймер сбрасывается на вводе команды (interpreter.cpp:1575), есть check_idling() (game_limits.cpp:1064). Игроков десятки, поэтому надёжнее всего проходить их в point_update отдельно — из descriptor_list (или лёгкого списка PC в игре). По ним идём всегда: регенерация/голод/жажда не должны зависеть от активности, иначе AFK-игрок перестанет голодать. Вариант «гейтить PC по активности» — опция с этой оговоркой.

4. Потребитель — point_update

for (auto *m : character_list.active()) {  // мобы — только активные
    if (m->purged()) continue;             // страховка
    /* тот же per-char код */
}
for (/* PC из descriptor_list / списка игроков */) {  // PC — всегда все
    /* тот же per-char код */
}
character_list.clear_active();             // наберётся заново мобактом

clear_active() делает набор «активные с прошлого point_update»: между двумя вызовами мобакт срабатывает многократно и наполнит его заново.

Что НЕ входит в scope

Убираем избыточный скан в point_update (целевые 111 мс). Полный проход character_list в самом mobile_activity остаётся — отдельная, более крупная задача (мобакт перегружен, трогать осторожно). Здесь в мобакт добавляется ровно одна O(1)-вставка.

Поведенческое следствие (обсудить)

point_update начнёт регенерировать только «активных» мобов, а не всех в used-зоне. Для простаивающих мобов безвредно (стоят с полным HP, на ресете восстановятся), но это сознательная смена семантики — фиксируем явно.

Валидация

  • Профайлер частей (PR perf(point_update): лог самой долгой итерации апдейта персонажа #3412): просмотрено резко падает, всего сходится с суммой частей.
  • Метрика mob.activity.duration (уже есть) — врезка её не ухудшила.
  • Паритет поведения: HP/move/conditions активных персонажей до/после идентичны; AFK-игрок продолжает голодать.
  • Стресс: массовая смерть мобов между мобактом и point_update — нет обращений к освобождённым (страхует remove+purged()).

Связанная находка

Рядом, в «Remember some spells» (game_limits.cpp:1657), условие GET_SPELL_MEM(i, sp) > GET_SPELL_MEM(i, sp) сравнивает значение само с собой → всегда false: мобы не запоминают спеллы, а while вхолостую крутится ~108 раз на каждом говорящем мобе. Отдельный баг — вынести в свою issue.


Связано с #3180 (уменьшение глобального состояния) — реализуется без новых глобалов, как пример DI-владельца.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions