StateT vs. IORef: a benchmark
Published on ; updated on
Sometimes I’m writing an IO loop in Haskell, and I need some sort of a counter or accumulator. The two main options are to use a mutable reference (IORef) or to put a StateT transformer on top the IO monad.
I was curious, though, if there was a difference in efficiency between these two approaches. Intuitively, IORefs are dedicated heap objects, while a StateT transformer’s state becomes “just” a local variable, so StateT might optimize better. But how much of a difference does it make?
So I benchmarked the four functions, all of which calculate the sum
of numbers between 1 and n = 1000.
base_sum simply calls sum from the base
package; state_sum and stateT_sum maintain the
accumulator using the State Int and
StateT Int IO monads, respectively, and
ioref_sum uses an IORef within the
IO monad. And here are the results, as reported by
criterion.
I’m not sure how stateT_sum manages to be faster than
state_sum and base_sum (this doesn’t appear to
be a statistical fluke), but what’s clear is that ioref_sum
is significantly slower of them all.
So if 3ns per state access matter to you, go for StateT
even when you are in IO.
(Update: also check out the comments on reddit, especially the ones by u/VincentPepper.)
Here’s the full benchmark code. It was compiled with -O2
by GHC 8.8.4 and run on AMD Ryzen 7 3700X.
import Criterion
import Criterion.Main
import Control.Monad.State
import Data.IORef
base_sum :: Int -> Int
base_sum n = sum [1 .. n]
state_sum :: Int -> Int
state_sum n = flip execState 0 $
forM_ [1..n] $ \i ->
modify' (+i)
stateT_sum :: Int -> IO Int
stateT_sum n = flip execStateT 0 $
forM_ [1..n] $ \i ->
modify' (+i)
ioref_sum :: Int -> IO Int
ioref_sum n = do
ref <- newIORef 0
forM_ [1..n] $ \i ->
modifyIORef' ref (+i)
readIORef ref
main = do
let n = 1000
defaultMain
[ bench "base_sum" $ whnf base_sum n
, bench "state_sum" $ whnf state_sum n
, bench "stateT_sum" $ whnfAppIO stateT_sum n
, bench "ioref_sum" $ whnfAppIO ioref_sum n
]