ppad-censor-0.5.1: lib/Censor/Meter.hs
{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE CPP #-}
-- |
-- Module: Censor.Meter
-- Copyright: (c) 2026 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Measurement abstraction.
--
-- A t'Meter' wraps an @IO@ action and returns a 'Word64' reading.
-- 'wallClock' is available everywhere. On Linux, 'withCounter'
-- provides performance-counter meters via @perf_event_open@: the
-- retired-instruction and retired-branch counts are deterministic
-- properties of the executed code, immune to the frequency, cache,
-- and scheduling noise that limits the wall clock on fast targets,
-- while the other counters help attribute a difference to a channel
-- (see t'Counter').
module Censor.Meter (
-- * Meter type
Meter(..)
-- * Implementations
, wallClock
-- * Hardware performance-counter meters (Linux)
, Counter(..)
, MeterError(..)
, withCounter
) where
import Control.Exception (Exception, throwIO)
import Data.Word (Word64)
#if !defined(darwin_HOST_OS)
import GHC.Clock (getMonotonicTimeNSec)
#endif
#if defined(linux_HOST_OS)
import Control.Concurrent (runInBoundThread, rtsSupportsBoundThreads)
import Control.Exception (finally)
import Foreign.C.Types (CInt(..))
#endif
-- | A measurement device. @'measure' m k act@ runs @act@ @k@ times
-- back to back and returns one reading for the batch: nanoseconds
-- for 'wallClock', counted events for a 'withCounter' meter. Each
-- meter owns its inner loop, reading the clock or arming the
-- counter once per batch.
newtype Meter = Meter
{ measure :: Int -> IO () -> IO Word64
}
-- | Wall-clock meter: elapsed nanoseconds over the batch.
--
-- On Darwin, backed by @clock_gettime_nsec_np(CLOCK_UPTIME_RAW)@,
-- which resolves the hardware tick (~42 ns on Apple Silicon, so
-- sub-microsecond targets need batching; see 'Censor.cfgBatch').
-- Not @GHC.Clock.getMonotonicTimeNSec@, which macOS quantises to
-- microseconds, putting CDF cut points on quantisation boundaries
-- (see @issues\/handled\/ISSUE2.md@). Elsewhere, backed by
-- @GHC.Clock.getMonotonicTimeNSec@.
wallClock :: Meter
wallClock = Meter measureWall
#if defined(darwin_HOST_OS)
foreign import ccall unsafe "censor_monotonic_ns"
monotonicNS :: IO Word64
#else
monotonicNS :: IO Word64
monotonicNS = getMonotonicTimeNSec
{-# INLINE monotonicNS #-}
#endif
measureWall :: Int -> IO () -> IO Word64
measureWall !k !act = do
!t0 <- monotonicNS
rep k act
!t1 <- monotonicNS
pure $! t1 - t0
{-# INLINE measureWall #-}
-- shared inner loop. INLINE so it specialises at each meter call site
-- and the recursion compiles to a join-point with no per-iteration
-- closure allocation.
rep :: Int -> IO () -> IO ()
rep !k !act = go k
where
go 0 = pure ()
go i = act >> go (i - 1)
{-# INLINE rep #-}
-- | A hardware (or, for 'TaskClock', software) event to count.
--
-- With 'wallClock' the counters form a ladder in which each
-- adjacent gap isolates one cause of a class difference:
-- instructions (code path) -> cycles (microarchitectural latency)
-- -> ref-cycles (frequency) -> task-clock (kernel and interrupt
-- time) -> wall (scheduling gaps).
data Counter =
InstructionsRetired -- ^ @PERF_COUNT_HW_INSTRUCTIONS@; deterministic.
| BranchesRetired -- ^ @PERF_COUNT_HW_BRANCH_INSTRUCTIONS@;
-- deterministic.
| Cycles -- ^ @PERF_COUNT_HW_CPU_CYCLES@; also sensitive
-- to data-dependent latency.
| RefCycles -- ^ @PERF_COUNT_HW_REF_CPU_CYCLES@: cycles at
-- the constant reference rate, so a
-- difference here with clean 'Cycles' is a
-- frequency effect. Not available on every
-- microarchitecture.
| TaskClock -- ^ @PERF_COUNT_SW_TASK_CLOCK@: on-CPU
-- nanoseconds, including kernel time on the
-- task's behalf. A software counter, so
-- available in VMs, but counting kernel time
-- needs @kernel.perf_event_paranoid@ at most
-- @1@.
| BranchMisses -- ^ @PERF_COUNT_HW_BRANCH_MISSES@: catches a
-- secret-dependent branch direction when
-- retired branch counts balance.
| CacheMisses -- ^ @PERF_COUNT_HW_CACHE_MISSES@: attributes a
-- cycles-level difference to memory access
-- patterns.
deriving (Eq, Show)
-- | Why a 'withCounter' meter could not be opened.
data MeterError =
Unsupported -- ^ not a Linux build; no hardware-counter support
| OpenFailed !Int -- ^ @perf_event_open@ failed with this @errno@
-- (commonly @EACCES@\/@EPERM@ when
-- @kernel.perf_event_paranoid@ is too high)
deriving (Eq, Show)
instance Exception MeterError
#if defined(linux_HOST_OS)
foreign import ccall unsafe "censor_perf_open"
c_perf_open :: CInt -> IO CInt
foreign import ccall unsafe "censor_perf_begin"
c_perf_begin :: CInt -> IO ()
foreign import ccall unsafe "censor_perf_end"
c_perf_end :: CInt -> IO Word64
foreign import ccall unsafe "censor_perf_close"
c_perf_close :: CInt -> IO ()
counterCode :: Counter -> CInt
counterCode InstructionsRetired = 0
counterCode BranchesRetired = 1
counterCode Cycles = 2
counterCode RefCycles = 3
counterCode TaskClock = 4
counterCode BranchMisses = 5
counterCode CacheMisses = 6
-- | Acquire a performance-counter t'Meter' for the duration of the
-- callback, then release it. Throws 'MeterError' if the counter
-- cannot be opened.
--
-- Counters count user space only, which unprivileged processes may
-- open at @kernel.perf_event_paranoid@ @2@ or lower; 'TaskClock'
-- deliberately includes kernel time and needs @1@ or lower, failing
-- with @'OpenFailed' 13@ otherwise rather than silently degrading
-- to a user-only clock. A counter is bound to the thread that
-- opened it, so the callback runs on one OS thread.
withCounter :: Counter -> (Meter -> IO a) -> IO a
withCounter counter k = onOneThread $ do
fd <- c_perf_open (counterCode counter)
if fd < 0
then throwIO (OpenFailed (negate (fromIntegral fd)))
else k (meterFor fd) `finally` c_perf_close fd
where
-- the perf fd is thread-affine; keep open + measures on one OS
-- thread. runInBoundThread requires the threaded RTS, so fall back
-- to running inline when there is only the single OS thread anyway.
onOneThread
| rtsSupportsBoundThreads = runInBoundThread
| otherwise = id
meterFor :: CInt -> Meter
meterFor !fd = Meter $ \k act -> do
c_perf_begin fd
rep k act
c_perf_end fd
{-# INLINE meterFor #-}
#else
-- | Hardware performance-counter meters are only available on Linux;
-- on other platforms this always throws 'Unsupported'.
withCounter :: Counter -> (Meter -> IO a) -> IO a
withCounter _ _ = throwIO Unsupported
#endif