From 63741c7bd4e6a4e5f56eed0a0961e2897848540c Mon Sep 17 00:00:00 2001 From: Scott Mackie Date: Fri, 25 Sep 2026 19:11:25 -0700 Subject: [PATCH] perf(gota): avoid redundant simulation copies and observation work --- coworld/wasm.md | 3 +- examples/gods_of_the_arena/config.nims | 3 + examples/gods_of_the_arena/sim.nim | 107 ++++++++++++------------- src/polyworld/tapes.nim | 2 +- src/polyworld/visions.nim | 20 +++-- tests/test_gota_observations.nim | 30 +++++++ 6 files changed, 102 insertions(+), 63 deletions(-) diff --git a/coworld/wasm.md b/coworld/wasm.md index 1c7943f5..bfa2f121 100644 --- a/coworld/wasm.md +++ b/coworld/wasm.md @@ -18,7 +18,8 @@ Only the immutable compiled module may be shared across attempts. The build uses ARC and Emscripten's default allocator directly. Nim allocation tracing is disabled because its counters require Nim's allocator. This affects runtime instrumentation, not game behavior. Linear memory grows from 16 MiB to a -96 MiB cap. That cap leaves room for the host but does not guarantee a complete Worker fits its isolate limit. +96 MiB cap, and allocation failure aborts the instance. That cap leaves room for the host but does not guarantee a +complete Worker fits its isolate limit. ## ABI version 1 diff --git a/examples/gods_of_the_arena/config.nims b/examples/gods_of_the_arena/config.nims index 9f557ea5..ce0f2e76 100644 --- a/examples/gods_of_the_arena/config.nims +++ b/examples/gods_of_the_arena/config.nims @@ -19,4 +19,7 @@ when defined(coworldWasm): switch("gc", "arc") switch("exceptions", "goto") switch("define", "noSignalHandler") + switch("passC", "-flto") + switch("passL", "-flto") + switch("passL", "-s ABORTING_MALLOC=1") switch("passL", "-O3 -s MODULARIZE=1 -s EXPORT_ES6=1 -s ENVIRONMENT=worker -s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=16777216 -s MAXIMUM_MEMORY=100663296 -s EXPORTED_FUNCTIONS=_main,_pw_alloc,_pw_free,_pw_initialize,_pw_advance,_pw_finalize,_pw_output,_pw_output_length -s EXPORTED_RUNTIME_METHODS=HEAPU8 -s FILESYSTEM=0") diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index 7f4677c9..59780469 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -305,8 +305,8 @@ type teamExplored*: array[2, seq[uint8]] visionCache: array[2, VisionCache] visionSkipKeys: seq[int32] - scriptObjects: seq[WorldObject] - scriptObjectCount: int + scriptObjects: array[Team, seq[WorldObject]] + scriptObjectsReady: array[Team, bool] scriptObjectsHeroId: int32 scriptObjectsTick: int32 observationsFrozen: bool @@ -2103,7 +2103,7 @@ proc rawWorldObjectAt(world: World, index: int, value: var WorldObject): bool = return true let buildingIndex = index - world.forts.len if buildingIndex < world.buildings.len: - let tower = world.buildings[buildingIndex] + let tower {.cursor.} = world.buildings[buildingIndex] value = WorldObject( id: tower.id, kind: (if tower.kind == TowerBuilding: TowerObjectKind @@ -2147,7 +2147,7 @@ proc rawWorldObjectAt(world: World, index: int, value: var WorldObject): bool = return true let footmanIndex = heroIndex - world.heroes.len if footmanIndex < world.footmen.len: - let footman = world.footmen[footmanIndex] + let footman {.cursor.} = world.footmen[footmanIndex] value = WorldObject( id: footman.id, kind: (if footman.camp > 0: NeutralObjectKind else: FootmanObjectKind), @@ -2202,7 +2202,7 @@ proc freezeObservations*(world: World): bool = cmp(world.spellObservationKey(first, team), world.spellObservationKey(second, team)) ) - world.scriptObjectsTick = -1 + world.scriptObjectsReady = [false, false] world.observationsFrozen = true true @@ -2212,7 +2212,7 @@ proc thawObservations*(world: World) = world.observedObjects.setLen(0) for spells in world.observedSpells.mitems: spells.setLen(0) - world.scriptObjectsTick = -1 + world.scriptObjectsReady = [false, false] iterator observedCasts*(world: World, team: Team): SpellCast = ## Reads the common decision frame, or live casts outside that phase. @@ -2241,44 +2241,42 @@ proc scriptObjectKey(value: WorldObject, observer: Team): (group, int(value.faction != observer.ord.int32), direction * value.position.z, direction * value.position.x, value.id) -proc ensureScriptObjects(world: World, heroId: int32) = - ## Rebuilds the visible object list once per hero decision tick. - if world.scriptObjectsHeroId == heroId and - world.scriptObjectsTick == world.tick: - return - world.scriptObjectCount = 0 +proc ensureScriptObjects(world: World, heroId: int32): int = + ## A frozen frame has one visibility and ordering per team, shared by its heroes. let observer = heroIndex(world, heroId) - if observer >= 0: - let team = world.heroes[observer].team - var value: WorldObject - let count = - if world.observationsFrozen: world.observedObjects.len - else: rawWorldObjectCount(world) - for i in 0 ..< count: - if world.observationsFrozen: - value = world.observedObjects[i] - elif not rawWorldObjectAt(world, i, value): - continue - if not objectVisibleTo(world, team, value) or - (value.kind in [TowerObjectKind, BarracksObjectKind] and value.hp <= 0): - continue - if world.scriptObjectCount == world.scriptObjects.len: - world.scriptObjects.add value - else: - world.scriptObjects[world.scriptObjectCount] = value - inc world.scriptObjectCount - world.scriptObjects.setLen(world.scriptObjectCount) - world.scriptObjects.sort(proc(first, second: WorldObject): int = - ## Orders observed identities in the querying team's coordinate frame. - cmp(first.scriptObjectKey(team), second.scriptObjectKey(team)) - ) + if observer < 0: + world.scriptObjectsReady = [false, false] + return -1 + let team = world.heroes[observer].team + result = team.ord + if world.scriptObjectsReady[team] and (world.observationsFrozen or + (world.scriptObjectsHeroId == heroId and world.scriptObjectsTick == world.tick)): + return + world.scriptObjects[team].setLen(0) + var value: WorldObject + let count = + if world.observationsFrozen: world.observedObjects.len + else: rawWorldObjectCount(world) + for i in 0 ..< count: + if world.observationsFrozen: + value = world.observedObjects[i] + elif not rawWorldObjectAt(world, i, value): + continue + if not objectVisibleTo(world, team, value) or + (value.kind in [TowerObjectKind, BarracksObjectKind] and value.hp <= 0): + continue + world.scriptObjects[team].add value + world.scriptObjects[team].sort(proc(first, second: WorldObject): int = + cmp(first.scriptObjectKey(team), second.scriptObjectKey(team)) + ) + world.scriptObjectsReady[team] = true world.scriptObjectsHeroId = heroId world.scriptObjectsTick = world.tick proc worldObjectCount*(world: World, heroId: int32): int = ## Returns the number of objects visible to one hero script. - world.ensureScriptObjects(heroId) - world.scriptObjectCount + let team = world.ensureScriptObjects(heroId) + if team >= 0: world.scriptObjects[Team(team)].len else: 0 proc worldObjectAt*( world: World, @@ -2287,10 +2285,10 @@ proc worldObjectAt*( value: var WorldObject ): bool = ## Reads one object from a hero's stable visibility-filtered enumeration. - world.ensureScriptObjects(heroId) - if index < 0 or index >= world.scriptObjectCount: + let team = world.ensureScriptObjects(heroId) + if team < 0 or index < 0 or index >= world.scriptObjects[Team(team)].len: return false - value = world.scriptObjects[index] + value = world.scriptObjects[Team(team)][index] true proc worldObjectById*( @@ -3062,7 +3060,7 @@ proc updateTower*(world: World, tower: var Building) = targetFootman = footmanIndex(world, tower.targetId) targetHero = heroIndex(world, tower.targetId) if targetFootman >= 0: - let footman = world.footmen[targetFootman] + let footman {.cursor.} = world.footmen[targetFootman] if not world.hostile(footman, tower.team) or footman.state == Dying or footman.hp <= 0 or not within(tower.position, footman.position, attackRange) or @@ -3080,7 +3078,8 @@ proc updateTower*(world: World, tower: var Building) = bestSquared = int64(attackRange) * attackRange bestId = 0'i32 bestPosition: WorldPoint - for i, footman in world.footmen: + for i in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[i] if not world.hostile(footman, tower.team) or footman.state == Dying or footman.hp <= 0 or not visible(world, tower.team, footman.position): @@ -3493,7 +3492,7 @@ proc updateFootman(world: World, footman: var Footman) = targetHero = heroIndex(world, footman.targetHeroId) targetBuilding = buildingIndex(world, footman.targetBuildingId) if targetFootman >= 0: - let other = world.footmen[targetFootman] + let other {.cursor.} = world.footmen[targetFootman] if not world.hostile(other, footman.team) or not visible(world, footman.team, other.position) or not within( @@ -3527,7 +3526,8 @@ proc updateFootman(world: World, footman: var Footman) = bestSquared = int64(FootmanSightRadius) * FootmanSightRadius bestId = 0'i32 bestPosition: WorldPoint - for i, other in world.footmen: + for i in 0 ..< world.footmen.len: + let other {.cursor.} = world.footmen[i] if not world.hostile(other, footman.team): continue if not visible(world, footman.team, other.position): @@ -3824,7 +3824,7 @@ proc applyDraft*( hero.maxMana = heroMaxMana(hero.class, hero.level) hero.mana = hero.maxMana hero.initHeroCharges() - world.scriptObjectsTick = -1 + world.scriptObjectsReady = [false, false] inc world.draftTurn world.draftTurnTicks = 0 if world.draftTurn == world.draftOrder.len: @@ -5114,7 +5114,8 @@ proc separateUnits(game: Game) = let world = game.world var maximumRadius = FixedZero game.collisionUnits.setLen(0) - for i, footman in world.footmen: + for i in 0 ..< world.footmen.len: + let footman {.cursor.} = world.footmen[i] if footman.state != Dying: game.collisionUnits.add CollisionUnit(body: footman.body, index: i, layer: footman.navLayer, team: footman.team, @@ -5506,8 +5507,8 @@ proc tickWorld*(game: Game, onHeroTurn: proc() {.closure.}) {.measure.} = # Plan every unit against the same actor state, then publish together. game.nextFootmen.setLen(world.footmen.len) - for i, footman in world.footmen: - game.nextFootmen[i] = footman + for i in 0 ..< world.footmen.len: + game.nextFootmen[i] = world.footmen[i] game.nextHeroes.setLen(world.heroes.len) for i, hero in world.heroes: if game.nextHeroes[i] == nil: @@ -5528,7 +5529,7 @@ proc tickWorld*(game: Game, onHeroTurn: proc() {.closure.}) {.measure.} = swap(world.footmen, game.nextFootmen) for i, hero in game.nextHeroes: - world.heroes[i][] = hero[] + swap(world.heroes[i][], hero[]) world.advanceSpells() world.advanceTowerShots() @@ -5540,7 +5541,7 @@ proc tickWorld*(game: Game, onHeroTurn: proc() {.closure.}) {.measure.} = if world.footmen[read].state != Dying or world.footmen[read].deathTicks < FootmanDeathTicks + CorpseLingerTicks: if write != read: - world.footmen[write] = world.footmen[read] + world.footmen[write] = move(world.footmen[read]) inc write else: when defined(replayEvents): @@ -5725,9 +5726,7 @@ proc newGame*( world: World( forts: startingForts(map), nextFootmanId: FirstFootmanId, - winner: RedTeam, - scriptObjects: newSeqOfCap[WorldObject](256), - scriptObjectsTick: -1 + winner: RedTeam ), map: map, replayMode: replayMode, diff --git a/src/polyworld/tapes.nim b/src/polyworld/tapes.nim index ed2c0de6..39a642ba 100644 --- a/src/polyworld/tapes.nim +++ b/src/polyworld/tapes.nim @@ -124,7 +124,7 @@ proc encodeReplayFile*[T]( result.addUint16(gameVersion) result.addUint16(uint16(game.len)) result.add game - result.add data.toFlatty() + result.toFlatty(data) if result.len > maxBytes: fail("encoded replay exceeds the file size limit") diff --git a/src/polyworld/visions.nim b/src/polyworld/visions.nim index 1c41e123..aa63a2d1 100644 --- a/src/polyworld/visions.nim +++ b/src/polyworld/visions.nim @@ -357,6 +357,10 @@ proc revealVision*( ): visible[index] = 255 +proc sameHeights(first, second: seq[int16]): bool = + first.len == second.len and (first.len == 0 or + equalMem(unsafeAddr first[0], unsafeAddr second[0], first.len * sizeof(int16))) + proc revealVisionCached*( cache: var VisionCache, visible: var seq[uint8], @@ -367,7 +371,8 @@ proc revealVisionCached*( ## Retains only the previous frame's source rays. Terrain or blocker changes ## invalidate every entry, including height changes without moving a source. if cache.width != width or cache.height != height or - cache.terrain != terrainHeights or cache.blockers != blockerHeights: + not sameHeights(cache.terrain, terrainHeights) or + not sameHeights(cache.blockers, blockerHeights): cache.sources.clear() cache.width = width cache.height = height @@ -376,22 +381,23 @@ proc revealVisionCached*( visible.setLen(int(width * height)) for value in visible.mitems: value = 0 - var nextSources: Table[VisionSource, seq[int32]] + var nextSources = initTable[VisionSource, seq[int32]](sources.len) for source in sources: if nextSources.hasKey(source): continue - if not cache.sources.hasKey(source): - var cells: seq[int32] + var cells: seq[int32] + cache.sources.withValue(source, previous): + cells = move(previous[]) + do: for z in max(0'i32, source.z - source.radius) .. min(height - 1, source.z + source.radius): for x in max(0'i32, source.x - source.radius) .. min(width - 1, source.x + source.radius): if source.radius > 0 and source.inVisionRange(x, z) and lineVisible( width, height, terrainHeights, blockerHeights, source.x, source.z, x, z, source.radius, source.eyeHeight): cells.add z * width + x - cache.sources[source] = move(cells) - for index in cache.sources[source]: + for index in cells: visible[index] = 255 - nextSources[source] = move(cache.sources[source]) + nextSources[source] = move(cells) cache.sources = move(nextSources) proc blurVisibility*(visible: openArray[uint8], width, height: int32): seq[uint8] = diff --git a/tests/test_gota_observations.nim b/tests/test_gota_observations.nim index 4ffd8ac0..f0b1aa86 100644 --- a/tests/test_gota_observations.nim +++ b/tests/test_gota_observations.nim @@ -22,6 +22,36 @@ proc reveal(world: World, team: Team, position: WorldPoint) = z = mapCoordinate(position.z) world.teamVisible[team.ord][int(z) * mapTiles() + int(x)] = 255 +proc objects(world: World, heroId: int32): seq[WorldObject] = + for i in 0 ..< world.worldObjectCount(heroId): + var value: WorldObject + doAssert world.worldObjectAt(heroId, i, value) + result.add value + +echo "Testing team observations stay frozen and refresh for the next frame" +block: + let world = observationWorld() + world.heroes[0].hp = 100 + let + red = world.objects(100) + blue = world.objects(105) + doAssert red != blue + doAssert world.objects(100) == red + world.heroes[0].hp = 75 + doAssert world.objects(100) == red + world.heroes[0].hp = 100 + doAssert world.freezeObservations() + doAssert world.objects(100) == red + world.heroes[0].hp = 50 + doAssert world.objects(105) == blue + doAssert world.objects(101) == red + world.thawObservations() + doAssert world.freezeObservations() + let next = world.objects(101) + doAssert next != red + doAssert world.objects(100) == next + world.thawObservations() + proc warning(heroId: int32, x = 0'i32): SpellCast = ## Creates a delayed spell whose aim position differs from its origin. SpellCast(