Skip to content

Commit

Permalink
react immediately to wakeups (#1736)
Browse files Browse the repository at this point in the history
Fixes #1598

## Demo

Illustrates immediate (same-tick) reaction to a change of a `watch`ed cell:

    scripts/play.sh -i data/scenarios/Testing/1598-detect-entity-change.yaml --autoplay --speed 1

## Background
Robots can be **scheduled** to wakeup from a `watch` by the `wakeWatchingRobots` function.
Robots may be **actually awakened** by the `wakeUpRobotsDoneSleeping` function.

Previously, `wakeWatchingRobots` would only ever schedule wakeups at `currentTick + 1`.  Then, once the next tick is reached, in `wakeUpRobotsDoneSleeping` only robots that had been scheduled to wake on precisely the **current tick** could actually be awakened.

But this made it impossible for one robot to cause another robot, which had been sleeping, to actually wake up within the same tick that scheduling of the wakeup is performed.

The importance of this comes into play for system robots that are `watch`ing a cell and intended to react instantaneously to player actions (usually by running code in an `instant` block).

During each "tick", every active robot gets exactly one "turn" to execute code.  Given the following ID assignments:
| Robot ID | Robot name |
| --- | --- |
| `0` | `base` |
| `1` | `systemBot` |

the `systemBot` will always execute *after* the `base` robot in a given tick.

If the `systemBot` is `watch`ing a given cell, a modification of that cell by the `base` will schedule a wakeup of the `systemBot`.  If the scheduled wakeup tick is `currentTick + 1`, then the `base` will have **another execution turn** before the `systemBot` gets an opportunity to react to the `base`'s action at the current tick, causing the `systemBot` to overlook actions that may be relevant to a goal condition.

## Solution

In contast, if we schedule the `systemBot` wakeup for `currentTick` instead of `currentTick + 1`, then it should have an opportunity to wake, run, and react to the `base`'s action before the `base`'s next turn.

But in the status quo, the selection of which robots to run in a given tick is made only once, before iteration over those robots begins.  We need instead to update the robots-to-run pool dynamically, after each iteration on an individual robot.  The most appropriate data structure for the iteration pool is probably a [Monotone priority queue](https://en.wikipedia.org/wiki/Monotone_priority_queue), but we approximate this with an `IntSet` of `RID`s and the [`minView`](https://hackage.haskell.org/package/containers-0.7/docs/Data-IntSet.html#v:minView) function.

Being able to alter the list of active robots in the middle of a given tick's robot iteration has required a change to the `runRobotIDs` function.  Instead of using a `forM_` to iterate over a static list of `RIDs`, we have a custom iteration function `iterateRobots` to handle the dynamic set.

## Performance

Benchmarks were performed locally with `stack` and GHC 9.6.

I had tested the replacement of the `forM_` loop with `iterateRobots` in isolation, and it had zero effect on benchmarks.  But some other trivial changes tested in isolation, such as the addition of docstrings, yielded a +6% increase in benchmarks.  So there seems to be some instability in the ability of the compiler to perform certain optimizations.

With this PR, increases in benchmark times ranging from 7% to 11% were observed.  The obvious culprit is that `wakeUpRobotsDoneSleeping` is now called N times per tick, rather than once per tick, where N is the number of robots that are initially (or become) active during that tick.

### Mitigating with an "awakened" set

In the common case (i.e. when there are *no* watching robots), the `wakeUpRobotsDoneSleeping` that is now executed `N` times per tick (instead of once per tick) incurs a `Map` lookup, which is `O(log M)` in the number of distinct future wakeup times across all robots.

However, those extra `N` invocations only exist to serve the purpose of `watch`ing robots that may need to wake up at the current tick.  It may be more efficient to have a dedicated `Set` in the `GameState` for such robots that gets populated parallel to this insertion:

https://github.com/swarm-game/swarm/blob/ad5c58917e058306d33e86f3b85e7825d64e54f4/src/swarm-engine/Swarm/Game/State/Robot.hs#L387

Then if this set remains `null`, we can avoid paying for an additional `O(N * log M)` operations entailed by the `Map` lookups into `internalWaitingRobots`.

#### Result

Indeed, storing awakened bots in a new `currentTickWakeableBots` set restored the benchmarks nearly to the baseline.  Instead of the regressions in the 10-20% range observed before, now only a few benchmarks had at most 3-4% increases.

### CI regressions

In CI, (`snake`, `powerset`, and `word-search`) exceeded their timeout thresholds in GHC 9.2 and GHC 9.4.  No regressions were observed with GHC 9.6.  To accommodate the lowest common denominator, I bumped the thresholds for those three scenarios to get a passing build.

I don't believe it is worth further effort or investigation to optimize for GHC 9.4, since such efforts will be moot for GHC 9.6 and onward.

Test invocation:

    cabal test swarm-integration --test-show-details streaming --test-options '--color always --pattern "word-search"'

| **Scenario** | GHC 9.4.8 | GHC 9.4.8 | GHC 9.6.4 | GHC 9.6.4 | 
| --- | --- | --- | --- | --- |
|  | Before | **After** | Before | **After**
| `snake` | 1.84s | **5.38s** | 1.62s | **1.67s** |
| `powerset` | 1.66s | **5.09s** | 1.56s | **1.66s** |
| `word-search` | 0.56s | **1.91s** | 0.44s | **0.48s** |

### Potential improvements

#### `TickNumber` as `Map` key

`waitingRobots` is of type `Map TickNumber [RID]`.  Instead of `Map`, we could have a `newtype` that encapsulates a `IntMap` to make lookups by `TickNumber` more efficient.
  • Loading branch information
kostmo authored Mar 11, 2024
1 parent 7d3f263 commit 01ae0e4
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 59 deletions.
2 changes: 1 addition & 1 deletion data/scenarios/Challenges/Sokoban/_foresight/solution.sw
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def firstLeg =
pushUntilBarrier;

wait 4;
move;
moveUntilBlocked;
doN 5 (turn left; moveUntilBlocked);

turn right;
Expand Down
1 change: 0 additions & 1 deletion data/scenarios/Challenges/_combo-lock/solution.sw
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ def moveToLock =
end;

def cycleCombos = \n.
wait 1;
entityNorth <- scan north;
let hasGate = case entityNorth (\_. false) (\x. x == "gate") in
if hasGate {
Expand Down
2 changes: 2 additions & 0 deletions data/scenarios/Testing/00-ORDER.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Achievements
1341-command-count.yaml
1355-combustion.yaml
1379-single-world-portal-reorientation.yaml
1322-wait-with-instant.yaml
1598-detect-entity-change.yaml
1399-backup-command.yaml
1430-built-robot-ownership.yaml
1536-custom-unwalkable-entities.yaml
Expand Down
72 changes: 72 additions & 0 deletions data/scenarios/Testing/1322-wait-with-instant.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
version: 1
name: Using wait with instant
author: Karl Ostmo
description: |
Observe timing of (instant $ wait 1)
interspersed with other commands
creative: false
seed: 0
objectives:
- goal:
- |
Hare must win by three cells
condition: |
h <- robotnamed "hare";
hareloc <- as h {whereami};
t <- robotnamed "tortoise";
tortoiseloc <- as t {whereami};
let xDiff = fst hareloc - fst tortoiseloc in
return $ fst hareloc == 0 && xDiff == 3;
solution: |
noop;
robots:
- name: base
dir: [1, 0]
display:
invisible: true
devices:
- hourglass
- logger
- name: tortoise
system: true
display:
invisible: false
attr: green
dir: [1, 0]
program: |
move; move;
move; move;
move; move;
- name: hare
system: true
display:
invisible: false
attr: snow
dir: [1, 0]
program: |
instant (
move; move;
wait 1;
move; move;
wait 1;
move; move;
);
world:
dsl: |
{blank}
upperleft: [-6, 2]
offset: false
palette:
'.': [grass, erase]
'd': [dirt, erase]
'B': [grass, erase, base]
'T': [grass, erase, tortoise]
'H': [grass, erase, hare]
map: |
B.....d.
T.....d.
H.....d.
......d.
94 changes: 94 additions & 0 deletions data/scenarios/Testing/1598-detect-entity-change.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
version: 1
name: Entity change detection
author: Karl Ostmo
description: |
Ensure that a change to an entity can be observed
by a system robot within a single tick.
In this scenario, the base will first `swap` the
existing `dial (R)`{=entity} with a `dial (G)`{=entity},
then immediately `swap` again with a `dial (B)`{=entity}.
The system robot should be able to detect the presence
of the `dial (G)`{=entity} before it is `swap`ped a second time.
creative: false
seed: 0
objectives:
- goal:
- |
Turn the light green
condition: |
as base {has "flower"};
prerequisite:
not: blue_light
- id: blue_light
teaser: No blue light
optional: true
goal:
- |
Turn the light blue
condition: |
r <- robotnamed "lockbot";
as r {ishere "dial (B)"};
robots:
- name: base
dir: [1, 0]
display:
invisible: true
devices:
- hourglass
- fast grabber
- logger
- treads
inventory:
- [1, "dial (R)"]
- [1, "dial (G)"]
- [1, "dial (B)"]
- name: lockbot
system: true
display:
invisible: true
dir: [1, 0]
program: |
run "scenarios/Testing/_1598-detect-entity-change/setup.sw"
inventory:
- [1, flower]
solution: |
wait 2;
move;
move;
swap "dial (G)";
swap "dial (B)";
entities:
- name: "dial (R)"
display:
char: ''
attr: red
description:
- A red dial
properties: [known, pickable]
- name: "dial (G)"
display:
char: ''
attr: green
description:
- A green dial
properties: [known, pickable]
- name: "dial (B)"
display:
char: ''
attr: blue
description:
- A blue dial
properties: [known, pickable]
world:
dsl: |
{blank}
upperleft: [-1, -1]
offset: false
palette:
'.': [grass, erase]
'B': [grass, erase, base]
'c': [grass, dial (R), lockbot]
map: |
B.c
17 changes: 17 additions & 0 deletions data/scenarios/Testing/_1598-detect-entity-change/setup.sw
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
def doUntilCorrect =
herenow <- ishere "dial (G)";
if herenow {
give base "flower";
} {
watch down;
wait 1000;
doUntilCorrect;
}
end;

def go =
instant $
doUntilCorrect;
end;

go;
25 changes: 25 additions & 0 deletions scripts/benchmark-against-ancestor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/bash -xe

# Requires that the working tree be clean.

REFERENCE_COMMIT=${1:-HEAD~}

SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
cd $SCRIPT_DIR/..

if git diff --quiet --exit-code
then
echo "Working tree is clean. Starting benchmarks..."
else
echo "Working tree is dirty! Quitting."
exit 1
fi

BASELINE_OUTPUT=baseline.csv

git checkout $REFERENCE_COMMIT

scripts/run-benchmarks.sh "--csv $BASELINE_OUTPUT"

git switch -
scripts/run-benchmarks.sh "--baseline $BASELINE_OUTPUT --fail-if-slower 3"
19 changes: 1 addition & 18 deletions scripts/benchmark-against-parent.sh
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
#!/bin/bash -xe

# Requires that the working tree be clean.

SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
cd $SCRIPT_DIR/..

if git diff --quiet --exit-code
then
echo "Working tree is clean. Starting benchmarks..."
else
echo "Working tree is dirty! Quitting."
exit 1
fi

BASELINE_OUTPUT=baseline.csv

git checkout HEAD~

scripts/run-benchmarks.sh "--csv $BASELINE_OUTPUT"

git switch -
scripts/run-benchmarks.sh "--baseline $BASELINE_OUTPUT --fail-if-slower 3"
scripts/benchmark-against-ancestor.sh HEAD~
Loading

0 comments on commit 01ae0e4

Please sign in to comment.