← § BLOG

20 CPU/тик: как я уперся в потолок и нашёл 4.8 CPU в untracked-логике

Бот стабильно жрал 17.7 из 20 CPU, bucket лежал в районе 2-22. Декомпозиция показала: 8.25 CPU — hard ceiling intent-ов, ничего не сделать. А 4.8 CPU — в untracked-логике, где лежит 90% потенциала. История про module-level cache в эфемерном runtime'е.

#screeps#performance#profiling#architecture#javascript

Продолжение статьи про мониторинг Screeps-бота через Grafana. Там была инфраструктура, здесь — конкретный кризис, который та инфраструктура помогла решить.

В Screeps у каждого игрока есть жёсткий CPU-бюджет. На GCL 6 это 20 CPU/тик. Превысил — bucket падает; кончится bucket — крипы перестают исполняться, бот стоит. Это не «оптимизация ради оптимизации», это hard limit, после которого бот физически перестаёт играть.

В апреле мой бот стабильно потреблял 17.7 CPU/тик при потолке 20, bucket колебался 2-22 при норме 10 000. Каждые 5 минут на дашборде было по 1-2 пика overrun, после которых пара комнат пропускала тик. Я знал, что нужно оптимизировать. Но не знал, что именно.

Эта статья — про то, как я разложил эти 17.7 CPU по полочкам, нашёл, что 4.8 из них — это «воздух» (untracked-логика), и про канонический паттерн module-level cache, который в эфемерном runtime'е Screeps работает не так, как кажется.

Bucket как индикатор здоровья

Сначала про модель CPU в Screeps. Каждый тик у бота есть:

  • CPU limit — фиксированный, 20 на GCL 6
  • Bucket — резервуар. Если потратил меньше limit — остаток капает в bucket (до 10 000). Если потратил больше — берёт недостающее из bucket.
  • Overrun — когда bucket пуст и тик не уложился: остановка кода, Game.cpu.tickLimit = 5 для следующего тика, фактически бот пропускает.

Здоровая система: avg < 15, bucket стабильно > 5000, можно позволить себе разовый пик 25-30 CPU без боли. Моя система: avg 17.7, bucket 2-22 — на грани, любой всплеск (волна спавнов, бой) → overrun.

В Grafana график bucket'а выглядел как кардиограмма умирающего пациента: ползёт около нуля, иногда судорожно дёргается до 22, потом снова в ноль. Без графика я бы списал это на «ну так бывает».

Декомпозиция: куда уходят 17.7 CPU

Бот пишет в Memory._cpu метрики каждые 100 тиков. Это попадает в сегмент 0 → PostgreSQL → Grafana. По фильтру в Grafana я разложил 17.7 CPU/тик так:

КомпонентCPUМожно ли уменьшить
Intents (tracked)8.25Нет — hard ceiling
Task generation~1-2Да
Overhead (findTask, Logger, pickup)~1.5Да
Role state machines~0.3Не трогать
Init + Memory parse1.6Чуть-чуть
Rooms4.2Чуть-чуть
Untracked в creeps4.8Да, основной потенциал

Intents — это creep.move(), creep.harvest(), creep.transfer(), любые игровые действия. У каждого фиксированная CPU-цена, и снизить её нельзя — это правило игры. 8.25 CPU intents при 43-45 крипах — потолок, прорваться через который означает только сократить количество крипов или их action'ов.

А вот 4.8 CPU untracked — это интересно. Это не intents, а вся остальная JS-логика: выбор задачи, цикл runRoom, генерация пула задач, состояние машин, фильтры. Именно там лежит 90% потенциала оптимизации.

CPU по ролям

РольштCPUCPU/creep
hauler175.90.35
miner123.30.27
upgrader31.20.40
skMiner30.90.30
skHauler0.90.30
worker50.40.08
skKiller10.30.30

Worker дёшев, потому что у него простая логика: пришёл к строительной площадке, построил, пошёл за энергией. Hauler дорогой, потому что каждый тик заново выбирает задачу из пула из 30+ вариантов с приоритетами. На 17 хаулерах это 17 раз в тик пройтись по пулу — там и сидит 5.9 CPU.

CPU по методам

Бот ещё пишет, какие игровые методы сколько съели:

МетодCPUCallsCPU/call
moveTo2.1180.26
transfer1.69110.15
harvest1.48140.11
withdraw0.8150.16
roomFind0.691240.006
upgrade0.5930.20
findInRange0.50410.012

roomFind 124 раза в тик — звучит много, но на круг это 0.69 CPU, не самое больное. А вот transfer 1.69 CPU при 11 вызовах — это намёк, что кто-то делает transfer на каждом тике.

Module-level cache: канонический паттерн

Самая контринтуитивная часть оптимизации Screeps — где хранить кэш.

В обычном Node.js процессе можно сделать room.taskPool = [...] — и оно будет жить, пока процесс жив. В Screeps это не работает:

Game.* объекты пересоздаются каждый тик. room.foo, creep.bar, structure.baz НЕ переживают переход тика.

Я первые две попытки оптимизации сделал через room._taskPool — каждый раз кэш «работал» на этот тик, но через тик его уже не было. И Memory.rooms[name].taskPool тоже плохо: десериализация Memory дорогая, плюс сами объекты задач (с pos, target) после десериализации — мёртвые pojo, без методов.

Правильный паттерн — module-level state, который живёт в require-кэше глобального sandbox'а:

// top of module — persists via require cache
const _cache = {};

function getCached(key, ttl, compute) {
    const tick = Game.time;
    const e = _cache[key];
    if (e && (tick - e.tick) < ttl) return e.value;
    const v = compute();
    _cache[key] = { value: v, tick };
    return v;
}

Глобальный sandbox в Screeps живёт между тиками — пока сервер не делает global reset (на деплое кода или раз в сотни-тысячи тиков). Поэтому _cache в module-scope переживает переход тика, в отличие от полей на game-объектах.

Что нельзя кэшировать в module cache: сами объекты-обёртки Room, Creep, Structure. Они мёртвые в следующем тике — методы вызвать нельзя. Хранить нужно id, и резолвить через Game.getObjectById(id) каждый раз.

Этот паттерн используют все известные публичные боты (Overmind, the-international, bonzAI). Я просто не знал.

Phase 1: Task pool TTL=2

Самая дорогая узловая точка — генерация пула задач для комнаты. TaskGenerator.generate(room) обходит структуры, ищет дропы, считает приоритеты — и возвращает массив из 30+ tasks. Это вызывалось каждый тик в каждой комнате: 6 комнат × ~0.7 CPU = ~4.2 CPU в task generation.

Но пул меняется редко: новые задачи появляются на событиях — крип умер, структура заполнилась, дроп исчез после pickup. На фоне 30+ tasks в пуле, между событиями пул идентичен. Ловите его и держите 2-3 тика.

Реализация:

// core.task.queue.js
getPool: function(room) {
    Cache.init(room);
    const TTL = 2;
    if (room._cache.taskPool && room._cache.taskPoolTick &&
        Game.time - room._cache.taskPoolTick < TTL) {
        return room._cache.taskPool;
    }
    const pool = TaskGenerator.generate(room);
    pool.sort((a, b) => a.priority - b.priority);
    room._cache.taskPool = pool;
    room._cache.taskPoolTick = Game.time;
    return pool;
}

И сброс по событиям:

  • TaskQueue.assign() с maxAssigned=1 → удалить кэш (слот занят)
  • TaskQueue.complete/release() → удалить кэш (слот освободился)
  • Смерть крипа в комнате → удалить кэш
  • Pickup убрал ground resource → удалить кэш

Риск был с pickup-задачами: dropped resource decay'ит со скоростью 1/1000 от amount в тик. 500e теряет 25e за 50 тиков. На TTL=2 это ничтожно. Tomb decay чуть быстрее, но всё ещё в пределах допустимого.

Эффект после деплоя:

  • Hit rate 73% (то есть 73% вызовов getPool возвращают cached значение)
  • avg CPU ~1 ниже
  • bucket из 2-22 пополз до 13-52

Не до 10000, до которых хочется, но впервые bucket стабильно растёт, а не лежит в нуле.

Что оказалось НЕ проблемой

