datastar-hs-1.0.2.0: bench/Main.hs
{-# LANGUAGE CPP #-}
{- |
A size benchmark for Datastar SSE compression.
It sends one @PatchElements@ event per "tick" over a single SSE connection and
measures the total bytes that go on the wire, uncompressed vs. gzip vs. brotli —
using the real SDK compressors and streaming path.
Run: @cabal run bench -- [numEvents] [tableRows]@ (defaults: 400 events, 2000 rows).
-}
module Main (main) where
import Control.Concurrent.MVar
import Control.Monad
import Data.ByteString.Builder qualified as BSB
import Data.ByteString.Lazy qualified as BL
import Data.Text (Text)
import Data.Text qualified as T
import System.Environment (getArgs)
import Text.Printf (printf)
import Network.Wai (defaultRequest)
import Network.Wai.Internal (Response (..))
import Hypermedia.Datastar
import Hypermedia.Datastar.Compression.Brotli (brotli)
import Hypermedia.Datastar.Compression.Zlib (gzip)
#ifdef ZSTD
import Hypermedia.Datastar.Compression.Zstd (zstd)
#endif
wireBytes :: Response -> IO Int
wireBytes (ResponseStream _ _ body) = do
nrBytes <- newMVar 0
let chunkLen = fromIntegral . BL.length . BSB.toLazyByteString
body
(\chunk -> modifyMVar_ nrBytes $ \nr -> return $ nr + chunkLen chunk)
(pure ())
readMVar nrBytes
wireBytes _ = error "expected a streaming response"
sendAll :: [Text] -> ServerSentEventGenerator -> IO ()
sendAll frags gen = forM_ frags (sendPatchElements gen . patchElements)
rawBytes :: [Text] -> IO Int
rawBytes frags = wireBytes (sseResponse nullLogger (sendAll frags))
compressedBytes :: Compressor -> [Text] -> IO Int
compressedBytes c frags =
wireBytes (sseResponseWithStrategy Forced nullLogger [c] defaultRequest (sendAll frags))
bench :: String -> [Text] -> IO ()
bench name frags = do
let n = length frags
perEvent = if n == 0 then 0 else T.length (head frags)
rawN <- rawBytes frags
gzN <- compressedBytes gzip frags
brN <- compressedBytes brotli frags
printf "\n=== %s ===\n" name
printf " %d events, ~%s uncompressed per fragment\n\n" n (human perEvent)
printf " none : %12s\n" (human rawN)
printf " gzip : %12s (%7.1fx vs none)\n" (human gzN) (ratio rawN gzN)
printf " brotli : %12s (%7.1fx vs none)\n" (human brN) (ratio rawN brN)
#ifdef ZSTD
zstdN <- compressedBytes zstd frags
printf " zstd : %12s (%7.1fx vs none)\n" (human zstdN) (ratio rawN zstdN)
#endif
ratio :: Int -> Int -> Double
ratio a b = if b == 0 then 0 else fromIntegral a / fromIntegral b
human :: Int -> String
human n
| n >= 1024 * 1024 = printf "%.1f MB" (fromIntegral n / (1024 * 1024) :: Double)
| n >= 1024 = printf "%.1f KB" (fromIntegral n / 1024 :: Double)
| otherwise = printf "%d B" n
tshow :: Int -> Text
tshow = T.pack . show
-- A self-similar table row (lots of intra-fragment repetition).
gridRow :: Int -> Text
gridRow i =
"<tr><td>" <> tshow i <> "</td><td>item-" <> tshow i <> "</td><td>active</td><td>0.00</td></tr>"
-- A large grid (>32 KB for a couple thousand rows), optionally stamped with a
-- per-tick caption so only a tiny part changes between events.
grid :: Int -> Maybe Int -> Text
grid rows mtick =
"<table id=\"grid\">"
<> maybe "" (\t -> "<caption>updated " <> tshow t <> "</caption>") mtick
<> T.concat (map gridRow [1 .. rows])
<> "</table>"
clock :: Int -> Text
clock tick = "<div id=\"clock\">" <> tshow tick <> "</div>"
main :: IO ()
main = do
args <- getArgs
let n = case args of (a : _) -> read a; _ -> 400
rows = case args of (_ : b : _) -> read b; _ -> 2000
putStrLn "Datastar SSE compression benchmark"
bench
"Identical large grid every tick"
(replicate n (grid rows Nothing))
bench
"Fat update: large grid, only the caption changes each tick"
(map (grid rows . Just) [1 .. n])
bench
"Small update: tiny clock div each tick"
(map clock [1 .. n])