From 508395e50507ac2b32e864e365723c4bf749401b Mon Sep 17 00:00:00 2001 From: Leo Wandersleb Date: Tue, 8 Sep 2026 22:13:04 +0200 Subject: [PATCH] Propagate the round-robin field on a worker thread while the units step 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. --- src/Game_sync.cpp | 2 + src/map/Map.cpp | 39 +++++++ src/map/Map.h | 49 ++++++++- src/map/MapStep.cpp | 145 +++++++++++++++++++------ src/map/gradient/MapGradientArea.cpp | 14 ++- src/map/gradient/MapGradientField.cpp | 41 +++++-- src/map/gradient/MapGradientGlobal.cpp | 7 +- src/map/io/MapIO.cpp | 3 + 8 files changed, 253 insertions(+), 47 deletions(-) diff --git a/src/Game_sync.cpp b/src/Game_sync.cpp index 9477443bd..ee32adaf2 100644 --- a/src/Game_sync.cpp +++ b/src/Game_sync.cpp @@ -143,6 +143,8 @@ void Game::syncStep(Sint32 localTeam) Uint64 startTick=SDL_GetTicks64(); + map.stepGradients(); + for (int i=0; isyncStep(); diff --git a/src/map/Map.cpp b/src/map/Map.cpp index 48281a3e2..855a9c1f6 100644 --- a/src/map/Map.cpp +++ b/src/map/Map.cpp @@ -86,6 +86,42 @@ Map::Map() fertilityMaximum = 0; } + +#ifndef YOG_SERVER_ONLY +// Draining the field jobs lives here, next to clear(), so every build that links +// Map.cpp can free a Map; the scheduling and the worker are in MapStep.cpp. + +void Map::publishGradients(Uint32 now) +{ + std::unique_lock lock(gradientMutex); + while (!gradientJobs.empty() && gradientJobs.front().publishTick <= now) + { + gradientWake.wait(lock, [this] { return gradientJobs.front().done; }); + GradientJob job = gradientJobs.front(); + gradientJobs.pop_front(); + std::swap(*job.slot, job.buffer); + spareGradients.push_back(job.buffer); + } +} + +void Map::finishPendingGradients() +{ + publishGradients(0xFFFFFFFF); + if (gradientWorker.joinable()) + { + { + std::lock_guard lock(gradientMutex); + gradientWorkerQuit = true; + gradientWake.notify_all(); + } + gradientWorker.join(); + } + for (Uint16 *spare : spareGradients) + delete[] spare; + spareGradients.clear(); +} +#endif // !YOG_SERVER_ONLY + Map::~Map(void) { clear(); @@ -93,6 +129,9 @@ Map::~Map(void) void Map::clear() { +#ifndef YOG_SERVER_ONLY + finishPendingGradients(); +#endif // A failed load can own only a subset of these arrays. for (int t=0; t +#include +#include +#include +#include +#include #include #include @@ -130,6 +135,11 @@ class Map #ifndef YOG_SERVER_ONLY //! Do a step associated with map (grow resources and process bullets) void syncStep(Uint32 stepCounter); + //! Refresh the resource and area fields round robin, one per tick: publishes the + //! field due this tick, then seeds the next one from the current map and hands it to + //! the worker thread, which propagates it while the units step. A field is seeded and + //! published at fixed ticks, so its content never depends on thread timing. + void stepGradients(); #endif // !YOG_SERVER_ONLY //! Switch the Fog of War bufferResourceType void switchFogOfWar(void); @@ -627,9 +637,15 @@ class Map void updateGlobalGradient(Uint8 *gradient); //! Dijkstra on a freshly seeded field (see MapInternal.h). Seed costs must be //! between 0 and the largest terrain step (currently 42); do not pass a completed - //! field. Uses shared scratch storage: calls across all Maps must be serial and - //! non-reentrant. swimClass must be in [0, SWIM_CLASS_COUNT). + //! field. swimClass must be in [0, SWIM_CLASS_COUNT). This overload is for the main + //! thread and uses its scratch; the worker thread passes its own scratch below, so + //! two fields can propagate concurrently. void propagateGradient(Uint16 *gradient, int swimClass); + struct GradientScratch; + struct GradientScratchDeleter { void operator()(GradientScratch *scratch) const; }; + typedef std::unique_ptr GradientScratchPtr; + void propagateGradient(Uint16 *gradient, int swimClass, GradientScratch &scratch); + static GradientScratchPtr newGradientScratch(); //! Step toward the neighbour with the highest value minus step cost. strict requires //! real progress; otherwise a random sidestep to an equal cell is accepted when blocked. bool directionByGradient(Uint32 teamMask, int swimClass, int x, int y, const Uint16 *gradient, int *dx, int *dy, bool strict) const; @@ -729,6 +745,35 @@ class Map // Used to attract idle workers into clearing // areas that aren't clear Uint16 *clearAreasGradient[Team::MAX_COUNT][SWIM_CLASS_COUNT]; + + // Round-robin fields in flight. A job is seeded at tick T into its own buffer, + // propagated by the worker thread, and published (buffer swapped into the slot) + // at tick T + GRADIENT_PIPELINE_TICKS. The slot keeps its previous buffer + // readable until then; afterwards that buffer becomes a spare for a later job. + struct GradientJob + { + Uint16 **slot = NULL; + Uint16 *buffer = NULL; + int swimClass = 0; + Uint32 publishTick = 0; + bool done = false; + }; + std::deque gradientJobs; + std::vector spareGradients; + std::thread gradientWorker; + std::mutex gradientMutex; + std::condition_variable gradientWake; + bool gradientWorkerQuit = false; + //! Pick and seed the next field of the round robin into job.buffer; false if none is in use. + bool pickRoundRobinField(GradientJob &job); + void startGradientWorker(); + //! Publish every job due at or before tick now, waiting for the worker if needed. + void publishGradients(Uint32 now); + //! Publish every job in flight and stop the worker (before freeing gradients). + void finishPendingGradients(); + void seedResourcesGradient(int teamNumber, Uint8 resourceType, int swimClass, Uint16 *gradient); + void seedGuardAreasGradient(int teamNumber, int swimClass, Uint16 *gradient); + void seedClearAreasGradient(int teamNumber, int swimClass, Uint16 *gradient); public: // Used to guide explorers diff --git a/src/map/MapStep.cpp b/src/map/MapStep.cpp index 5fd91c69c..f14d003ae 100644 --- a/src/map/MapStep.cpp +++ b/src/map/MapStep.cpp @@ -12,6 +12,7 @@ #endif // !YOG_SERVER_ONLY #include +#include // growResources, syncStep, fog of war, discovery, explored area @@ -92,50 +93,128 @@ void Map::syncStep(Uint32 stepCounter) if (team < game->mapHeader.getNumberOfTeams()) updateExploredArea(team); } - - // We only update one gradient per step, round robin over the gradients in use: - bool updated=false; - while (!updated) +} + +namespace { + +// Ticks between seeding a round-robin field and publishing it. The worker has +// this long to propagate it; the units read the previous field meanwhile. Part of +// the simulation: every peer must use the same value. +constexpr Uint32 GRADIENT_PIPELINE_TICKS = 3; + +} // namespace + +void Map::startGradientWorker() +{ + if (gradientWorker.joinable()) + return; + gradientWorkerQuit = false; + gradientWorker = std::thread([this] { - int numberOfTeam=game->mapHeader.getNumberOfTeams(); - for (int t=0; t lock(gradientMutex); + for (;;) + { + // Jobs are propagated in order; the first undone one is ours. + GradientJob *job = NULL; + gradientWake.wait(lock, [this, &job] + { + if (gradientWorkerQuit) + return true; + for (GradientJob &j : gradientJobs) + if (!j.done) + { + job = &j; + return true; + } + return false; + }); + if (gradientWorkerQuit) + return; + Uint16 *buffer = job->buffer; + int swimClass = job->swimClass; + lock.unlock(); + propagateGradient(buffer, swimClass, *scratch); + lock.lock(); + // publishGradients pops only done jobs from the front and waits on this + // one, so it is still in the deque; find it again by buffer in case the + // deque reallocated while we were unlocked. + for (GradientJob &j : gradientJobs) + if (j.buffer == buffer) + j.done = true; + gradientWake.notify_all(); + } + }); +} + +void Map::stepGradients() +{ + const Uint32 now = game->stepCounter; + publishGradients(now); + if (spareGradients.empty()) + spareGradients.push_back(new Uint16[size]); + GradientJob job; + job.buffer = spareGradients.back(); + if (!pickRoundRobinField(job)) + return; + spareGradients.pop_back(); + job.publishTick = now + GRADIENT_PIPELINE_TICKS; + startGradientWorker(); + std::lock_guard lock(gradientMutex); + gradientJobs.push_back(job); + gradientWake.notify_all(); +} + +bool Map::pickRoundRobinField(GradientJob &p) +{ + const int numberOfTeam = game->mapHeader.getNumberOfTeams(); + for (int pass = 0; pass < 2; pass++) + { + for (int t = 0; t < numberOfTeam; t++) + for (int r = 0; r < MAX_RESOURCES; r++) + for (int s = 0; s < SWIM_CLASS_COUNT; s++) if (resourcesGradient[t][r][s] && !gradientUpdated[t][r][s]) { - updateResourcesGradient(t, r, s); - gradientUpdated[t][r][s]=true; - return; + gradientUpdated[t][r][s] = true; + p.slot = &resourcesGradient[t][r][s]; + p.swimClass = s; + seedResourcesGradient(t, r, s, p.buffer); + return true; } - for (int t=0; t 0; @@ -134,7 +140,6 @@ void Map::updateGuardAreasGradient(int teamNumber, int swimClass) gradient[i] = GRADIENT_UNREACHABLE; } - propagateGradient(gradient, swimClass); } void Map::updateGuardAreasGradient(int teamNumber) @@ -154,6 +159,12 @@ void Map::updateGuardAreasGradient() void Map::updateClearAreasGradient(int teamNumber, int swimClass) { Uint16 *gradient = clearAreasGradient[teamNumber][swimClass]; + seedClearAreasGradient(teamNumber, swimClass, gradient); + propagateGradient(gradient, swimClass); +} + +void Map::seedClearAreasGradient(int teamNumber, int swimClass, Uint16 *gradient) +{ assert(gradient); bool canSwim = swimClass > 0; @@ -177,7 +188,6 @@ void Map::updateClearAreasGradient(int teamNumber, int swimClass) gradient[i] = GRADIENT_UNREACHABLE; } - propagateGradient(gradient, swimClass); } void Map::updateClearAreasGradient(int teamNumber) diff --git a/src/map/gradient/MapGradientField.cpp b/src/map/gradient/MapGradientField.cpp index f39d8a270..8fa6d045b 100644 --- a/src/map/gradient/MapGradientField.cpp +++ b/src/map/gradient/MapGradientField.cpp @@ -2,6 +2,7 @@ // Copyright (C) 2026 glob2 contributors #include "Map.h" +#include #include "MapInternal.h" #include "Utilities.h" @@ -25,10 +26,31 @@ namespace // Costs above this would run into the sentinels; propagation stops there. constexpr int COST_LIMIT = GRADIENT_AT_GOAL - GRADIENT_UNREACHABLE - 1 - MAX_STEP; - // Shared scratch storage retains capacity between fields. Calls must be serial - // and non-reentrant, including calls on different Maps. Before parallelizing - // propagation, give each worker its own workspace; these are not cached fields. +} + +// Propagation scratch: the bucket queues. One per thread that propagates (the +// main thread's below, the worker's on its own stack); capacity is retained +// between fields. Not a cache: it holds no field data between calls. +struct Map::GradientScratch +{ std::vector buckets[BUCKETS]; +}; + +void Map::GradientScratchDeleter::operator()(GradientScratch *scratch) const +{ + delete scratch; +} + +Map::GradientScratchPtr Map::newGradientScratch() +{ + return GradientScratchPtr(new GradientScratch); +} + +void Map::propagateGradient(Uint16 *gradient, int swimClass) +{ + // Main-thread callers only (units, AIs, editor), which are serial. + static GradientScratch mainScratch; + propagateGradient(gradient, swimClass, mainScratch); } static_assert(WATER_STEP[Map::SWIM_CLASS_EVEN] == GRADIENT_STEP); @@ -69,22 +91,23 @@ int Map::stepCost(int dx, int dy, size_t targetIndex, int swimClass) const // (0 for GRADIENT_AT_GOAL). Seed costs must be in [0, MAX_STEP], so the initial // queue fits one bucket rotation. Reseed before reuse; a propagated field is // not a valid seed buffer. GRADIENT_FORBIDDEN cells are obstacles. -void Map::propagateGradient(Uint16 *gradient, int swimClass) +void Map::propagateGradient(Uint16 *gradient, int swimClass, GradientScratch &scratch) { + std::vector (&bk)[BUCKETS] = scratch.buckets; for (int b = 0; b < BUCKETS; b++) - buckets[b].clear(); + bk[b].clear(); size_t pending = 0; for (size_t i = 0; i < size; i++) if (gradient[i] > GRADIENT_UNREACHABLE) { int cost = GRADIENT_AT_GOAL - gradient[i]; assert(cost <= MAX_STEP); - buckets[cost % BUCKETS].push_back((int)i); + bk[cost % BUCKETS].push_back((int)i); pending++; } for (int cur = 0; pending > 0 && cur <= COST_LIMIT; cur++) { - std::vector &bucket = buckets[cur % BUCKETS]; + std::vector &bucket = bk[cur % BUCKETS]; // Relaxations may append to other buckets but never to this one // (each step is positive and less than BUCKETS), so iteration is safe. for (size_t bi = 0; bi < bucket.size(); bi++) @@ -103,8 +126,8 @@ void Map::propagateGradient(Uint16 *gradient, int swimClass) // All reverse edges enter i, so they share its two terrain costs. const int cardinalCost = cur + stepCost(1, 0, (size_t)i, swimClass); const int diagonalCost = cur + stepCost(1, 1, (size_t)i, swimClass); - auto& cardinalBucket = buckets[cardinalCost % BUCKETS]; - auto& diagonalBucket = buckets[diagonalCost % BUCKETS]; + auto& cardinalBucket = bk[cardinalCost % BUCKETS]; + auto& diagonalBucket = bk[diagonalCost % BUCKETS]; auto relax = [&](size_t n, int cost, std::vector& destination) { if (gradient[n] != GRADIENT_FORBIDDEN && cost < GRADIENT_AT_GOAL - gradient[n]) diff --git a/src/map/gradient/MapGradientGlobal.cpp b/src/map/gradient/MapGradientGlobal.cpp index c694d531f..f406857b9 100644 --- a/src/map/gradient/MapGradientGlobal.cpp +++ b/src/map/gradient/MapGradientGlobal.cpp @@ -120,6 +120,12 @@ Uint16 *Map::getResourceGradient(int teamNumber, int resourceType, int swimClass void Map::updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimClass) { Uint16 *gradient=resourcesGradient[teamNumber][resourceType][swimClass]; + seedResourcesGradient(teamNumber, resourceType, swimClass, gradient); + propagateGradient(gradient, swimClass); +} + +void Map::seedResourcesGradient(int teamNumber, Uint8 resourceType, int swimClass, Uint16 *gradient) +{ assert(gradient); bool canSwim = swimClass > 0; @@ -152,5 +158,4 @@ void Map::updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimCl gradient[i]=GRADIENT_FORBIDDEN; } - propagateGradient(gradient, swimClass); } diff --git a/src/map/io/MapIO.cpp b/src/map/io/MapIO.cpp index ea3b5e630..f33ab18a1 100644 --- a/src/map/io/MapIO.cpp +++ b/src/map/io/MapIO.cpp @@ -268,6 +268,9 @@ void Map::addTeam(void) void Map::removeTeam(void) { +#ifndef YOG_SERVER_ONLY + finishPendingGradients(); +#endif int numberOfTeam=game->mapHeader.getNumberOfTeams(); assert(numberOfTeam