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-бота через 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 parse | 1.6 | Чуть-чуть |
| Rooms | 4.2 | Чуть-чуть |
| Untracked в creeps | 4.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 по ролям
| Роль | шт | CPU | CPU/creep |
|---|---|---|---|
| hauler | 17 | 5.9 | 0.35 |
| miner | 12 | 3.3 | 0.27 |
| upgrader | 3 | 1.2 | 0.40 |
| skMiner | 3 | 0.9 | 0.30 |
| skHauler | — | 0.9 | 0.30 |
| worker | 5 | 0.4 | 0.08 ✅ |
| skKiller | 1 | 0.3 | 0.30 |
Worker дёшев, потому что у него простая логика: пришёл к строительной площадке, построил, пошёл за энергией. Hauler дорогой, потому что каждый тик заново выбирает задачу из пула из 30+ вариантов с приоритетами. На 17 хаулерах это 17 раз в тик пройтись по пулу — там и сидит 5.9 CPU.
CPU по методам
Бот ещё пишет, какие игровые методы сколько съели:
| Метод | CPU | Calls | CPU/call |
|---|---|---|---|
| moveTo | 2.11 | 8 | 0.26 |
| transfer | 1.69 | 11 | 0.15 |
| harvest | 1.48 | 14 | 0.11 |
| withdraw | 0.81 | 5 | 0.16 |
| roomFind | 0.69 | 124 | 0.006 |
| upgrade | 0.59 | 3 | 0.20 |
| findInRange | 0.50 | 41 | 0.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 reusePath | 8 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 ≠ «выполнено».
Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.