Skip to content

Commit 665cf27

Browse files
authored
Add world metrics (#2573)
* add `Metric` fused effect and re-export it from `Swarm.Effect` in utility library (moving Time effect) * add `Swarm.Game.World.Metrics` module with `WorldMetrics` stored in `Landscape` state * game.tile_loaded * game.tile_in_memory * game.tile_average_load_time * game.tiles_batch_load_time * add `loadRegionM` (and `loadCellM`) function that caches tile and updates metrics * make World state use [`strict`](https://hackage.haskell.org/package/strict-0.5) pair and maybe types * this makes the measurements meaningful but otherwise isn't a big change as the tile would be forced soon enough This comes with the major caveat that only the time loading tiles by the world view in TUI is measured. You can see the disparity in the graph: ```bash cabal run swarm -- --seed 0 --scenario Creative --run <(echo 'build {require "boat"; turn south; let m = move; m in m}') ``` <img width="400" alt="Screenshot 2025-08-31 at 12 41 43 AM" src="https://github.com/user-attachments/assets/1a13a941-9c1d-4885-bc2e-ac06f0bc4b27" /> Adding effects to other world functions changes the type of few very common functions in Swarm Engine (e.g. structure recognizer) which were only using the `GameState`. I will do that in a follow up PR.
1 parent 9758a2a commit 665cf27

File tree

17 files changed

+235
-52
lines changed

17 files changed

+235
-52
lines changed

src/swarm-engine/Swarm/Game/State.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -570,11 +570,11 @@ zoomRobots n = do
570570
zoomWorld ::
571571
(Has (State GameState) sig m) =>
572572
SubworldName ->
573-
Fused.StateC (W.World Int Entity) Identity b ->
573+
Fused.StateC (W.World Int Entity) m b ->
574574
m (Maybe b)
575575
zoomWorld swName n = do
576576
mw <- use $ landscape . multiWorld
577577
forM (M.lookup swName mw) $ \w -> do
578-
let (w', a) = run (Fused.runState w n)
578+
(w', a) <- Fused.runState w n
579579
landscape . multiWorld %= M.insert swName w'
580580
return a

src/swarm-engine/Swarm/Game/State/Initialize.hs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ import Swarm.Game.Scenario.Topography.Structure.Recognition.Precompute
4545
import Swarm.Game.Scenario.Topography.Structure.Recognition.Registry (emptyFoundStructures)
4646
import Swarm.Game.Scenario.Topography.Structure.Recognition.Type
4747
import Swarm.Game.State
48-
import Swarm.Game.State.Landscape (mkLandscape)
48+
import Swarm.Game.State.Landscape (mkLandscape, worldMetrics)
4949
import Swarm.Game.State.Runtime
5050
import Swarm.Game.State.Substate
5151
import Swarm.Game.Step.Util (adaptGameState)
52-
import Swarm.Game.World (Seed)
52+
import Swarm.Game.World (Seed, WorldMetrics, initWorldMetrics)
5353
import Swarm.Language.Capability (constCaps)
5454
import Swarm.Language.Syntax (allConst, erase)
5555
import Swarm.Language.Types
@@ -62,23 +62,26 @@ scenarioToGameState ::
6262
ScenarioWith (Maybe ScenarioPath) ->
6363
ValidatedLaunchParams ->
6464
Maybe GameMetrics ->
65+
Maybe WorldMetrics ->
6566
RuntimeState ->
6667
IO GameState
67-
scenarioToGameState si@(ScenarioWith scenario _) (LaunchParams (Identity userSeed) (Identity toRun)) prevMetric rs = do
68+
scenarioToGameState si@(ScenarioWith scenario _) (LaunchParams (Identity userSeed) (Identity toRun)) prevGMetric prevWMetric rs = do
6869
theSeed <- arbitrateSeed userSeed $ scenario ^. scenarioLandscape
6970
now <- Clock.getTime Clock.Monotonic
70-
gMetric <- maybe (initGameMetrics $ rs ^. metrics) pure prevMetric
71-
return $ pureScenarioToGameState si theSeed now toRun (Just gMetric) (rs ^. stdGameConfigInputs)
71+
gMetric <- maybe (initGameMetrics $ rs ^. metrics) pure prevGMetric
72+
wMetric <- maybe (initWorldMetrics $ rs ^. metrics) pure prevWMetric
73+
return $ pureScenarioToGameState si theSeed now toRun (Just gMetric) (Just wMetric) (rs ^. stdGameConfigInputs)
7274

7375
pureScenarioToGameState ::
7476
ScenarioWith (Maybe ScenarioPath) ->
7577
Seed ->
7678
Clock.TimeSpec ->
7779
Maybe CodeToRun ->
7880
Maybe GameMetrics ->
81+
Maybe WorldMetrics ->
7982
GameStateConfig ->
8083
GameState
81-
pureScenarioToGameState (ScenarioWith scenario fp) theSeed now toRun gMetric gsc =
84+
pureScenarioToGameState (ScenarioWith scenario fp) theSeed now toRun gMetric wMetric gsc =
8285
preliminaryGameState
8386
& discovery . structureRecognition .~ recognition
8487
where
@@ -110,6 +113,7 @@ pureScenarioToGameState (ScenarioWith scenario fp) theSeed now toRun gMetric gsc
110113
& randomness . randGen .~ mkStdGen theSeed
111114
& recipesInfo %~ modifyRecipesInfo
112115
& landscape .~ mkLandscape sLandscape worldTuples theSeed
116+
& landscape . worldMetrics .~ wMetric
113117
& gameControls . initiallyRunCode .~ (erase <$> initialCodeToRun)
114118
& gameControls . replStatus .~ case running of -- When the base starts out running a program, the REPL status must be set to working,
115119
-- otherwise the store of definition cells is not saved (see #333, #838)

src/swarm-scenario/Swarm/Game/State/Landscape.hs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module Swarm.Game.State.Landscape (
1414
worldScrollable,
1515
terrainAndEntities,
1616
recognizerAutomatons,
17+
worldMetrics,
1718

1819
-- ** Utilities
1920
initLandscape,
@@ -64,6 +65,7 @@ data Landscape = Landscape
6465
, _terrainAndEntities :: TerrainEntityMaps
6566
, _recognizerAutomatons :: RecognizerAutomatons RecognizableStructureContent Entity
6667
, _worldScrollable :: Bool
68+
, _worldMetrics :: Maybe WorldMetrics
6769
}
6870

6971
makeLensesNoSigs ''Landscape
@@ -87,6 +89,9 @@ recognizerAutomatons :: Lens' Landscape (RecognizerAutomatons RecognizableStruct
8789
-- | Whether the world map is supposed to be scrollable or not.
8890
worldScrollable :: Lens' Landscape Bool
8991

92+
-- | Metrics tracked for the Swarm World, namely tile load time and cache. See 'RuntimeState' metrics store.
93+
worldMetrics :: Lens' Landscape (Maybe WorldMetrics)
94+
9095
-- | Create an record that is empty except for
9196
-- system-provided entities.
9297
initLandscape :: GameStateConfig -> Landscape
@@ -97,6 +102,7 @@ initLandscape gsc =
97102
, _terrainAndEntities = initEntityTerrain $ gsiScenarioInputs $ initState gsc
98103
, _recognizerAutomatons = RecognizerAutomatons mempty mempty
99104
, _worldScrollable = True
105+
, _worldMetrics = Nothing
100106
}
101107

102108
mkLandscape :: ScenarioLandscape -> NonEmpty SubworldDescription -> Seed -> Landscape
@@ -110,6 +116,7 @@ mkLandscape sLandscape worldTuples theSeed =
110116
-- Leaning toward no, but for now just adopt the root world scrollability
111117
-- as being universal.
112118
_worldScrollable = NE.head (sLandscape ^. scenarioWorlds) ^. to scrollable
119+
, _worldMetrics = Nothing
113120
}
114121

115122
buildWorldTuples :: ScenarioLandscape -> NonEmpty SubworldDescription

src/swarm-scenario/Swarm/Game/World.hs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,8 @@ module Swarm.Game.World (
3333
lookupEntityM,
3434
lookupContentM,
3535
updateM,
36-
-- TODO: #2555
37-
-- loadCellM
38-
-- loadRegionM,
36+
loadCellM,
37+
loadRegionM,
3938

4039
-- * Multi-Worlds
4140
MultiWorld,
@@ -47,12 +46,14 @@ module Swarm.Game.World (
4746

4847
-- * Re-Exports
4948
Seed,
49+
module Metrics,
5050
module Coords,
5151
) where
5252

5353
import Swarm.Game.World.Coords as Coords
5454
import Swarm.Game.World.DSL.Gen (Seed)
5555
import Swarm.Game.World.Function
56+
import Swarm.Game.World.Metrics as Metrics
5657
import Swarm.Game.World.Multi
5758
import Swarm.Game.World.Pure
5859
import Swarm.Game.World.Stateful
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
{-# LANGUAGE RecordWildCards #-}
3+
{-# LANGUAGE NoFieldSelectors #-}
4+
5+
-- |
6+
-- SPDX-License-Identifier: BSD-3-Clause
7+
-- Description: Game-related state and utilities
8+
--
9+
-- Definition of metrics tracked for world.
10+
module Swarm.Game.World.Metrics (
11+
WorldMetrics (..),
12+
initWorldMetrics,
13+
) where
14+
15+
import System.Metrics
16+
import System.Metrics.Distribution (Distribution)
17+
import System.Metrics.Gauge (Gauge)
18+
19+
-- | Metrics tracked in Swarm game engine.
20+
data WorldMetrics = WorldMetrics
21+
{ loadedTiles :: Gauge
22+
, inMemoryTiles :: Gauge
23+
, tilesBatchLoadTime :: Distribution
24+
, tileAverageLoadTime :: Distribution
25+
}
26+
27+
-- | Create and register the metrics to metric store.
28+
--
29+
-- This function can be only called **once** on the store.
30+
initWorldMetrics :: Store -> IO WorldMetrics
31+
initWorldMetrics s = do
32+
loadedTiles <- createGauge "game.tile_loaded" s
33+
inMemoryTiles <- createGauge "game.tile_in_memory" s
34+
tileAverageLoadTime <- createDistribution "game.tile_average_load_time" s
35+
tilesBatchLoadTime <- createDistribution "game.tile_batch_load_time" s
36+
pure WorldMetrics {..}

src/swarm-scenario/Swarm/Game/World/Pure.hs

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,21 @@ module Swarm.Game.World.Pure (
3636
loadRegion,
3737
) where
3838

39-
import Control.Arrow ((&&&))
39+
import Control.Arrow (second)
4040
import Control.Lens hiding (use)
4141
import Data.Array qualified as A
4242
import Data.Array.IArray
4343
import Data.Array.Unboxed qualified as U
44-
import Data.Foldable (foldl')
44+
import Data.List qualified as List
4545
import Data.Map.Strict qualified as M
46+
import Data.Strict (toLazy, toStrict)
47+
import Data.Strict qualified as Strict
4648
import Swarm.Game.Entity (Entity, entityHash)
4749
import Swarm.Game.Scenario.Topography.Modify
4850
import Swarm.Game.World.Coords
4951
import Swarm.Game.World.Function
5052
import Swarm.Game.World.Tile
5153
import Swarm.Util ((?))
52-
import Prelude hiding (Foldable (..), lookup)
5354

5455
-- | A 'World' consists of a 'WorldFun' that specifies the initial
5556
-- world, a cache of loaded square tiles to make lookups faster, and
@@ -65,8 +66,8 @@ import Prelude hiding (Foldable (..), lookup)
6566
-- handle respawning.
6667
data World t e = World
6768
{ worldFun :: WorldFun t e
68-
, tileCache :: M.Map TileCoords (TerrainTile t, EntityTile e)
69-
, changed :: M.Map Coords (Maybe e)
69+
, tileCache :: M.Map TileCoords (Strict.Pair (TerrainTile t) (EntityTile e))
70+
, changed :: M.Map Coords (Strict.Maybe e)
7071
}
7172

7273
-- | Create a new 'World' from a 'WorldFun'.
@@ -81,7 +82,7 @@ newWorld f = World f M.empty M.empty
8182
-- given coordinates is loaded. For that, see 'lookupTerrainM'.
8283
lookupTerrain :: (IArray U.UArray t) => Coords -> World t e -> t
8384
lookupTerrain i (World f t _) =
84-
((U.! tileOffset i) . fst <$> M.lookup (tileCoords i) t)
85+
((U.! tileOffset i) . Strict.fst <$> M.lookup (tileCoords i) t)
8586
? fst (runWF f i)
8687

8788
-- | Look up the entity at certain coordinates: first, see if it is in
@@ -92,10 +93,11 @@ lookupTerrain i (World f t _) =
9293
-- This function does /not/ ensure that the tile containing the
9394
-- given coordinates is loaded. For that, see 'lookupEntityM'.
9495
lookupEntity :: Coords -> World t e -> Maybe e
95-
lookupEntity i (World f t m) =
96-
M.lookup i m
97-
? ((A.! tileOffset i) . snd <$> M.lookup (tileCoords i) t)
98-
? snd (runWF f i)
96+
lookupEntity i (World f t m) = modifiedEntity ? cachedTileEntity ? computedEntity
97+
where
98+
modifiedEntity = toLazy <$> M.lookup i m
99+
cachedTileEntity = toLazy . (A.! tileOffset i) . Strict.snd <$> M.lookup (tileCoords i) t
100+
computedEntity = snd (runWF f i)
99101

100102
-- | Update the entity (or absence thereof) at a certain location,
101103
-- returning an updated 'World' and a Boolean indicating whether
@@ -109,7 +111,7 @@ update ::
109111
update i g w@(World f t m) =
110112
(wNew, classifyModification (view entityHash) entityBefore entityAfter)
111113
where
112-
wNew = World f t $ M.insert i entityAfter m
114+
wNew = World f t $ M.insert i (toStrict entityAfter) m
113115
entityBefore = lookupEntity i w
114116
entityAfter = g entityBefore
115117

@@ -119,7 +121,7 @@ loadCell ::
119121
Coords ->
120122
World t e ->
121123
World t e
122-
loadCell c = loadRegion (c, c)
124+
loadCell c = fst . loadRegion (c, c)
123125

124126
-- | Load all the tiles which overlap the given rectangular region
125127
-- (specified as an upper-left and lower-right corner, inclusive).
@@ -128,18 +130,17 @@ loadRegion ::
128130
(IArray U.UArray t) =>
129131
(Coords, Coords) ->
130132
World t e ->
131-
World t e
132-
loadRegion reg (World f t m) = World f t' m
133+
(World t e, [TileCoords])
134+
loadRegion reg (World f t m) = (World f t' m, tileCs)
133135
where
134-
tiles = range (over both tileCoords reg)
135-
t' = foldl' (\hm (i, tile) -> maybeInsert i tile hm) t (map (id &&& loadTile) tiles)
136-
137-
maybeInsert k v tm
138-
| k `M.member` tm = tm
139-
| otherwise = M.insert k v tm
136+
-- the range is applied to tile coordinates, so we are not loading a tile twice
137+
tileCs = filter (`M.notMember` t) $ range (over both tileCoords reg)
138+
tiles = map loadTile tileCs
139+
t' = List.foldl' (\hm (i, tile) -> M.insert i tile hm) t (zip tileCs tiles)
140140

141-
loadTile :: TileCoords -> (TerrainTile t, EntityTile e)
142-
loadTile tc = (listArray tileBounds terrain, listArray tileBounds entities)
141+
loadTile :: TileCoords -> Strict.Pair (TerrainTile t) (EntityTile e)
142+
loadTile tc = listArray tileBounds terrain Strict.:!: listArray tileBounds entities
143143
where
144144
tileCorner = tileOrigin tc
145-
(terrain, entities) = unzip $ map (runWF f . plusOffset tileCorner) (range tileBounds)
145+
runWF' = second toStrict . runWF f
146+
(terrain, entities) = unzip $ map (runWF' . plusOffset tileCorner) (range tileBounds)

src/swarm-scenario/Swarm/Game/World/Stateful.hs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{-# LANGUAGE AllowAmbiguousTypes #-}
22
{-# LANGUAGE ConstraintKinds #-}
33
{-# LANGUAGE DerivingStrategies #-}
4+
{-# LANGUAGE OverloadedRecordDot #-}
45
{-# LANGUAGE TypeFamilies #-}
56

67
-- |
@@ -18,20 +19,32 @@ module Swarm.Game.World.Stateful (
1819

1920
-- ** Update
2021
updateM,
21-
-- TODO: #2555
22-
-- Loading
23-
-- loadCellM,
24-
-- loadRegionM,
22+
23+
-- ** Loading
24+
loadCellM,
25+
loadRegionM,
2526
) where
2627

2728
import Control.Algebra (Has)
2829
import Control.Effect.State (State, get, modify, state)
30+
import Control.Monad (unless, void)
2931
import Data.Array.IArray
3032
import Data.Array.Unboxed qualified as U
33+
import Data.Map.Strict qualified as M
34+
import Swarm.Effect qualified as Effect
3135
import Swarm.Game.Entity (Entity)
3236
import Swarm.Game.Scenario.Topography.Modify
3337
import Swarm.Game.World.Coords
38+
import Swarm.Game.World.Metrics
3439
import Swarm.Game.World.Pure
40+
import Swarm.Game.World.Tile
41+
42+
type HasWorldStateEffect t e sig m =
43+
( IArray U.UArray t
44+
, Has (State (World t e)) sig m
45+
, Has Effect.Metric sig m
46+
, Has Effect.Time sig m
47+
)
3548

3649
-- | A stateful variant of 'lookupTerrain', which first loads the tile
3750
-- containing the given coordinates if it is not already loaded,
@@ -77,3 +90,35 @@ updateM ::
7790
m (CellUpdate Entity)
7891
updateM c g = do
7992
state @(World t Entity) $ update c g . loadCell c
93+
94+
loadCellM ::
95+
forall t e sig m.
96+
HasWorldStateEffect t e sig m =>
97+
Maybe WorldMetrics ->
98+
Coords ->
99+
m ()
100+
loadCellM wm c = loadRegionM @t @e wm (c, c)
101+
102+
loadRegionM ::
103+
forall t e sig m.
104+
HasWorldStateEffect t e sig m =>
105+
Maybe WorldMetrics ->
106+
(Coords, Coords) ->
107+
m ()
108+
loadRegionM wm = updateMetric . state @(World t e) . loadRegion'
109+
where
110+
loadRegion' :: (Coords, Coords) -> World t e -> (World t e, [TileCoords])
111+
loadRegion' cc ow = let (nw, ts) = loadRegion cc ow in nw.tileCache `seq` (nw, ts)
112+
updateMetric :: m [TileCoords] -> m ()
113+
updateMetric m = case wm of
114+
Nothing -> void m
115+
Just wMetrics -> do
116+
(loadTime, loadedTiles) <- Effect.measureCpuTimeInSec m
117+
inMemoryTiles <- M.size . (.tileCache) <$> get @(World t e)
118+
Effect.gaugeSet wMetrics.inMemoryTiles inMemoryTiles
119+
unless (null loadedTiles) $ do
120+
let loadedCount = length loadedTiles
121+
let avgTime = loadTime / fromIntegral loadedCount
122+
Effect.gaugeAdd wMetrics.loadedTiles loadedCount
123+
Effect.distributionAdd wMetrics.tilesBatchLoadTime loadTime
124+
Effect.distributionAdd wMetrics.tileAverageLoadTime avgTime

src/swarm-scenario/Swarm/Game/World/Tile.hs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import Data.Array.IArray qualified as A
3333
import Data.Array.Unboxed qualified as U
3434
import Data.Bits
3535
import Data.Int (Int32)
36+
import Data.Strict qualified as Strict
3637
import GHC.Generics (Generic)
3738
import Swarm.Game.World.Coords
3839

@@ -42,7 +43,7 @@ type TerrainTile t = U.UArray TileOffset t
4243
-- | An entity tile is an array of possible entity values. Note it
4344
-- cannot be an unboxed array since entities are complex records
4445
-- which have to be boxed.
45-
type EntityTile e = A.Array TileOffset (Maybe e)
46+
type EntityTile e = A.Array TileOffset (Strict.Maybe e)
4647

4748
-- | The number of bits we need in each coordinate to represent all
4849
-- the locations in a tile. In other words, each tile has a size of

src/swarm-tournament/Swarm/Web/Tournament/Validate.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ gamestateFromScenarioText content = do
193193

194194
let scenarioInputs = gsiScenarioInputs . initState $ rs ^. stdGameConfigInputs
195195
scenarioObject <- initScenarioObject scenarioInputs content
196-
gs <- liftIO $ scenarioToGameState (ScenarioWith scenarioObject Nothing) emptyLaunchParams Nothing rs
196+
gs <- liftIO $ scenarioToGameState (ScenarioWith scenarioObject Nothing) emptyLaunchParams Nothing Nothing rs
197197
return (gs, scenarioObject)
198198

199199
verifySolution ::

0 commit comments

Comments
 (0)