diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index bb67a17..d3e8832 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -53,6 +53,63 @@ jobs: if: always() run: docker compose -f docker-compose.cross.yml down -v + perf-interop: + name: perf-interop (hs <-> nim, tcp+noise+yamux) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # nim-libp2p pinned to the commit unified-testing's perf/images.yaml + # registers for nim-v1.15 (single source of truth: NIM_LIBP2P_COMMIT + # in interop/Makefile); docker-compose.perf-cross.yml builds its + # interop/perf/Dockerfile. The unified-testing go perf image is a + # placeholder that opens no perf streams, so nim is the partner that + # actually exercises the /perf/1.0.0 wire format. + - name: Clone nim-libp2p (pinned) + run: make -C interop nim-libp2p + + - name: Build libp2p-hs perf image + uses: docker/build-push-action@v6 + with: + context: . + file: interop/perf/Dockerfile + load: true + tags: libp2p-hs-perf:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Self-test — hs listener, hs dialer + run: > + docker compose -f docker-compose.perf.yml up + --exit-code-from hs-perf-dialer + redis hs-perf-listener hs-perf-dialer + + - name: Reset after self-test + run: docker compose -f docker-compose.perf.yml down -v + + - name: Direction 1 — nim listener, hs dialer + run: > + docker compose -f docker-compose.perf-cross.yml up + --exit-code-from hs-perf-dialer + redis nim-perf-listener hs-perf-dialer + + - name: Reset between directions + run: docker compose -f docker-compose.perf-cross.yml down + + - name: Direction 2 — hs listener, nim dialer + run: > + docker compose -f docker-compose.perf-cross.yml up + --exit-code-from nim-perf-dialer + redis hs-perf-listener nim-perf-dialer + + - name: Tear down + if: always() + run: docker compose -f docker-compose.perf-cross.yml down -v + kad-dht-interop: name: kad-dht-interop (hs bootstrap → provider → querier) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 9bd6c86..fe254b9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ cabal.project.local~ .playwright-mcp/ cabal.project.freeze go-libp2p/ +nim-libp2p/ interop/rust-peer/target/ diff --git a/docker-compose.perf-cross.yml b/docker-compose.perf-cross.yml new file mode 100644 index 0000000..c9b1c7e --- /dev/null +++ b/docker-compose.perf-cross.yml @@ -0,0 +1,110 @@ +# Cross-implementation perf test: libp2p-hs <-> nim-libp2p. +# +# nim-libp2p is the reference perf test app for the unified-testing +# submission (perf/images.yaml: nim-v1.15) and implements the canonical +# /perf/1.0.0 wire protocol from libp2p/specs. The unified-testing go +# image (images/go/v0.45) is a placeholder that opens no perf streams, +# so nim is the cross-implementation partner that actually exercises +# the wire format. +# +# Prerequisites: `make -C interop nim-libp2p` clones nim-libp2p at the +# commit perf/images.yaml pins for nim-v1.15 (NIM_LIBP2P_COMMIT in +# interop/Makefile is the single source of truth). +# +# Both sides speak the modern perf contract (uppercase env vars, Redis +# SET/GET on `{TEST_KEY}_listener_multiaddr`), so no key-translation +# shims are needed. Directions use distinct TEST_KEYs so a lingering +# SET from one direction can never satisfy the other's poll. +# +# Test 1: nim listener, hs dialer +# docker compose -f docker-compose.perf-cross.yml up --build --exit-code-from hs-perf-dialer redis nim-perf-listener hs-perf-dialer +# +# Test 2: hs listener, nim dialer +# docker compose -f docker-compose.perf-cross.yml up --build --exit-code-from nim-perf-dialer redis hs-perf-listener nim-perf-dialer +x-hs-dial-key: &hs-dial-key "cafe0131" +x-nim-dial-key: &nim-dial-key "cafe0132" + +x-perf-sizes: &perf-sizes + UPLOAD_BYTES: "10485760" + DOWNLOAD_BYTES: "10485760" + UPLOAD_ITERATIONS: "3" + DOWNLOAD_ITERATIONS: "3" + LATENCY_ITERATIONS: "10" + +x-perf-env: &perf-env + REDIS_ADDR: "redis:6379" + TRANSPORT: tcp + SECURE_CHANNEL: noise + MUXER: yamux + LISTENER_IP: "0.0.0.0" + +services: + redis: + image: redis:7-alpine + # Disable persistence so a stale listener address never survives across runs. + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1s + timeout: 3s + retries: 30 + + # --- Test 1: nim listener, hs dialer --- + nim-perf-listener: + build: + context: ./nim-libp2p + dockerfile: interop/perf/Dockerfile + image: nim-libp2p-perf:latest + depends_on: + redis: + condition: service_healthy + environment: + <<: *perf-env + TEST_KEY: *hs-dial-key + IS_DIALER: "false" + + hs-perf-dialer: + build: + context: . + dockerfile: interop/perf/Dockerfile + image: libp2p-hs-perf:latest + depends_on: + redis: + condition: service_healthy + nim-perf-listener: + condition: service_started + environment: + <<: [*perf-env, *perf-sizes] + TEST_KEY: *hs-dial-key + IS_DIALER: "true" + + # --- Test 2: hs listener, nim dialer --- + hs-perf-listener: + build: + context: . + dockerfile: interop/perf/Dockerfile + image: libp2p-hs-perf:latest + depends_on: + redis: + condition: service_healthy + environment: + <<: *perf-env + TEST_KEY: *nim-dial-key + IS_DIALER: "false" + + nim-perf-dialer: + build: + context: ./nim-libp2p + dockerfile: interop/perf/Dockerfile + image: nim-libp2p-perf:latest + depends_on: + redis: + condition: service_healthy + hs-perf-listener: + condition: service_started + environment: + <<: [*perf-env, *perf-sizes] + TEST_KEY: *nim-dial-key + IS_DIALER: "true" diff --git a/docker-compose.perf.yml b/docker-compose.perf.yml new file mode 100644 index 0000000..a35579e --- /dev/null +++ b/docker-compose.perf.yml @@ -0,0 +1,64 @@ +# Perf self-test: libp2p-hs listener + libp2p-hs dialer. +# +# Speaks the unified-testing perf contract: the listener SETs +# `{TEST_KEY}_listener_multiaddr` in Redis and the dialer polls GET +# (the perf contract uses a plain string, unlike the transport +# contract's RPUSH/BLPOP list). +# +# Sizes are kept small so the self-test finishes quickly; the upstream +# perf harness supplies its own (much larger) values. +# +# docker compose -f docker-compose.perf.yml up --build --exit-code-from hs-perf-dialer redis hs-perf-listener hs-perf-dialer +x-test-key: &test-key "cafe0130" + +x-perf-env: &perf-env + REDIS_ADDR: "redis:6379" + TEST_KEY: *test-key + TRANSPORT: tcp + SECURE_CHANNEL: noise + MUXER: yamux + LISTENER_IP: "0.0.0.0" + +services: + redis: + image: redis:7-alpine + # Disable persistence so a stale listener address never survives across runs. + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1s + timeout: 3s + retries: 30 + + hs-perf-listener: + build: + context: . + dockerfile: interop/perf/Dockerfile + image: libp2p-hs-perf:latest + depends_on: + redis: + condition: service_healthy + environment: + <<: *perf-env + IS_DIALER: "false" + + hs-perf-dialer: + build: + context: . + dockerfile: interop/perf/Dockerfile + image: libp2p-hs-perf:latest + depends_on: + redis: + condition: service_healthy + hs-perf-listener: + condition: service_started + environment: + <<: *perf-env + IS_DIALER: "true" + UPLOAD_BYTES: "10485760" + DOWNLOAD_BYTES: "10485760" + UPLOAD_ITERATIONS: "3" + DOWNLOAD_ITERATIONS: "3" + LATENCY_ITERATIONS: "10" diff --git a/interop/Makefile b/interop/Makefile index a29613f..fe0b2f9 100644 --- a/interop/Makefile +++ b/interop/Makefile @@ -6,7 +6,11 @@ IMAGE_NAME := libp2p-hs-interop .PHONY: build image self-test cross-test-go-listener cross-test-hs-listener \ - cross-gossipsub-rust-listener cross-gossipsub-hs-listener cross-gossipsub kad-dht + cross-gossipsub-rust-listener cross-gossipsub-hs-listener cross-gossipsub kad-dht \ + perf-self-test perf-cross-nim-listener perf-cross-hs-listener perf-cross nim-libp2p + +# nim-libp2p commit pinned by unified-testing perf/images.yaml (nim-v1.15). +NIM_LIBP2P_COMMIT := 1bdf2f67971529e8bee01252230bdb00ab785ef7 image: docker build -t $(IMAGE_NAME) .. @@ -39,3 +43,23 @@ cross-gossipsub-hs-listener: # GossipSub cross-test: both directions cross-gossipsub: cross-gossipsub-rust-listener cross-gossipsub-hs-listener + +# Perf self-test: hs listener + hs dialer +perf-self-test: + cd .. && docker compose -f docker-compose.perf.yml up --build --exit-code-from hs-perf-dialer redis hs-perf-listener hs-perf-dialer + +# Clone nim-libp2p at the commit unified-testing pins for perf (nim-v1.15) +nim-libp2p: + cd .. && test -d nim-libp2p || git clone https://github.com/vacp2p/nim-libp2p.git nim-libp2p + cd ../nim-libp2p && git checkout $(NIM_LIBP2P_COMMIT) + +# Perf cross-test: nim listener, hs dialer +perf-cross-nim-listener: nim-libp2p + cd .. && docker compose -f docker-compose.perf-cross.yml up --build --exit-code-from hs-perf-dialer redis nim-perf-listener hs-perf-dialer + +# Perf cross-test: hs listener, nim dialer +perf-cross-hs-listener: nim-libp2p + cd .. && docker compose -f docker-compose.perf-cross.yml up --build --exit-code-from nim-perf-dialer redis hs-perf-listener nim-perf-dialer + +# Perf cross-test: both directions +perf-cross: perf-cross-nim-listener perf-cross-hs-listener diff --git a/interop/perf/Dockerfile b/interop/perf/Dockerfile new file mode 100644 index 0000000..f21f505 --- /dev/null +++ b/interop/perf/Dockerfile @@ -0,0 +1,47 @@ +# Multi-stage build for the libp2p-hs perf test daemon. +# Used by the libp2p/unified-testing perf framework; build context is +# the repository root (perf/images.yaml: dockerfile: interop/perf/Dockerfile). + +# Stage 1: Build with GHC 9.10 +FROM haskell:9.10-slim-bookworm AS builder + +WORKDIR /app + +# Copy only the package description first, so the dependency-build layer below +# is cached and reused as long as libp2p-hs.cabal / cabal.project are unchanged. +COPY libp2p-hs.cabal cabal.project ./ + +# Fix ppad-sha256 ARM SHA2 intrinsic compilation on Docker (GCC 12). +# Only apply ARM-specific flags on aarch64; skip on x86_64. +# Must run before any build so the override is in effect. +RUN if [ "$(uname -m)" = "aarch64" ]; then \ + echo 'package ppad-sha256' >> cabal.project && \ + echo ' ghc-options: -optc-march=armv8-a+crypto' >> cabal.project; \ + fi + +# Pre-build the library's dependencies. This is the expensive layer (crypton, +# cacophony, tls, lens, ...) and is cached unless the cabal file changes, so +# source-only edits skip it. We target the library (not the executable) because +# the executable depends on the local libp2p-hs library, which cannot be built +# before its source is copied below. +RUN cabal update && cabal build --only-dependencies lib:libp2p-hs + +# Copy sources and build the project itself. +COPY src/ src/ +COPY interop/ interop/ +RUN cabal build libp2p-perf \ + && cp "$(cabal list-bin libp2p-perf)" /app/libp2p-perf \ + && strip /app/libp2p-perf + +# Stage 2: Minimal runtime image +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libgmp10 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/libp2p-perf /usr/local/bin/libp2p-perf + +ENTRYPOINT ["libp2p-perf"] diff --git a/interop/perf/Main.hs b/interop/perf/Main.hs new file mode 100644 index 0000000..50fe236 --- /dev/null +++ b/interop/perf/Main.hs @@ -0,0 +1,330 @@ +-- | Perf test daemon for the libp2p/unified-testing framework. +-- +-- Implements the perf test-app contract +-- (unified-testing docs/write-a-perf-test-app.md): reads uppercase +-- environment variables, coordinates listener discovery through a +-- shared Redis instance namespaced by TEST_KEY, runs the /perf/1.0.0 +-- upload/download/latency measurements, and reports statistics as YAML +-- on stdout. All logging goes to stderr. +-- +-- Unlike the transport contract (RPUSH/BLPOP), the perf contract's +-- reference apps coordinate through a plain Redis string: the listener +-- SETs `{TEST_KEY}_listener_multiaddr` and the dialer polls GET. +-- +-- Environment variables (perf contract): +-- IS_DIALER - "true" or "false" +-- REDIS_ADDR - Redis host:port (default: "redis:6379") +-- TEST_KEY - hex key namespacing Redis coordination keys +-- TRANSPORT - must be "tcp" +-- SECURE_CHANNEL - must be "noise" +-- MUXER - must be "yamux" +-- LISTENER_IP - bind address (default: "0.0.0.0") +-- DEBUG - accepted but ignored; all logging goes to stderr +-- UPLOAD_BYTES - bytes per upload iteration (default: 1073741824) +-- DOWNLOAD_BYTES - bytes per download iteration (default: 1073741824) +-- UPLOAD_ITERATIONS - upload repetitions (default: 10) +-- DOWNLOAD_ITERATIONS - download repetitions (default: 10) +-- LATENCY_ITERATIONS - latency repetitions (default: 100) +module Main (main) where + +import Control.Concurrent (threadDelay) +import Control.Monad (forM, forever) +import qualified Data.ByteString.Char8 as BS8 +import Data.List (find) +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import Data.Word (Word64) +import qualified Database.Redis as Redis +import LibP2P + ( Connection + , Multiaddr (..) + , PeerId + , PerfResult (..) + , Protocol (..) + , Switch + , addTransport + , defaultConnectionGater + , dial + , fromPublicKey + , fromText + , generateKeyPair + , newSwitch + , newTCPTransport + , peerIdBytes + , registerPerfHandler + , runPerf + , splitP2P + , switchClose + , switchListen + , toBase58 + , toText + ) +import LibP2P.Crypto.Key (publicKey) +import Network.Socket + ( AddrInfo (..) + , SockAddr (..) + , defaultHints + , getAddrInfo + , hostAddressToTuple + ) +import qualified Network.Socket as Socket +import PerfInterop.Stats (computeStats, renderStatsYaml) +import System.Environment (lookupEnv) +import System.Exit (exitFailure, exitSuccess) +import System.IO (hFlush, hPutStrLn, stderr, stdout) +import Text.Read (readMaybe) + +-- | Bounds Redis polling for the listener address. +testTimeoutSeconds :: Int +testTimeoutSeconds = 180 + +main :: IO () +main = do + isDialer <- getEnvRequired "IS_DIALER" + redisAddr <- fromMaybe "redis:6379" <$> lookupEnv "REDIS_ADDR" + testKey <- getEnvRequired "TEST_KEY" + transport <- getEnvRequired "TRANSPORT" + security <- lookupEnv "SECURE_CHANNEL" + muxer <- lookupEnv "MUXER" + ip <- fromMaybe "0.0.0.0" <$> lookupEnv "LISTENER_IP" + + uploadBytes <- getEnvRead "UPLOAD_BYTES" (1073741824 :: Word64) + downloadBytes <- getEnvRead "DOWNLOAD_BYTES" (1073741824 :: Word64) + uploadIters <- getEnvRead "UPLOAD_ITERATIONS" (10 :: Int) + downloadIters <- getEnvRead "DOWNLOAD_ITERATIONS" (10 :: Int) + latencyIters <- getEnvRead "LATENCY_ITERATIONS" (100 :: Int) + + case validateProtocols transport security muxer of + Left err -> do + hPutStrLn stderr $ "Unsupported configuration: " ++ err + exitFailure + Right () -> pure () + + let addrKey = BS8.pack (testKey ++ "_listener_multiaddr") + + ekp <- generateKeyPair + case ekp of + Left err -> do + hPutStrLn stderr $ "Key generation failed: " ++ err + exitFailure + Right kp -> do + let pid = fromPublicKey (publicKey kp) + logInfo $ "PeerId: " ++ T.unpack (toBase58 pid) + + sw <- newSwitch pid kp + tcp <- newTCPTransport + addTransport sw tcp + registerPerfHandler sw + + let (redisHost, redisPort) = parseHostPort redisAddr + let redisConnInfo = Redis.defaultConnectInfo + { Redis.connectHost = redisHost + , Redis.connectPort = Redis.PortNumber (fromIntegral redisPort) + } + redisConn <- Redis.checkedConnect redisConnInfo + + case isDialer of + "false" -> runListener sw pid ip redisConn addrKey + "true" -> runDialer sw redisConn addrKey + uploadBytes downloadBytes + uploadIters downloadIters latencyIters + other -> dieWith sw ("Invalid IS_DIALER value: " ++ other) + +-- | Listener mode: bind, SET the address in Redis, serve perf requests +-- until the test harness shuts the container down. +runListener :: Switch -> PeerId -> String -> Redis.Connection -> BS8.ByteString -> IO () +runListener sw pid ip redisConn addrKey = do + addrText <- listenAndResolve sw pid ip + logInfo $ "Perf listener on: " ++ T.unpack addrText + + result <- Redis.runRedis redisConn $ Redis.set addrKey (TE.encodeUtf8 addrText) + case result of + Left err -> dieWith sw ("Redis SET failed: " ++ show err) + Right _ -> pure () + + logInfo "Address published to Redis, serving perf requests..." + forever $ threadDelay 3600000000 + +-- | Dialer mode: GET the listener address, dial, run the three +-- measurement groups, and print the YAML results on stdout. +runDialer + :: Switch -> Redis.Connection -> BS8.ByteString + -> Word64 -> Word64 -> Int -> Int -> Int -> IO () +runDialer sw redisConn addrKey uploadBytes downloadBytes uploadIters downloadIters latencyIters = do + logInfo "Polling Redis for listener address..." + addrBS <- pollListenerAddr redisConn addrKey + >>= maybe (dieWith sw "Timed out waiting for listener address") pure + let addrText = TE.decodeUtf8 addrBS + logInfo $ "Got listener address: " ++ T.unpack addrText + + (transportAddr, remotePeerId) <- + either (\err -> dieWith sw ("Failed to parse multiaddr: " ++ err)) pure $ + fromText addrText + >>= \a -> maybe (Left "multiaddr has no /p2p/ component") Right (splitP2P a) + + logInfo $ "Dialing peer: " ++ T.unpack (toBase58 remotePeerId) + conn <- dial sw remotePeerId [transportAddr] + >>= either (\err -> dieWith sw ("Dial failed: " ++ show err)) pure + + logInfo $ "Running upload test (" ++ show uploadIters ++ " iterations)..." + uploadSamples <- runGroup sw conn uploadBytes 0 uploadIters + + logInfo $ "Running download test (" ++ show downloadIters ++ " iterations)..." + downloadSamples <- runGroup sw conn 0 downloadBytes downloadIters + + logInfo $ "Running latency test (" ++ show latencyIters ++ " iterations)..." + latencySamples <- runGroup sw conn 1 1 latencyIters + + putStr $ renderStatsYaml "upload" uploadIters 2 "Gbps" (computeStats uploadSamples) + putStrLn "" + putStr $ renderStatsYaml "download" downloadIters 2 "Gbps" (computeStats downloadSamples) + putStrLn "" + putStr $ renderStatsYaml "latency" latencyIters 3 "ms" (computeStats latencySamples) + hFlush stdout + + switchClose sw + exitSuccess + +-- | Run one measurement group: @iters@ perf exchanges, one stream each. +-- Transfers above 100 bytes report throughput in Gbps; smaller ones +-- report round-trip latency in milliseconds (contract convention). +runGroup :: Switch -> Connection -> Word64 -> Word64 -> Int -> IO [Double] +runGroup sw conn uploadBytes downloadBytes iters = do + let transferBytes = max uploadBytes downloadBytes + forM [1 .. iters] $ \(i :: Int) -> do + r <- runPerf sw conn uploadBytes downloadBytes + >>= either (\err -> dieWith sw ("Perf iteration " ++ show i ++ " failed: " ++ show err)) pure + let secs = realToFrac (perfElapsed r) :: Double + pure $ if transferBytes > 100 + then fromIntegral transferBytes * 8 / secs / 1e9 + else secs * 1000 + +-- | Bind, resolve the non-localhost address, and return the full +-- multiaddr (with /p2p/ suffix) as text. +listenAndResolve :: Switch -> PeerId -> String -> IO T.Text +listenAndResolve sw pid ip = do + let bindAddr = case fromText (T.pack ("/ip4/" ++ ip ++ "/tcp/0")) of + Right ma -> ma + Left err -> error $ "Invalid bind address: " ++ err + + addrs <- switchListen sw defaultConnectionGater [bindAddr] + case addrs of + [] -> dieWith sw "switchListen returned no addresses" + (listenAddr : _) -> do + actualAddr <- resolveListenAddr listenAddr ip + let peerIdMH = peerIdBytes pid + let fullAddr = encapsulateP2P actualAddr peerIdMH + pure (toText fullAddr) + +-- | Poll GET on the listener-multiaddr key every 500ms until it holds a +-- value or 'testTimeoutSeconds' elapses (perf contract coordination). +pollListenerAddr :: Redis.Connection -> BS8.ByteString -> IO (Maybe BS8.ByteString) +pollListenerAddr redisConn addrKey = go (testTimeoutSeconds * 2) + where + go :: Int -> IO (Maybe BS8.ByteString) + go 0 = pure Nothing + go attemptsLeft = do + result <- Redis.runRedis redisConn $ Redis.get addrKey + case result of + Left err -> do + hPutStrLn stderr $ "Redis GET failed: " ++ show err + pure Nothing + Right (Just value) | not (BS8.null value) -> pure (Just value) + Right _ -> do + threadDelay 500000 + go (attemptsLeft - 1) + +-- | Validate that we support the requested protocol combination. +validateProtocols :: String -> Maybe String -> Maybe String -> Either String () +validateProtocols transport security muxer = do + case transport of + "tcp" -> pure () + other -> Left $ "transport " ++ other ++ " not supported (only tcp)" + case security of + Just "noise" -> pure () + Just other -> Left $ "secure channel " ++ other ++ " not supported (only noise)" + Nothing -> Left "SECURE_CHANNEL not set (required for tcp)" + case muxer of + Just "yamux" -> pure () + Just other -> Left $ "muxer " ++ other ++ " not supported (only yamux)" + Nothing -> Left "MUXER not set (required for tcp)" + +-- | Parse "host:port" string. +parseHostPort :: String -> (String, Int) +parseHostPort s = case break (== ':') s of + (host, ':' : portStr) -> (host, fromMaybe 6379 (readMaybe portStr)) + (host, _) -> (host, 6379) + +-- | Resolve 0.0.0.0 to actual container IP for Docker networking. +resolveListenAddr :: Multiaddr -> String -> IO Multiaddr +resolveListenAddr addr ip + | ip == "0.0.0.0" = do + actualIP <- discoverContainerIP + case protocols addr of + (IP4 _ : rest) -> + case fromText (T.pack ("/ip4/" ++ actualIP)) of + Right (Multiaddr [IP4 w]) -> pure $ Multiaddr (IP4 w : rest) + _ -> pure addr + _ -> pure addr + | otherwise = pure addr + where + protocols (Multiaddr ps) = ps + +-- | Discover actual container IP via hostname resolution. +-- In Docker, HOSTNAME is set to the container ID, which resolves +-- to the container's IP address on the Docker network. +discoverContainerIP :: IO String +discoverContainerIP = do + mHostname <- lookupEnv "HOSTNAME" + case mHostname of + Nothing -> pure "0.0.0.0" + Just hostname -> do + addrs <- getAddrInfo (Just defaultHints) (Just hostname) Nothing :: IO [AddrInfo] + case find isNonLoopbackIPv4 addrs of + Just ai -> pure $ sockAddrToIP (Socket.addrAddress ai) + Nothing -> pure "0.0.0.0" + +-- | Extract just the IP string from a SockAddr. +sockAddrToIP :: SockAddr -> String +sockAddrToIP (SockAddrInet _ hostAddr) = + let (a, b, c, d) = hostAddressToTuple hostAddr + in show a ++ "." ++ show b ++ "." ++ show c ++ "." ++ show d +sockAddrToIP other = show other + +-- | Check if an AddrInfo is a non-loopback IPv4 address. +isNonLoopbackIPv4 :: AddrInfo -> Bool +isNonLoopbackIPv4 ai = case Socket.addrAddress ai of + SockAddrInet _ hostAddr -> + let (a, _, _, _) = hostAddressToTuple hostAddr + in a /= 127 + _ -> False + +-- | Encapsulate a /p2p/ suffix onto a multiaddr. +encapsulateP2P :: Multiaddr -> BS8.ByteString -> Multiaddr +encapsulateP2P (Multiaddr ps) mhBytes = Multiaddr (ps ++ [P2P mhBytes]) + +-- | Get a required environment variable, failing if not set. +getEnvRequired :: String -> IO String +getEnvRequired name = do + val <- lookupEnv name + case val of + Just v -> pure v + Nothing -> do + hPutStrLn stderr $ "Missing required environment variable: " ++ name + exitFailure + +-- | Read an environment variable via 'Read' with a default. +getEnvRead :: Read a => String -> a -> IO a +getEnvRead name def = fromMaybe def . (>>= readMaybe) <$> lookupEnv name + +-- | Log the error, close the Switch, and exit non-zero. +dieWith :: Switch -> String -> IO a +dieWith sw msg = do + hPutStrLn stderr msg + switchClose sw + exitFailure + +-- | Log to stderr. +logInfo :: String -> IO () +logInfo msg = hPutStrLn stderr msg >> hFlush stderr diff --git a/interop/perf/PerfInterop/Stats.hs b/interop/perf/PerfInterop/Stats.hs new file mode 100644 index 0000000..e5f29b9 --- /dev/null +++ b/interop/perf/PerfInterop/Stats.hs @@ -0,0 +1,87 @@ +-- | Sample statistics and YAML rendering for the unified-testing perf +-- contract (docs/write-a-perf-test-app.md). +-- +-- Quartiles use linear interpolation over all sorted samples; outliers +-- are values outside the [q1 - 1.5*IQR, q3 + 1.5*IQR] fences; min/max +-- are taken over the non-outlier samples (falling back to the full set +-- if everything is flagged). This mirrors the reference test apps so +-- results are comparable across implementations. +module PerfInterop.Stats + ( Stats (..) + , computeStats + , percentile + , renderStatsYaml + ) where + +import Data.List (intercalate, partition, sort) +import Text.Printf (printf) + +-- | Summary statistics over one measurement group. +data Stats = Stats + { statMin :: !Double + , statQ1 :: !Double + , statMedian :: !Double + , statQ3 :: !Double + , statMax :: !Double + , statOutliers :: ![Double] -- ^ Values outside the IQR fences, sorted + , statSamples :: ![Double] -- ^ All samples, sorted + } deriving (Show, Eq) + +-- | Interpolated percentile of a sorted, non-empty sample list. +percentile :: [Double] -> Double -> Double +percentile sorted p = + let n = length sorted + index = (p / 100) * fromIntegral (n - 1) + lower = floor index :: Int + upper = ceiling index :: Int + weight = index - fromIntegral lower + in if lower == upper + then sorted !! lower + else sorted !! lower * (1 - weight) + sorted !! upper * weight + +-- | Compute summary statistics for a list of samples. +computeStats :: [Double] -> Stats +computeStats [] = Stats 0 0 0 0 0 [] [] +computeStats values = + let sorted = sort values + q1 = percentile sorted 25 + median = percentile sorted 50 + q3 = percentile sorted 75 + iqr = q3 - q1 + lowerFence = q1 - 1.5 * iqr + upperFence = q3 + 1.5 * iqr + (outliers, kept) = partition (\v -> v < lowerFence || v > upperFence) sorted + bounds = if null kept then sorted else kept + in Stats + { statMin = minimum bounds + , statQ1 = q1 + , statMedian = median + , statQ3 = q3 + , statMax = maximum bounds + , statOutliers = outliers + , statSamples = sorted + } + +-- | Render one YAML results section (contract Results Schema). +renderStatsYaml + :: String -- ^ Section name (upload | download | latency) + -> Int -- ^ Iteration count + -> Int -- ^ Decimal places for formatted values + -> String -- ^ Unit label (Gbps | ms) + -> Stats + -> String +renderStatsYaml section iterations decimals unit s = unlines + [ section ++ ":" + , " iterations: " ++ show iterations + , " min: " ++ fmt (statMin s) + , " q1: " ++ fmt (statQ1 s) + , " median: " ++ fmt (statMedian s) + , " q3: " ++ fmt (statQ3 s) + , " max: " ++ fmt (statMax s) + , " outliers: " ++ fmtList (statOutliers s) + , " samples: " ++ fmtList (statSamples s) + , " unit: " ++ unit + ] + where + fmt = printf ("%." ++ show decimals ++ "f") + fmtList xs = "[" ++ intercalate ", " (map fmt xs) ++ "]" diff --git a/libp2p-hs.cabal b/libp2p-hs.cabal index c5579a1..337d1b9 100644 --- a/libp2p-hs.cabal +++ b/libp2p-hs.cabal @@ -62,6 +62,7 @@ library LibP2P.Protocol.Identify.Message LibP2P.Protocol.Identify LibP2P.Protocol.Ping + LibP2P.Protocol.Perf LibP2P.DHT.Types LibP2P.DHT.Distance LibP2P.DHT.RoutingTable @@ -129,6 +130,22 @@ executable libp2p-interop libp2p-hs ghc-options: -threaded -rtsopts +executable libp2p-perf + import: warnings, lang + hs-source-dirs: interop/perf + main-is: Main.hs + other-modules: + PerfInterop.Stats + build-depends: + base >= 4.18 && < 5, + bytestring >= 0.10 && < 0.13, + text >= 1.2 && < 2.2, + time >= 1.9 && < 2, + network >= 3.1 && < 3.3, + hedis >= 0.15 && < 0.16, + libp2p-hs + ghc-options: -threaded -rtsopts + executable libp2p-kad-dht-node import: warnings, lang hs-source-dirs: interop/kad-dht-node @@ -148,9 +165,11 @@ executable libp2p-kad-dht-node test-suite libp2p-hs-test import: warnings, lang type: exitcode-stdio-1.0 - hs-source-dirs: test + hs-source-dirs: test, interop/perf main-is: Spec.hs other-modules: + PerfInterop.Stats + PerfInterop.StatsSpec LibP2P.Core.VarintSpec LibP2P.Core.MultihashSpec LibP2P.Multiaddr.MultiaddrSpec @@ -187,6 +206,7 @@ test-suite libp2p-hs-test LibP2P.Protocol.Identify.IdentifySpec LibP2P.Protocol.Identify.IdentifyOnConnectSpec LibP2P.Protocol.Ping.PingSpec + LibP2P.Protocol.Perf.PerfSpec LibP2P.Noise.HandshakeSpec LibP2P.Noise.ForeignPeerSpec LibP2P.DHT.DistanceSpec diff --git a/src/LibP2P.hs b/src/LibP2P.hs index f941341..2738a80 100644 --- a/src/LibP2P.hs +++ b/src/LibP2P.hs @@ -83,6 +83,13 @@ module LibP2P , PingResult (..) , PingError (..) + -- * Perf protocol + , registerPerfHandler + , runPerf + , perfProtocolId + , PerfResult (..) + , PerfError (..) + -- * NAT traversal (AutoNAT, Circuit Relay v2, DCUtR) , NATConfig (..) , defaultNATConfig @@ -152,6 +159,13 @@ import LibP2P.Protocol.Identify , registerIdentifyHandlers , requestIdentify ) +import LibP2P.Protocol.Perf + ( PerfError (..) + , PerfResult (..) + , perfProtocolId + , registerPerfHandler + , runPerf + ) import LibP2P.Protocol.Ping ( PingError (..) , PingResult (..) diff --git a/src/LibP2P/Core/Binary.hs b/src/LibP2P/Core/Binary.hs index ead429d..933b754 100644 --- a/src/LibP2P/Core/Binary.hs +++ b/src/LibP2P/Core/Binary.hs @@ -5,15 +5,17 @@ module LibP2P.Core.Binary ( word16BE , word32BE + , word64BE , readWord16BE , readWord32BE + , readWord64BE ) where -import Data.Binary.Get (getWord16be, getWord32be, runGet) +import Data.Binary.Get (getWord16be, getWord32be, getWord64be, runGet) import Data.ByteString (ByteString) import qualified Data.ByteString.Builder as Builder import qualified Data.ByteString.Lazy as BL -import Data.Word (Word16, Word32) +import Data.Word (Word16, Word32, Word64) -- | Encode a Word16 as 2-byte big-endian ByteString. word16BE :: Word16 -> ByteString @@ -23,6 +25,10 @@ word16BE = BL.toStrict . Builder.toLazyByteString . Builder.word16BE word32BE :: Word32 -> ByteString word32BE = BL.toStrict . Builder.toLazyByteString . Builder.word32BE +-- | Encode a Word64 as 8-byte big-endian ByteString. +word64BE :: Word64 -> ByteString +word64BE = BL.toStrict . Builder.toLazyByteString . Builder.word64BE + -- | Read a big-endian Word16 from a ByteString (must be >= 2 bytes). readWord16BE :: ByteString -> Word16 readWord16BE = runGet getWord16be . BL.fromStrict @@ -30,3 +36,7 @@ readWord16BE = runGet getWord16be . BL.fromStrict -- | Read a big-endian Word32 from a ByteString (must be >= 4 bytes). readWord32BE :: ByteString -> Word32 readWord32BE = runGet getWord32be . BL.fromStrict + +-- | Read a big-endian Word64 from a ByteString (must be >= 8 bytes). +readWord64BE :: ByteString -> Word64 +readWord64BE = runGet getWord64be . BL.fromStrict diff --git a/src/LibP2P/MultistreamSelect/Negotiation.hs b/src/LibP2P/MultistreamSelect/Negotiation.hs index bfa78d3..ad2dd50 100644 --- a/src/LibP2P/MultistreamSelect/Negotiation.hs +++ b/src/LibP2P/MultistreamSelect/Negotiation.hs @@ -10,10 +10,11 @@ module LibP2P.MultistreamSelect.Negotiation , negotiateResponder , mkMemoryStreamPair , readExactBounded + , closeQuietly ) where import Control.Concurrent.STM -import Control.Exception (IOException, catch) +import Control.Exception (IOException, SomeException, catch) import Control.Monad (replicateM) import Data.ByteString (ByteString) import qualified Data.ByteString as BS @@ -98,6 +99,12 @@ readExactBounded stream maxLen n chunk <- BS.pack <$> replicateM m (streamReadByte stream) (chunk :) <$> go (remaining - m) +-- | Close a stream, swallowing any exception (best-effort EOF signal). +-- Shared by protocol handlers that must release a stream on every exit +-- path without letting a close-time error mask the real outcome. +closeQuietly :: StreamIO -> IO () +closeQuietly stream = streamClose stream `catch` \(_ :: SomeException) -> pure () + -- | Read a complete multistream-select message from a stream. -- Reads varint length byte-by-byte, then reads the full payload. -- The declared length is validated against 'maxMessageLength' before diff --git a/src/LibP2P/Protocol/Perf.hs b/src/LibP2P/Protocol/Perf.hs new file mode 100644 index 0000000..32739e2 --- /dev/null +++ b/src/LibP2P/Protocol/Perf.hs @@ -0,0 +1,166 @@ +-- | Perf protocol implementation (specs/perf). +-- +-- Protocol ID: /perf/1.0.0 +-- +-- Wire format: the client sends a single 8-byte big-endian uint64 +-- naming the number of bytes it wants the server to send back, then +-- streams its upload payload, then half-closes its write side. The +-- server reads the 8-byte header, drains the upload until EOF, and +-- only then (perf.md: the response "MUST NOT be run concurrently" +-- with the upload) writes the requested number of bytes back and +-- closes the stream. +-- +-- Each measurement runs on its own stream; there is no framing and no +-- protobuf. Payload bytes carry no meaning, so both sides send zeros. +module LibP2P.Protocol.Perf + ( -- * Protocol ID + perfProtocolId + -- * Types + , PerfError (..) + , PerfResult (..) + -- * Responder + , handlePerf + -- * Initiator + , perfOnStream + , runPerf + -- * Registration + , registerPerfHandler + ) where + +import Control.Concurrent.STM (atomically, readTVar, writeTVar) +import Control.Exception (SomeException, catch, finally, try) +import qualified Data.ByteString as BS +import qualified Data.Map.Strict as Map +import Data.Text (Text) +import Data.Time.Clock (NominalDiffTime, diffUTCTime, getCurrentTime) +import Data.Word (Word64) +import LibP2P.Core.Binary (readWord64BE, word64BE) +import LibP2P.Crypto.PeerId (PeerId) +import LibP2P.MultistreamSelect.Negotiation + ( NegotiationResult (..) + , StreamIO (..) + , closeQuietly + , negotiateInitiator + , readExactBounded + ) +import LibP2P.Switch.Connection (newStream) +import LibP2P.Switch.Types + ( Connection (..) + , Switch (..) + ) + +-- | Perf protocol ID. +perfProtocolId :: Text +perfProtocolId = "/perf/1.0.0" + +-- | Chunk size for bulk sends, matching the 64 KiB block the reference +-- implementations use. +perfBlockSize :: Int +perfBlockSize = 65536 + +-- | Perf error types. +data PerfError + = PerfNegotiationError !String -- ^ Stream open or protocol negotiation failed + | PerfStreamError !String -- ^ I/O error during the exchange + deriving (Show, Eq) + +-- | Successful perf exchange result. +data PerfResult = PerfResult + { perfElapsed :: !NominalDiffTime -- ^ Header write to last byte received + } deriving (Show, Eq) + +-- | A shared zero block for bulk sends. +zeroBlock :: BS.ByteString +zeroBlock = BS.replicate perfBlockSize 0 + +-- | Write @n@ zero bytes in 'perfBlockSize' chunks. +writeZeros :: StreamIO -> Word64 -> IO () +writeZeros stream = go + where + go 0 = pure () + go n = do + let chunk = min n (fromIntegral perfBlockSize) + streamWrite stream (BS.take (fromIntegral chunk) zeroBlock) + go (n - chunk) + +-- | Read and discard bytes until EOF (the initiator's half-close). +drainUntilEof :: StreamIO -> IO () +drainUntilEof stream = loop `catch` \(_ :: SomeException) -> pure () + where + loop = streamReadByte stream >> loop + +-- | Read and discard exactly @n@ bytes. The payload carries no meaning, +-- so no ByteString is built; premature EOF throws. +discardExactly :: StreamIO -> Word64 -> IO () +discardExactly stream = go + where + go :: Word64 -> IO () + go 0 = pure () + go !n = streamReadByte stream >> go (n - 1) + +-- | Handle an inbound perf request (responder). +-- +-- Reads the 8-byte download size, drains the client's upload until it +-- half-closes, then sends the requested bytes back and closes. A client +-- that closes before sending a full header is dropped silently. +handlePerf :: StreamIO -> PeerId -> IO () +handlePerf stream _remotePeerId = serve `finally` closeQuietly stream + where + serve = do + header <- readExactBounded stream 8 8 `catch` + (\(_ :: SomeException) -> pure (Left "stream closed")) + case header of + Left _ -> pure () + Right sizeBytes -> do + let downloadSize = readWord64BE sizeBytes + drainUntilEof stream + writeZeros stream downloadSize + `catch` (\(_ :: SomeException) -> pure ()) + +-- | One perf exchange on an already-negotiated stream (initiator). +-- +-- Sends the header and @uploadBytes@ zeros, half-closes the write side, +-- then reads exactly @downloadBytes@ back. The elapsed time covers the +-- full exchange, header write to last byte read. +perfOnStream :: StreamIO -> Word64 -> Word64 -> IO (Either PerfError PerfResult) +perfOnStream stream uploadBytes downloadBytes = do + t0 <- getCurrentTime + outcome <- try $ do + streamWrite stream (word64BE downloadBytes) + writeZeros stream uploadBytes + streamClose stream + discardExactly stream downloadBytes + case outcome of + Left (e :: SomeException) -> + pure (Left (PerfStreamError ("perf I/O failed: " ++ show e))) + Right () -> do + t1 <- getCurrentTime + pure (Right (PerfResult (diffUTCTime t1 t0))) + +-- | Run one perf measurement against a connected peer: open a stream, +-- negotiate /perf/1.0.0, run the exchange, and release the stream. +runPerf :: Switch -> Connection -> Word64 -> Word64 -> IO (Either PerfError PerfResult) +runPerf sw conn uploadBytes downloadBytes = do + streamOrErr <- newStream sw conn + case streamOrErr of + Left err -> + pure (Left (PerfNegotiationError ("stream reservation failed: " ++ show err))) + Right stream -> do + negotiated <- try (negotiateInitiator stream [perfProtocolId]) + case negotiated of + Right (Accepted _) -> + perfOnStream stream uploadBytes downloadBytes + `finally` closeQuietly stream + Right NoProtocol -> do + closeQuietly stream + pure (Left (PerfNegotiationError "remote does not support perf")) + Left (e :: SomeException) -> do + closeQuietly stream + pure (Left (PerfNegotiationError ("perf negotiation failed: " ++ show e))) + +-- | Register the perf handler on the Switch. +registerPerfHandler :: Switch -> IO () +registerPerfHandler sw = atomically $ do + protos <- readTVar (swProtocols sw) + let handler conn stream = handlePerf stream (connPeerId conn) + writeTVar (swProtocols sw) (Map.insert perfProtocolId handler protos) diff --git a/test/LibP2P/Protocol/Perf/PerfSpec.hs b/test/LibP2P/Protocol/Perf/PerfSpec.hs new file mode 100644 index 0000000..95733db --- /dev/null +++ b/test/LibP2P/Protocol/Perf/PerfSpec.hs @@ -0,0 +1,135 @@ +module LibP2P.Protocol.Perf.PerfSpec (spec) where + +import Control.Concurrent.Async (async, wait, withAsync) +import Control.Concurrent.STM (atomically, readTVar) +import Control.Exception (try) +import qualified Data.ByteString as BS +import Data.IORef (modifyIORef', newIORef, readIORef) +import qualified Data.Map.Strict as Map +import Data.Word (Word8) +import LibP2P.Crypto.Ed25519 (generateKeyPair) +import LibP2P.Crypto.Key (kpPublic) +import LibP2P.Crypto.PeerId (PeerId, fromPublicKey) +import LibP2P.EofStream (mkEofStreamPair) +import LibP2P.MultistreamSelect.Negotiation (StreamIO (..)) +import LibP2P.Protocol.Perf +import LibP2P.Switch (newSwitch) +import LibP2P.Switch.Types (Switch (..)) +import System.Timeout (timeout) +import Test.Hspec + +-- | Read exactly n bytes from a stream (test helper). +readNBytes :: StreamIO -> Int -> IO BS.ByteString +readNBytes s n = BS.pack <$> mapM (const (streamReadByte s)) [1 .. n] + +-- | Expect EOF on the next read. +expectEof :: StreamIO -> Expectation +expectEof s = do + result <- try (streamReadByte s) :: IO (Either IOError Word8) + case result of + Left _ -> pure () + Right b -> expectationFailure ("expected EOF, got byte " ++ show b) + +mkTestPeerId :: IO PeerId +mkTestPeerId = do + Right kp <- generateKeyPair + pure (fromPublicKey (kpPublic kp)) + +spec :: Spec +spec = do + describe "handlePerf (server)" $ do + it "should send back the requested number of bytes when the client half-closes" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + serverA <- async (handlePerf server pid) + -- 8-byte big-endian download size = 5, plus a 3-byte upload + streamWrite client (BS.pack [0, 0, 0, 0, 0, 0, 0, 5]) + streamWrite client (BS.pack [1, 2, 3]) + streamClose client + response <- readNBytes client 5 + BS.length response `shouldBe` 5 + expectEof client + wait serverA + + it "should decode the download size as big-endian" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + serverA <- async (handlePerf server pid) + -- 0x0100 = 256; a little-endian reading would be 2^48 instead + streamWrite client (BS.pack [0, 0, 0, 0, 0, 0, 1, 0]) + streamClose client + response <- readNBytes client 256 + BS.length response `shouldBe` 256 + expectEof client + wait serverA + + it "should send nothing when the requested download size is zero" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + serverA <- async (handlePerf server pid) + streamWrite client (BS.replicate 8 0) + streamClose client + expectEof client + wait serverA + + it "should close without responding when the client closes before the header" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + serverA <- async (handlePerf server pid) + streamClose client + expectEof client + wait serverA + + describe "perfOnStream (client)" $ do + it "should complete an upload/download exchange against handlePerf" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + withAsync (handlePerf server pid) $ \_ -> do + result <- perfOnStream client 1000 2000 + case result of + Left err -> expectationFailure ("perfOnStream failed: " ++ show err) + Right r -> perfElapsed r `shouldSatisfy` (>= 0) + + it "should complete a latency-shaped exchange (1 byte each way)" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + withAsync (handlePerf server pid) $ \_ -> do + result <- perfOnStream client 1 1 + result `shouldSatisfy` either (const False) (const True) + + it "should complete a zero-byte exchange" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + withAsync (handlePerf server pid) $ \_ -> do + result <- perfOnStream client 0 0 + result `shouldSatisfy` either (const False) (const True) + + it "should send the download size as an 8-byte big-endian header" $ do + (client, server) <- mkEofStreamPair + pid <- mkTestPeerId + writes <- newIORef [] + let recording = client + { streamWrite = \bs -> modifyIORef' writes (bs :) >> streamWrite client bs } + withAsync (handlePerf server pid) $ \_ -> do + _ <- perfOnStream recording 0 256 + chunks <- reverse <$> readIORef writes + BS.take 8 (BS.concat chunks) `shouldBe` BS.pack [0, 0, 0, 0, 0, 0, 1, 0] + + it "should report a stream error when the server disappears mid-download" $ do + (client, server) <- mkEofStreamPair + -- Fake server: reads nothing, immediately closes without sending + streamClose server + result <- timeout 1000000 (perfOnStream client 0 100) + case result of + Nothing -> expectationFailure "perfOnStream hung" + Just (Left (PerfStreamError _)) -> pure () + Just other -> expectationFailure ("expected PerfStreamError, got " ++ show other) + + describe "registerPerfHandler" $ do + it "should add the perf handler to the switch protocol map" $ do + Right kp <- generateKeyPair + let pid = fromPublicKey (kpPublic kp) + sw <- newSwitch pid kp + registerPerfHandler sw + protos <- atomically $ readTVar (swProtocols sw) + Map.member perfProtocolId protos `shouldBe` True diff --git a/test/PerfInterop/StatsSpec.hs b/test/PerfInterop/StatsSpec.hs new file mode 100644 index 0000000..d43df32 --- /dev/null +++ b/test/PerfInterop/StatsSpec.hs @@ -0,0 +1,77 @@ +module PerfInterop.StatsSpec (spec) where + +import PerfInterop.Stats +import Test.Hspec + +spec :: Spec +spec = do + describe "percentile" $ do + it "should interpolate linearly between samples" $ do + let xs = [1, 2, 3, 4] + percentile xs 25 `shouldBe` 1.75 + percentile xs 50 `shouldBe` 2.5 + percentile xs 75 `shouldBe` 3.25 + + it "should return the exact sample when the index is integral" $ do + let xs = [10, 20, 30, 40, 50] + percentile xs 0 `shouldBe` 10 + percentile xs 50 `shouldBe` 30 + percentile xs 100 `shouldBe` 50 + + it "should return the single sample for a singleton list" $ do + percentile [7] 25 `shouldBe` 7 + percentile [7] 75 `shouldBe` 7 + + describe "computeStats" $ do + it "should report min/max from the full set when there are no outliers" $ do + let s = computeStats [3, 1, 2, 5, 4] + statMin s `shouldBe` 1 + statMax s `shouldBe` 5 + statMedian s `shouldBe` 3 + statOutliers s `shouldBe` [] + statSamples s `shouldBe` [1, 2, 3, 4, 5] + + it "should flag values outside the 1.5*IQR fences as outliers" $ do + -- sorted: [10,11,12,13,14,100]; q1=11.25, q3=13.75, iqr=2.5 + -- fences: [7.5, 17.5] -> 100 is an outlier + let s = computeStats [12, 100, 10, 13, 11, 14] + statOutliers s `shouldBe` [100] + statMax s `shouldBe` 14 + statMin s `shouldBe` 10 + statSamples s `shouldBe` [10, 11, 12, 13, 14, 100] + + it "should keep quartiles computed over all samples including outliers" $ do + let s = computeStats [12, 100, 10, 13, 11, 14] + statQ1 s `shouldBe` 11.25 + statQ3 s `shouldBe` 13.75 + + describe "renderStatsYaml" $ do + it "should render a section with 2-decimal formatting" $ do + let s = computeStats [2.04, 2.05, 2.06] + renderStatsYaml "upload" 3 2 "Gbps" s `shouldBe` unlines + [ "upload:" + , " iterations: 3" + , " min: 2.04" + , " q1: 2.04" + , " median: 2.05" + , " q3: 2.05" + , " max: 2.06" + , " outliers: []" + , " samples: [2.04, 2.05, 2.06]" + , " unit: Gbps" + ] + + it "should render outliers as a flow list with the same precision" $ do + let s = computeStats [12, 100, 10, 13, 11, 14] + renderStatsYaml "latency" 6 3 "ms" s `shouldBe` unlines + [ "latency:" + , " iterations: 6" + , " min: 10.000" + , " q1: 11.250" + , " median: 12.500" + , " q3: 13.750" + , " max: 14.000" + , " outliers: [100.000]" + , " samples: [10.000, 11.000, 12.000, 13.000, 14.000, 100.000]" + , " unit: ms" + ]