После профилирования я думал, что главные виновники — это room.find() (124 вызова в тик) и moveTo. Оказалось, что нет. Реальные узкие места обнаружились в декомпозиции, а не в интуиции. Кандидаты, которые я отверг:

КандидатПочему не трогал
manager.colonize.js 26 find/тикУже throttled через Game.time % 50 === 0
room.find в hauler dead branchСрабатывает только в стартовых колониях, прод не задевает
Кэширование 124 roomFind callsИтого 0.69 CPU — плохой ROI на рефакторинг 30 мест
Убрать moveTo reusePath8 calls/тик — pathfinding и так срабатывает редко

Урок: интуиция говорила «оптимизируй find()», цифры говорили «оптимизируй task generation». Цифры выиграли.

Следующий шаг: miner transfer throttle

Самое смешное открытие пришло уже после Phase 1. Я заметил, что transfer 1.69 CPU при 11 вызовах в тик — это в основном майнеры, которые делают creep.transfer(link, ENERGY) каждый тик когда store > 0.

Майнер 5W добывает 10 e/tick, store cap 50. То есть после первого harvest у него 10 в стоке, после transfer — 0, через тик опять 10. Каждый тик одинаковый цикл, каждый тик transfer — 0.15 CPU × 12 майнеров = 1.8 CPU на одно действие, которое можно делать раз в 5 тиков.

Фикс на одну строку:

// БЫЛО:
if (creep.store[RESOURCE_ENERGY] > 0) {
    creep.transfer(link, RESOURCE_ENERGY);
}

// СТАЛО:
if (creep.store[RESOURCE_ENERGY] >= 40) {
    creep.transfer(link, RESOURCE_ENERGY);
}

Майнер копит до 40, потом дампит за раз. Цикл 40→0 раз в 4 тика. Экономия: 9 transfers/tick × 0.15 CPU = −1.35 CPU. Это больше, чем дала вся Phase 1.

Риск нулевой. harvest в pipeline P1, transfer в P3 — не конфликтуют (см. [предыдущую статью про action pipelines, если успею её написать]). Threshold 40 + harvest 10 = 50 = capacity — overflow невозможен.

Эта одна строка ждёт деплоя, по плану — следующая фаза.

Что я понял

Hard ceiling vs soft ceiling. В Screeps intent-ы стоят фиксировано — это hard ceiling. Сколько ты ни оптимизируй, 8.25 CPU на 43 крипа никуда не денется. Если в системе есть hard ceiling — его сначала надо найти, чтобы не оптимизировать невозможное. У SaaS-сервиса аналог — стоимость SQL-запроса в БД: если запрос обязателен, оптимизация кода вокруг него имеет потолок.

Hot path ≠ hot logic. room.find() 124 раза в тик звучит страшно, но на круг даёт 0.69 CPU. А TaskGenerator.generate() 6 раз в тик — 4.2 CPU. Оптимизировать надо логику, которая много делает за вызов, а не точку, которая часто вызывается.

Module-level state в эфемерных runtime'ах. В Screeps глобальный sandbox живёт между тиками, а game-объекты — нет. Это контринтуитивно: кажется, что room.foo персистентнее, чем глобальная переменная, но на самом деле наоборот. У FaaS / Lambda похожее правило: warm container переживает запросы, request-scope state — нет.

Цифры обыгрывают интуицию. Пока я не разложил 17.7 CPU по полкам, я бы оптимизировал room.find() — и сэкономил бы 0.5 CPU. Декомпозиция показала, что в untracked-логике лежит 4.8 CPU из 13.1 — это 90% потенциала. Без grafana-дашборда я бы этого не увидел.


В следующей статье — про action pipelines в Screeps: как два метода могут вернуть OK, но один из них молча проигнорирован движком; и как drainer (5T+18RA+17M+10H) становится бессмертным под тремя башнями именно из-за правильного выбора pipeline'ов. Это уже не про производительность — это про silent failures и почему OK ≠ «выполнено».

Поделиться
TelegramX (Twitter)
Discussion

Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.

Открыть обсуждение на GitHub
20 CPU/тик: как я уперся в потолок и нашёл 4.8 CPU в untracked-логике · Григорий Масич