-
Notifications
You must be signed in to change notification settings - Fork 254
docs: add ENGINE_PERFORMANCE.md with some notes on engine internals #2919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+59
−0
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
210ce6b
docs: add ENGINE_PERFORMANCE.md with some notes on engine internals
bruno-dasilva de090f6
some accuracy fixes
bruno-dasilva ca2928c
fix: added multithreading section and addressed comments from tk
bruno-dasilva d9aec61
cut down on content and reword some sections
bruno-dasilva File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # Engine performance | ||
|
|
||
| Recoil is an RTS engine built for large-scale games — designed to handle thousands of units at once. | ||
|
|
||
| ## Scale target | ||
|
|
||
| - **Target:** ~10k concurrent units, *including buildings*. | ||
| - Mobile units tend to be ~40% of that late-game total. | ||
| - Largest seen in a real game: ~17.7k units. That's a data point, not a design target. | ||
|
|
||
|
bruno-dasilva marked this conversation as resolved.
|
||
| ## Sim, draw, and update frames | ||
|
|
||
| The main loop is `Update → Draw`, repeating — see the diagram and table below for the per-phase breakdown. Each iteration first drains any queued sim-frame packets (0..N per iteration), then renders one draw frame. `CGame::Update` dispatches `SimFrame()` calls as `NETMSG_NEWFRAME` packets arrive; `CGame::Draw` runs the unsynced update phase and then renders. The sim burst is capped at ~500 ms (`minDrawFPS`) so draw always gets to run, and it's all one thread — sim and rendering are **not concurrent**; parallelism only happens *inside* a phase. | ||
|
|
||
| Conversely, if no sim frames are in the queue the main loop runs `Draw`/`UpdateUnsynced` as fast as possible — many draw iterations can pass between successive sim frames, with visuals interpolating smoothly in between via `globalRendering->timeOffset`. | ||
|
|
||
| ``` | ||
| main-loop iteration (repeats as fast as possible) | ||
| ├── CGame::Update (mostly synced) | ||
| │ └── SimFrame × 0..N ← processes queued sim frames capped at | ||
| | ~500ms per iteration | ||
| └── CGame::Draw (unsynced) | ||
| ├── UpdateUnsynced ← unsynced update phase | ||
| └── render world + screen ← Draw::World + Draw::Screen | ||
| ``` | ||
|
|
||
| | Phase | Rate | Synced? | Responsibility | | ||
| |---|---|---|---| | ||
| | **Sim frame** — `CGame::SimFrame` | fixed 30 Hz (`GAME_SPEED`) | mostly yes | advance deterministic state: units, pathing, projectiles, line-of-sight, scripts, Lua `GameFrame` | | ||
| | **Draw frame** — `CGame::Draw` | variable | no | update phase (see below) + render world/screen | | ||
| | **Update phase** — `CGame::UpdateUnsynced` *(inside draw frame)* | per draw frame | no | timings, interpolation, camera, GUI, sound, world-drawer prep | | ||
|
|
||
| ### Profiler buckets | ||
|
|
||
| The engine `CTimeProfiler` (and the `benchmark` tool) report three peer buckets: `Sim` (the whole synced step), `Update` (`CGame::UpdateUnsynced`), and `Draw` (rendering only, *excluding* the Update that runs first). | ||
|
|
||
| `Sim` is **"mostly" synced**: it also bills unsynced work that runs inline during `SimFrame`. | ||
| - **Explicit Lua callins** — `GameFrame`/`GameFramePost` run near the start of each sim frame. | ||
| - **Event-driven Lua callins** — unsynced widgets can subscribe to synced game events, so their handlers run inline as those events fire during the frame. | ||
| - **C++-only unsynced sections** — e.g. the MT projectile visual pass (`Sim::Projectiles::UpdateUnsyncedMT`) and ghosted-building updates (`CUnitDrawer::UpdateGhostedBuildings`). | ||
|
|
||
| ### Scheduling and CPU budget | ||
|
|
||
| - Sim has a target rate set by the server; draw is as fast as the hardware allows. The sim target is `30 Hz × speedFactor`; at a speed factor of 1x, in-game time tracks real-world time 1:1, and at 2x speed the server fires twice as many sim frames per real-world second so the world evolves twice as fast. | ||
| - **Zero, one, or many** sim frames per draw frame — if the client falls behind, pending sim frames burst in the next iteration to catch up. | ||
| - Visuals interpolate between sim frames, so draw rate can exceed sim rate without stutter. | ||
| - Sim time is carefully budgeted and scheduled against draw frames (because they run serially) so there's always a minimum fps for the player | ||
|
|
||
| ## Multi-threading | ||
|
|
||
| The engine runs one **main thread** plus a pool of **worker threads**, all pinned to distinct cores. We typically aim for 6-8 worker threads. The main thread drives the sim/draw loop; workers pick up parallel work dispatched from the main thread (via `for_mt` and friends in `rts/System/Threading/ThreadPool.h`). The main thread also participates in draining the task queue while it waits. | ||
|
|
||
| Most parallel work in the engine is **homogeneous** — the same operation applied over many items (unit updates, projectile steps, etc.) via `for_mt`. Keeping parallel work homogeneous is a deliberate discipline: it makes determinism easier to reason about and keeps sim output independent of how work happens to land across threads. | ||
|
|
||
| **QTPFS is the one heterogeneous exception.** The quad-tree pathfinder maintains its own per-worker search state (`SearchThreadData`, `SparseData`) independent of engine sim state, which lets it safely run path searches on the worker pool *in the background* via `for_mt_background`. Background tasks yield to higher-priority work by rescheduling themselves when other jobs arrive, so QTPFS soaks up idle worker capacity without preempting foreground parallelism. | ||
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bruno-dasilva I didn't think
@includes worked outside of claude code?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you could be right for harness-forced includes. But regardless any modern model should see that as a dir and read it when it chooses to 🤷