Skip to content

Propagate the round-robin field on a worker thread while the units step - #212

Open
Giszmo wants to merge 1 commit into
masterfrom
feat/async-gradient-fields
Open

Propagate the round-robin field on a worker thread while the units step#212
Giszmo wants to merge 1 commit into
masterfrom
feat/async-gradient-fields

Conversation

@Giszmo

@Giszmo Giszmo commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #184, prompted by @stephanemagnenat's question there about building gradients with std::execution::par.

What is parallel today. Nothing: the resource, guard and clear area fields are refreshed one per tick, round robin, at the end of Map::syncStep, and building fields are built lazily inside the unit step that first asks for them. propagateGradient used one static scratch. There is never a batch of independent fields to hand to a parallel algorithm, so std::execution::par (which on libstdc++ also means linking TBB, and which Apple's libc++ does not implement) has nothing to work on.

What this does instead. The one round-robin field per tick is overlapped with the units step:

  • At the start of Game::syncStep, Map::stepGradients publishes the field due this tick, then seeds the next one from the live map into a spare buffer and hands it to a worker thread.
  • The worker propagates it while the teams step. It reads only terrain (immutable during a game) and its own buffer, with its own scratch (GradientScratch, one per thread, owned by the caller; the main thread's lives in Map).
  • The field is published by a pointer swap exactly GRADIENT_PIPELINE_TICKS (3) ticks after seeding; until then units read the previous buffer. The old buffer becomes the spare.

Seeding and publication happen at fixed ticks, so the simulation does not depend on thread timing. Verified with per-tick checksum sidecars (GLOB2_CHECKSUM_SIDECAR=1) on 128² Mazury, 2 Nicowars, 15360 ticks: two plain runs, a run pinned to a single core (taskset -c 3, so main thread and worker interleave completely differently) and a run under six busy-loop processes all produce byte-identical sidecars; during development, a build that propagated the same jobs synchronously on the main thread matched the threaded one byte for byte on both maps, and 1 vs 8 worker threads likewise. Map::clear drains and stops the worker before freeing any field.

The lazily built building fields stay synchronous: on the 512² game they are 0.3 builds per tick (first build or stuck rebuild); "dirty" rebuilds do not occur outside the unit step in practice, because a field dirtied after its 25-tick delay is rebuilt in the same tick the map changes.

Measurements (release build, g++ 15, -test-games-nox, GLOB2_TEST_SEED=1, base = master f50ab27a, whole-game wall time, base and new alternated on an otherwise idle 20-core VM):

game base this PR worker disabled (same code)
512² tiled Mazury, 8 Nicowars, 9216 ticks (2 runs each) 44.2 / 44.1 s 32.2 / 32.1 s (−27 % wall, +37 % ticks/s) 42.4 s
128² Mazury, 2 Nicowars, 15360 ticks (3 runs each) 3.34 / 3.34 / 3.35 s 2.67 / 2.66 / 2.64 s (−21 %) 3.51 / 3.52 / 3.49 s

The "worker disabled" column is from the development build that had a switch for it; the PR itself has no such switch.

Propagation is about two thirds of the simulation time on the 512² game, so this is most of what overlapping one field can give; the main thread still waits on the worker in short early-game ticks (about 10 % of its time there), which depth 3 already halves compared to depth 1. Refreshing two fields per tick on two workers was tried and dropped: 8 % slower on 512² (memory bandwidth) and no measurable economy gain.

Economy (end-of-game units, mean over seeds, base vs this PR; the field is 3 ticks staler than before on a refresh cycle of 20 to 60 ticks): Mazury n=36: 123.0 vs 120.0 (−2.4 %, t=−0.8); balanced_for_2 n=24: 164.2 vs 167.5 (+2.1 %, t=+0.8); 512² n=3: 472 vs 461. Within noise.

Code notes. updateResourcesGradient / updateGuardAreasGradient / updateClearAreasGradient are split into a seed step and the propagation so a job can be seeded on the main thread and propagated elsewhere; the synchronous callers are unchanged. The round robin moved from Map::syncStep into Map::pickRoundRobinField, same order, same reset. Requires the thread support glob2 already links (pthread); MinGW's posix thread model covers Windows.

Tests: the 170 unit tests and every test/ harness pass; immobile-unit-gradient-test and global-gradient-test pass.

The resource, guard and clear area fields are refreshed one per tick,
round robin, and that propagation is about two thirds of the simulation
time on a 512x512 game. It now overlaps the units step: at the start of
Game::syncStep, Map::stepGradients publishes the field due this tick,
seeds the next one from the live map into a spare buffer and hands it to
a worker thread. The worker reads only terrain and its own buffer, with
its own propagation scratch. The field is published by a pointer swap
GRADIENT_PIPELINE_TICKS (3) ticks after seeding; until then units read
the previous buffer. Seeding and publication happen at fixed ticks, so
the result does not depend on thread timing (per-tick checksum sidecars
are identical to a synchronous build on 128x128 and 512x512 games).

Whole-game wall time against master: 512x512, 8 Nicowars, 9216 ticks
42.7 s -> 31.4 s; 128x128, 2 Nicowars, 15360 ticks 3.37 s -> 2.67 s.

The update*Gradient functions are split into a seed step and the
propagation; the round robin moves from Map::syncStep into
Map::pickRoundRobinField unchanged. Map::clear drains and stops the
worker before freeing any field.

Fable 5.1 helped authoring this commit.
@Giszmo Giszmo mentioned this pull request Sep 8, 2026
@genixpro

genixpro commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Bradley approves the design direction and accepts the deliberate fixed delay in periodic gradient refreshes, conditional on resolving the technical concerns below. This is not approval to merge the current head (508395e).

The bounded buffer pool and fixed-tick publication are a sensible approach. We independently built this head and its parent f50ab27 on macOS and ran three alternating 15,000-tick headless runs per build on each of two saved games. Peak RSS averaged 27.51 -> 28.55 MiB on 128x128 G2 (four teams) and 83.68 -> 83.28 MiB on 256x256 Oazis (eleven teams); the latter difference is within run variation, not evidence of savings. The pipeline retains three extra map-sized fields, not a second copy of every gradient. Memory does not look like a blocker on these workloads. CPU-time observations were noisy under concurrent machine load and are not a controlled performance result.

Before merge:

  1. Prevent queued older results from overwriting a newer synchronous refresh. Game::executeAlterGuardArea/executeAlterClearArea refresh their fields immediately, and pathfindArea can also refresh a blocked field, but Map::publishGradients unconditionally swaps in a previously seeded job. A job seeded at T can therefore replace a fresh update from T+1 when published at T+3. This is confirmed by tracing the code, not yet a gameplay reproduction. Please add a deterministic regression for this ordering and invalidate superseded jobs (a per-field generation counter is one possible approach). Cover the other synchronous refresh paths too.

  2. Establish save/load continuation with jobs in flight. Pending snapshots, publication ticks and scheduling state are not serialized; loading clears and lazily reconstructs gradients. Please compare uninterrupted and save/resume per-tick checksums at multiple pipeline phases, with changing resources/areas, and resolve any divergence introduced by the pipeline. Distinguish pre-existing cache-continuity problems from new ones. Simply publishing all pending jobs early at save time would itself alter simulation timing and is not automatically equivalent.

  3. Provide cross-platform checksum evidence on the same retained fixtures, initial state/seeds and orders. The reported scheduling-stress runs are useful, but successful Linux/Windows builds do not prove identical execution between those platforms and macOS. Add automated regression coverage for fixed publication timing, stale-job invalidation and lifecycle handling; retain commands and results for reproducibility.

  4. Review replay/network compatibility for this intentional simulation change against the current master, including the version gates just landed in Bump map, network and replay versions for the pathfinding change #211. If save serialization changes as part of the fix, handle its versioning as well.

With those concerns resolved and the final head reviewed/tested, I support landing this. No need to remove the threading or the accepted fixed-delay design merely to preserve the previous simulation's outcomes.

@genixpro

Copy link
Copy Markdown
Contributor

The threading design here looks right and the speedup is real — overlapping the one round-robin field with the units step is a genuinely better answer than std::execution::par on a batch that does not exist. Seeding on the main thread at a fixed tick and publishing by pointer swap at a fixed tick is the correct way to keep a worker off the critical path without making results depend on thread timing, and the determinism evidence for that is thorough: two plain runs, a run pinned to one core, a run under six busy-loop processes, a synchronous build, and 1 vs 8 workers all byte-identical. That is the hard part and it is done.

One thing is missing, and it is a blocker: this changes simulation results and does not bump the version.

The branch touches no src/Version.h, no src/ReplayReader.h and nothing in src/yog/. But making the field three ticks staler changes what units decide, and your own economy table compares base against this branch — which is only meaningful if the outcomes differ.

Measured directly rather than inferred. Same seed, same map, same AI line-up, this branch against its own merge-base f50ab27ac:

GLOB2_TEST_SEED=1 GLOB2_CHECKSUM_SIDECAR=1 GLOB2_CHECKSUM_SIDECAR_MAX_TICKS=3000 \
  ./build/src/glob2 -test-games-nox 1 --map balanced
base f50ab27 this branch
first diverging tick 199
checksum at tick 199 0xd19bd76e 0xd99b976e
game length 50594 ticks 39234 ticks
orders issued 8296 6478

Ticks 0–198 are byte-identical in the sidecars; tick 199 is where they part. The game then ends 11360 ticks earlier with a quarter fewer orders. That is not drift in the noise — it is a different game from the same seed.

What follows from that:

  • Replays recorded on master will not replay on this build, and vice versa. ReplayReader's acceptance range has no idea anything changed.
  • A mixed-version network game desyncs, because two clients running different sides of this change diverge by tick 199.

AGENTS.md is explicit that this is its own category: "a simulation change can invalidate replays and mixed-client games without changing a single saved byte." So this wants VERSION_MINOR bumped with the replay floor moved alongside it, and the affected acceptance boundary tested directly.

Note master is now at 96 and #232 took 97 (merged), so this wants 98 — but #256 and #257 are also converging on 97/98, so whichever lands last needs to move again. #232 landed a named-constant pattern in src/FileFormatVersions.h that makes this a one-line change next time instead of a hunt through >= comparisons; worth adopting here.

Nothing else in the review is blocking. Two smaller notes:

  1. The economy comparison would be more convincing per-seed than pooled. Mazury n=36 at 123.0 vs 120.0 with t=-0.8 is consistent with no effect, but it is also consistent with a real effect the sample cannot resolve — and since we now know the games genuinely differ, "within noise" is a claim about magnitude, not about whether anything changed.

  2. Once the version moves, it is worth saying in the PR body that replays from before this change are rejected rather than silently mis-replayed, since that is the user-visible consequence.

@genixpro

Copy link
Copy Markdown
Contributor

Approved in principle — handing the rebase back to you, because the architecture call in it is yours to make.

To be clear about what that approval covers: the threading design, the speedup, and the determinism argument. Seeding on the main thread at a fixed tick and publishing by pointer swap a fixed number of ticks later is the right way to keep a worker off the critical path without making results depend on thread timing, and the evidence for it is thorough — pinned to one core, under six busy-loop processes, a synchronous build, 1 vs 8 workers, all byte-identical. That is the hard part and it is done. We are not asking you to re-litigate any of it.

What we are not going to do is resolve the rebase ourselves. Four PRs have landed in exactly this machinery since this branch was cut, and the conflicts are semantic rather than textual:

landed what it did here conflicts with
#244 gave propagateGradient a maxCost parameter this branch splits the same function into main-thread and worker-thread overloads taking explicit scratch
#243 replaced the round-robin with a two-pass loop that skips unallocated fields this branch replaces the same loop with the pipeline
#232 Map::topologyGeneration, invalidating every cached field on a structural change, throttle raised 25 → 100 ticks changes when fields are rebuilt, which is what your pipeline schedules
#197 fetching-job swaps same area

Six hunks, about 128 lines, across Map.cpp, Map.h, MapStep.cpp and MapGradientField.cpp. Every one needs both sides to survive rather than one to win, and the right composition depends on intent we would be guessing at. You designed both the pipeline and the topology generation it now has to sit alongside, so you are the one person who can say how they compose. A wrong guess here is a desync, and desyncs do not fail CI.

#232 in particular is worth reading before you start: it means a structural map change now invalidates every cached field of every team, and the rebuild throttle is 100 ticks rather than 25. Whether that changes what your pipeline should schedule, or makes some of it unnecessary, is a judgement we would rather have from you than make for you.

The two mechanical things, so they do not cost you a round trip

VERSION_MINOR 98, with the replay floor. master is at 97 (#232 took it). This branch bumps nothing today, and it needs to — not because anything is wrong with it, but because the simulation genuinely changes and ReplayReader currently has no idea. Measured against this branch's own merge-base f50ab27ac, same seed, same map, same AI line-up:

GLOB2_TEST_SEED=1 GLOB2_CHECKSUM_SIDECAR=1 GLOB2_CHECKSUM_SIDECAR_MAX_TICKS=3000 \
  ./build/src/glob2 -test-games-nox 1 --map balanced
base f50ab27 this branch
first diverging tick 199
checksum at tick 199 0xd19bd76e 0xd99b976e
game length 50594 ticks 39234 ticks
orders 8296 6478

Ticks 0–198 are byte-identical; 199 is where they part. Divergence is expected and fine — we have been bumping the replay version freely through this run of engine work and are content to keep doing so. Tick 199 is offered only because AGENTS.md asks that the acceptance boundary be tested directly, and it gives you a concrete place to point a test.

One caveat on our own number: that is a single seed, and AI game length varies a lot between seeds. It proves the games differ; it says nothing about magnitude. Your pooled n=36 comparison is the better evidence there and we are not disputing it.

Use the named-constant pattern. #232 landed FILE_FORMAT_VERSION_TOPOLOGY_GENERATION in src/FileFormatVersions.h rather than bare >= comparisons at each read site. #256 and #257 are also converging on 97/98, so whoever lands last renumbers again — the constant makes that a one-line change instead of a hunt.

Ping us when it is rebased and we will take it from there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants