24 December 2020 ; tagged in: advent of code , haskell

Advent of Code 2020 day 15

Diving into ST and monad loop control, all in the pursuit of speed

Advent of Code 2020 day 15

Day 15 started as a fiddle with off-by-one errors, then dived into the detail of the ST monad, mutable values, and Control.Monad.Loops for control structures.

The problem was the same in both parts, but different sizes. The instance in part 1 was small enough for a pure functional version to be quick enough, but part 2 required mutable storage to be quick.

The functional solution

Progress in the game was built around the Game record. This records the state of the game at the end of a particular round. round is the number of the round just finished; word is the word/number spoken this round; history is the history of what was spoken in previous rounds.

data Game = Game { round :: Int
                 , word :: Int
                 , history :: M.IntMap Int
                 } deriving (Show, Eq)

I create a game from the seed string, taking care not to include the current round in the history.

seedGame seed = Game {..}
  where round = length seed 
        word = last seed
        history = M.fromList $ zip (init seed) [1..]

Given a game at the end of round n, I can create the game at the end of round n + 1 by looking up the word spoken and updating the history. (If a word has not been spoken before, say zero, otherwise say the gap.)

gameStep Game{..} = 
  Game { round = round + 1
       , word = word'
       , history = history'
       }
  where 
    word' = speakWord (M.lookup word history) round
    history' = M.insert word round history

speakWord Nothing _ = 0
speakWord (Just prev) now = now - prev

To find the desired state, I iterate to create an infinite list of states, and drop them while I'm notYet finished.

infiniteGame g = iterate gameStep g

gameRound r game0 = head $ dropWhile notYet $ infiniteGame game0
  where notYet game = round game < r

And then call that from main

main :: IO ()
main = 
  do  let seed = [20, 0, 1, 11, 6, 3]
      print $ part1 seed

part1 = word . (gameRound 2020) . seedGame

This version is available as advent15slow.hs.

The imperative solution

This solution works for part 2, but it takes about 6 minutes to complete the 30,000,000 rounds. I may have been satisfied with that, but I've been keeping an eye on Michael Wong's blog, and his solution to day 15 was quick, by using mutable storage in the ST monad. He's a beginner, and I'd never done anything with ST before, so… challenge accepted!

This version revolves around three mutable variables, round, word, and history. The first two are Ints, the last is Vector of Ints.

seedGame :: [Int] -> Int -> ST s (STRef s Int, STRef s Int,  V.MVector s Int)
seedGame seed historySize = 
  do round <- newSTRef $ length seed
     word <- newSTRef $ last seed
     history <- V.replicate historySize 0
     forM_ (zip (init seed) [1..]) $ \(t, s) -> V.write history t s
     return (round, word, history)

The s state in the type signatures is just to enforce the encapsulation of the mutation to the ST monad's scope. It's not used anywhere and you don't define it.

These variables are updated every round of the game. At the end of each call of gameSteps, these variables reflect the state of the game at the end of the current round. Calculating each game step is just shuffling values around these variables. Note that I also check for finishing the game in the same loop.

gameSteps :: Int -> STRef s Int -> STRef s Int -> V.MVector s Int -> ST s ()
gameSteps targetRound round word history =
  do roundVal <- readSTRef round
     if roundVal == targetRound
     then return ()
     else do 
           wordVal <- readSTRef word
           wordH <- V.read history wordVal
           let word' = speakWord wordH roundVal
           V.write history wordVal roundVal
           modifySTRef round (+1)
           writeSTRef word word'
           gameSteps targetRound round word history

This is all called with runGame, which sets up the ST monad to allow the mutability, seeds the game, runs it, then looks up the final word from the mutable variable.

runGame :: [Int] -> Int -> Int
runGame seed roundsNeeded =
  runST $ 
    do (round, word, history) <- seedGame seed roundsNeeded
       gameSteps roundsNeeded round word history
       readSTRef word

How depressingly imperative. It does, however, complete in less than two seconds.

This version is available as advent15.hs.

Separating the loop

That mixing of update and termination checking in gameSteps slightly bothered me, so I took a look at the Control.Monad.Loops library. Unfortunately, the documentation there is terse, to say the least. But I did find a nice post looking at the library, which was a great help.

I can simplify gameSteps into gameStep, that just does one game update.

gameStep :: STRef s Int -> STRef s Int -> V.MVector s Int -> ST s ()
gameStep round word history =
  do roundVal <- readSTRef round
     wordVal <- readSTRef word
     wordH <- V.read history wordVal
     let word' = speakWord wordH roundVal
     V.write history wordVal roundVal
     modifySTRef round (+1)
     writeSTRef word word'
     return ()

Control of this is handled in gameLoop. whileM_ takes two monadic expressions. The first returns an ST s Bool and determines whether the loop should continue. The second is the body of the loop.

gameLoop targetRound round word history =
    do whileM_ ((/= targetRound) <$> readSTRef round)
               $ gameStep round word history
       return ()

There's an alternative version using untilM_, but with the same two monadic expressions.

gameLoop targetRound round word history =
    do gameStep round word history 
         `untilM_` ((== targetRound) <$> readSTRef round)
       return ()

It's a matter of taste as to which one is clearer.

This version is available as advent15loop.hs, including a number of ways of writing gameLoop as comments.

Code

You can find the code here or on Gitlab.