moonlight-pale-0.1.0.0: src-bench/Moonlight/Pale/Bench/Measure.hs
{-| Checked wall-clock and RTS-resource measurements for benchmark actions. -}
module Moonlight.Pale.Bench.Measure
( TimedSample (..),
timeSample,
RtsCounter (..),
RtsSnapshot (..),
RtsDeltaObstruction (..),
RtsDelta (..),
checkedRtsDelta,
RtsPhaseResourceObstruction (..),
RtsPhaseMeasurement,
rtsPhaseElapsedNanoseconds,
measuredRtsPhaseResourceBytes,
RtsPhaseBoundaryObservation (..),
finalizeRtsPhaseMeasurement,
unmeasuredRtsPhaseMeasurement,
combineRtsPhaseMeasurements,
observeRtsPhaseEither,
RtsMeasurementFailure (..),
RtsMeasurement (..),
finalizeRtsMeasurement,
measureSample,
)
where
import Control.DeepSeq (force)
import Control.Exception (evaluate)
import Data.Bifunctor (first)
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Word (Word32, Word64)
import GHC.Clock (getMonotonicTimeNSec)
import GHC.Stats
( RTSStats,
allocated_bytes,
copied_bytes,
cpu_ns,
elapsed_ns,
gc,
gc_cpu_ns,
gc_elapsed_ns,
gcdetails_live_bytes,
gcs,
getRTSStats,
getRTSStatsEnabled,
major_gcs,
max_live_bytes,
mutator_cpu_ns,
mutator_elapsed_ns,
)
import System.Mem (performMajorGC)
data TimedSample value = TimedSample
{ timedSampleElapsedNanoseconds :: !Word64,
timedSampleValue :: !value,
timedSampleDigest :: !Int
}
timeSample ::
Int -> (Int -> IO input) -> (input -> Either errorValue value) -> (value -> Int) ->
IO (Either errorValue (TimedSample value))
timeSample sampleOrdinal prepareInput runSample digest = do
input <- prepareInput sampleOrdinal
start <- getMonotonicTimeNSec
sampleResult <- evaluate (runSample input)
traverse
( \sampleValue -> do
sampleDigest <- evaluate (force (digest sampleValue))
end <- getMonotonicTimeNSec
pure (TimedSample (end - start) sampleValue sampleDigest)
)
sampleResult
-- The closed set of monotone RTS counters used by process measurements.
data RtsCounter
= RtsCounterGcs
| RtsCounterMajorGcs
| RtsCounterAllocatedBytes
| RtsCounterCopiedBytes
| RtsCounterMutatorCpuNanoseconds
| RtsCounterMutatorElapsedNanoseconds
| RtsCounterGcCpuNanoseconds
| RtsCounterGcElapsedNanoseconds
| RtsCounterCpuNanoseconds
| RtsCounterElapsedNanoseconds
deriving stock (Eq, Show, Read)
-- Strict action-boundary projection of the cumulative RTS counters.
data RtsSnapshot = RtsSnapshot
{ rtsSnapshotGcs :: !Word32,
rtsSnapshotMajorGcs :: !Word32,
rtsSnapshotAllocatedBytes :: !Word64,
rtsSnapshotCopiedBytes :: !Word64,
rtsSnapshotMutatorCpuNanoseconds :: !Int64,
rtsSnapshotMutatorElapsedNanoseconds :: !Int64,
rtsSnapshotGcCpuNanoseconds :: !Int64,
rtsSnapshotGcElapsedNanoseconds :: !Int64,
rtsSnapshotCpuNanoseconds :: !Int64,
rtsSnapshotElapsedNanoseconds :: !Int64,
rtsSnapshotLiveBytes :: !Word64,
rtsSnapshotMaxLiveBytes :: !Word64
}
deriving stock (Eq, Show)
data RtsDeltaObstruction
= RtsCounterRegression !RtsCounter !Integer !Integer
deriving stock (Eq, Show, Read)
-- Checked action-local differences of every governed cumulative RTS counter.
data RtsDelta = RtsDelta
{ rtsDeltaGcs :: !Word64,
rtsDeltaMajorGcs :: !Word64,
rtsDeltaAllocatedBytes :: !Word64,
rtsDeltaCopiedBytes :: !Word64,
rtsDeltaMutatorCpuNanoseconds :: !Word64,
rtsDeltaMutatorElapsedNanoseconds :: !Word64,
rtsDeltaGcCpuNanoseconds :: !Word64,
rtsDeltaGcElapsedNanoseconds :: !Word64,
rtsDeltaCpuNanoseconds :: !Word64,
rtsDeltaElapsedNanoseconds :: !Word64
}
deriving stock (Eq, Show, Read)
-- Why a profiled phase cannot publish checked allocation/copy evidence.
-- The semantic action may still succeed: this obstruction belongs to the
-- measurement boundary, not to the domain interpreter being observed.
data RtsPhaseResourceObstruction
= RtsPhaseResourcesUnmeasured
| RtsPhaseStatsUnavailable
| RtsPhaseDeltaRefused !(NonEmpty RtsDeltaObstruction)
deriving stock (Eq, Show, Read)
data RtsPhaseResources
= RtsPhaseResourcesMeasured
!Integer
!Integer
| RtsPhaseResourcesNotMeasured
| RtsPhaseResourcesStatsUnavailable
| RtsPhaseResourcesDeltaRefused !(NonEmpty RtsDeltaObstruction)
deriving stock (Eq, Show, Read)
-- One semantic phase observed at its existing IO interpreter boundary.
-- Allocation and copied bytes are checked monotone deltas. They use
-- 'Integer' after differencing so composing adjacent sub-phases is exact and
-- cannot overflow a machine counter type.
data RtsPhaseMeasurement = RtsPhaseMeasurement
{ rtsPhaseElapsedNanoseconds :: !Integer,
rtsPhaseResources :: !RtsPhaseResources
}
deriving stock (Eq, Show, Read)
-- The exhaustive boundary evidence from which a phase measurement is
-- finalized. Snapshot construction remains owned by the RTS interpreter;
-- the pure finalizer makes precedence and checked differencing testable.
data RtsPhaseBoundaryObservation
= RtsPhaseBoundaryNotMeasured
| RtsPhaseBoundaryStatsUnavailable
| RtsPhaseBoundarySnapshots !RtsSnapshot !RtsSnapshot !RtsSnapshot
deriving stock (Eq, Show)
finalizeRtsPhaseMeasurement ::
Integer ->
RtsPhaseBoundaryObservation ->
RtsPhaseMeasurement
finalizeRtsPhaseMeasurement elapsedNanoseconds boundaryObservation =
RtsPhaseMeasurement
{ rtsPhaseElapsedNanoseconds = elapsedNanoseconds,
rtsPhaseResources =
case boundaryObservation of
RtsPhaseBoundaryNotMeasured -> RtsPhaseResourcesNotMeasured
RtsPhaseBoundaryStatsUnavailable -> RtsPhaseResourcesStatsUnavailable
RtsPhaseBoundarySnapshots beforeAction afterAction afterPostGc ->
either
(RtsPhaseResourcesDeltaRefused . (:| []))
( \deltaValue ->
RtsPhaseResourcesMeasured
(toInteger (rtsDeltaAllocatedBytes deltaValue))
(toInteger (rtsDeltaCopiedBytes deltaValue))
)
(checkedRtsPhaseDelta beforeAction afterAction afterPostGc)
}
measuredRtsPhaseResourceBytes ::
RtsPhaseMeasurement ->
Either RtsPhaseResourceObstruction (Integer, Integer)
measuredRtsPhaseResourceBytes measurementValue =
case rtsPhaseResources measurementValue of
RtsPhaseResourcesMeasured allocatedBytes copiedBytes ->
Right (allocatedBytes, copiedBytes)
RtsPhaseResourcesNotMeasured -> Left RtsPhaseResourcesUnmeasured
RtsPhaseResourcesStatsUnavailable -> Left RtsPhaseStatsUnavailable
RtsPhaseResourcesDeltaRefused obstructions ->
Left (RtsPhaseDeltaRefused obstructions)
unmeasuredRtsPhaseMeasurement :: RtsPhaseMeasurement
unmeasuredRtsPhaseMeasurement =
finalizeRtsPhaseMeasurement 0 RtsPhaseBoundaryNotMeasured
combineRtsPhaseMeasurements ::
RtsPhaseMeasurement ->
RtsPhaseMeasurement ->
RtsPhaseMeasurement
combineRtsPhaseMeasurements leftMeasurement rightMeasurement =
RtsPhaseMeasurement
{ rtsPhaseElapsedNanoseconds =
rtsPhaseElapsedNanoseconds leftMeasurement
+ rtsPhaseElapsedNanoseconds rightMeasurement,
rtsPhaseResources =
combineRtsPhaseResources
(rtsPhaseResources leftMeasurement)
(rtsPhaseResources rightMeasurement)
}
combineRtsPhaseResources ::
RtsPhaseResources ->
RtsPhaseResources ->
RtsPhaseResources
combineRtsPhaseResources leftResources rightResources =
case (leftResources, rightResources) of
(RtsPhaseResourcesDeltaRefused leftObstructions, RtsPhaseResourcesDeltaRefused rightObstructions) ->
RtsPhaseResourcesDeltaRefused (leftObstructions <> rightObstructions)
(RtsPhaseResourcesDeltaRefused obstructions, _) ->
RtsPhaseResourcesDeltaRefused obstructions
(_, RtsPhaseResourcesDeltaRefused obstructions) ->
RtsPhaseResourcesDeltaRefused obstructions
(RtsPhaseResourcesStatsUnavailable, _) -> RtsPhaseResourcesStatsUnavailable
(_, RtsPhaseResourcesStatsUnavailable) -> RtsPhaseResourcesStatsUnavailable
(RtsPhaseResourcesNotMeasured, _) -> RtsPhaseResourcesNotMeasured
(_, RtsPhaseResourcesNotMeasured) -> RtsPhaseResourcesNotMeasured
(RtsPhaseResourcesMeasured leftAllocated leftCopied, RtsPhaseResourcesMeasured rightAllocated rightCopied) ->
RtsPhaseResourcesMeasured
(leftAllocated + rightAllocated)
(leftCopied + rightCopied)
-- Observe one already-owned pure phase without introducing a second
-- pipeline. A major collection before the phase establishes the allocation
-- boundary; the post-phase collection closes the nursery for allocation only.
-- Copied bytes and elapsed time stop before that collection, matching
-- 'finalizeRtsMeasurement'. Neither boundary collection is charged to phase
-- elapsed time.
observeRtsPhaseEither ::
(value -> witness) ->
Either errorValue value ->
IO (Either errorValue (value, RtsPhaseMeasurement))
observeRtsPhaseEither timingReadiness phaseResult =
getRTSStatsEnabled >>= \statsEnabled ->
if statsEnabled
then observeWithStats
else observeWithoutStats
where
observeWithStats = do
beforeActionStats <- majorGcStats
start <- getMonotonicTimeNSec
case phaseResult of
Left phaseFailure -> pure (Left phaseFailure)
Right phaseValue -> do
_ <- evaluate (timingReadiness phaseValue)
end <- getMonotonicTimeNSec
afterActionStats <- getRTSStats
afterPostGcStats <- majorGcStats
pure
( Right
( phaseValue,
finalizeRtsPhaseMeasurement
(toInteger (end - start))
( RtsPhaseBoundarySnapshots
(rtsSnapshotFromStats beforeActionStats)
(rtsSnapshotFromStats afterActionStats)
(rtsSnapshotFromStats afterPostGcStats)
)
)
)
observeWithoutStats = do
start <- getMonotonicTimeNSec
case phaseResult of
Left phaseFailure -> pure (Left phaseFailure)
Right phaseValue -> do
_ <- evaluate (timingReadiness phaseValue)
end <- getMonotonicTimeNSec
pure
( Right
( phaseValue,
finalizeRtsPhaseMeasurement
(toInteger (end - start))
RtsPhaseBoundaryStatsUnavailable
)
)
checkedRtsPhaseDelta ::
RtsSnapshot ->
RtsSnapshot ->
RtsSnapshot ->
Either RtsDeltaObstruction RtsDelta
checkedRtsPhaseDelta beforeAction afterAction afterPostGc =
(\actionDelta allocatedBytes -> actionDelta {rtsDeltaAllocatedBytes = allocatedBytes})
<$> checkedRtsDelta beforeAction afterAction
<*> checkedCounterDifference
RtsCounterAllocatedBytes
(rtsSnapshotAllocatedBytes beforeAction)
(rtsSnapshotAllocatedBytes afterPostGc)
checkedRtsDelta ::
RtsSnapshot ->
RtsSnapshot ->
Either RtsDeltaObstruction RtsDelta
checkedRtsDelta beforeSnapshot afterSnapshot =
RtsDelta
<$> counterDelta RtsCounterGcs rtsSnapshotGcs
<*> counterDelta RtsCounterMajorGcs rtsSnapshotMajorGcs
<*> counterDelta RtsCounterAllocatedBytes rtsSnapshotAllocatedBytes
<*> counterDelta RtsCounterCopiedBytes rtsSnapshotCopiedBytes
<*> counterDelta RtsCounterMutatorCpuNanoseconds rtsSnapshotMutatorCpuNanoseconds
<*> counterDelta RtsCounterMutatorElapsedNanoseconds rtsSnapshotMutatorElapsedNanoseconds
<*> counterDelta RtsCounterGcCpuNanoseconds rtsSnapshotGcCpuNanoseconds
<*> counterDelta RtsCounterGcElapsedNanoseconds rtsSnapshotGcElapsedNanoseconds
<*> counterDelta RtsCounterCpuNanoseconds rtsSnapshotCpuNanoseconds
<*> counterDelta RtsCounterElapsedNanoseconds rtsSnapshotElapsedNanoseconds
where
counterDelta ::
(Integral counter) =>
RtsCounter ->
(RtsSnapshot -> counter) ->
Either RtsDeltaObstruction Word64
counterDelta counter project =
checkedCounterDifference counter (project beforeSnapshot) (project afterSnapshot)
checkedCounterDifference ::
(Integral counter) =>
RtsCounter ->
counter ->
counter ->
Either RtsDeltaObstruction Word64
checkedCounterDifference counter beforeValue afterValue
| afterValue < beforeValue =
Left
( RtsCounterRegression
counter
(toInteger beforeValue)
(toInteger afterValue)
)
| otherwise =
Right (fromIntegral (afterValue - beforeValue))
data RtsMeasurementFailure errorValue
= RtsMeasurementStatsDisabled
| RtsMeasurementActionFailed !errorValue
| RtsMeasurementDeltaFailed !RtsDeltaObstruction
deriving stock (Eq, Show)
data RtsMeasurement value = RtsMeasurement
{ rtsMeasurementElapsedNanoseconds :: !Word64,
rtsMeasurementDelta :: !RtsDelta,
rtsMeasurementProcessLiveBytesAfterGc :: !Word64,
rtsMeasurementProcessMaxLiveBytes :: !Word64,
rtsMeasurementValue :: !value,
rtsMeasurementDigest :: !Int
}
-- Pure checked gluing of the three action-boundary RTS observations.
-- The live and maximum fields are explicitly process-wide observations. GHC's
-- counters do not expose action-local retained or peak residency.
--
-- Allocation is taken across the post-GC boundary while every other counter is
-- taken across the action boundary, and the asymmetry is deliberate. GHC
-- refreshes @allocated_bytes@ at garbage collections, so a plain
-- before-to-after difference counts only the allocation that a collection
-- inside the region happened to close out: measured 2026-08-06, four of five
-- atomic probes reported exactly zero allocated bytes beside a nonzero wall,
-- each with a zero GC count, while the one probe that triggered four
-- collections reported 17.7 MB. Reading allocation after a major collection
-- accounts the outstanding nursery. It is sound for this counter alone because
-- a collection performs no mutator allocation; the timing counters and
-- @copied_bytes@ must not cross that boundary, since the collection's own cost
-- would be attributed to the measured action.
finalizeRtsMeasurement ::
Word64 ->
RtsSnapshot ->
RtsSnapshot ->
RtsSnapshot ->
value ->
Int ->
Either RtsDeltaObstruction (RtsMeasurement value)
finalizeRtsMeasurement elapsedNanoseconds beforeAction afterAction afterPostGc sampleValue sampleDigest =
(\actionDelta allocatedBytes ->
RtsMeasurement
{ rtsMeasurementElapsedNanoseconds = elapsedNanoseconds,
rtsMeasurementDelta = actionDelta {rtsDeltaAllocatedBytes = allocatedBytes},
rtsMeasurementProcessLiveBytesAfterGc = rtsSnapshotLiveBytes afterPostGc,
rtsMeasurementProcessMaxLiveBytes = rtsSnapshotMaxLiveBytes afterPostGc,
rtsMeasurementValue = sampleValue,
rtsMeasurementDigest = sampleDigest
}
)
<$> checkedRtsDelta beforeAction afterAction
<*> checkedCounterDifference
RtsCounterAllocatedBytes
(rtsSnapshotAllocatedBytes beforeAction)
(rtsSnapshotAllocatedBytes afterPostGc)
measureSample ::
Int ->
(Int -> IO input) ->
(input -> IO (Either errorValue value)) ->
(value -> ()) ->
(value -> Int) ->
IO (Either (RtsMeasurementFailure errorValue) (RtsMeasurement value))
measureSample sampleOrdinal prepareInput runSample timingReadiness digest =
getRTSStatsEnabled >>= \statsEnabled ->
if statsEnabled
then measureWithStats
else pure (Left RtsMeasurementStatsDisabled)
where
measureWithStats = do
input <- prepareInput sampleOrdinal
beforeActionStats <- majorGcStats
start <- getMonotonicTimeNSec
sampleResult <- runSample input
fmap (>>= id) $
traverse
(finishMeasurement beforeActionStats start)
(first RtsMeasurementActionFailed sampleResult)
finishMeasurement beforeActionStats start sampleValue = do
sampleDigest <-
snd
<$> evaluate
(force (timingReadiness sampleValue, digest sampleValue))
end <- getMonotonicTimeNSec
afterActionStats <- getRTSStats
afterPostGcStats <- majorGcStats
pure
( first RtsMeasurementDeltaFailed
( finalizeRtsMeasurement
(end - start)
(rtsSnapshotFromStats beforeActionStats)
(rtsSnapshotFromStats afterActionStats)
(rtsSnapshotFromStats afterPostGcStats)
sampleValue
sampleDigest
)
)
rtsSnapshotFromStats :: RTSStats -> RtsSnapshot
rtsSnapshotFromStats stats =
RtsSnapshot
{ rtsSnapshotGcs = gcs stats,
rtsSnapshotMajorGcs = major_gcs stats,
rtsSnapshotAllocatedBytes = allocated_bytes stats,
rtsSnapshotCopiedBytes = copied_bytes stats,
rtsSnapshotMutatorCpuNanoseconds = mutator_cpu_ns stats,
rtsSnapshotMutatorElapsedNanoseconds = mutator_elapsed_ns stats,
rtsSnapshotGcCpuNanoseconds = gc_cpu_ns stats,
rtsSnapshotGcElapsedNanoseconds = gc_elapsed_ns stats,
rtsSnapshotCpuNanoseconds = cpu_ns stats,
rtsSnapshotElapsedNanoseconds = elapsed_ns stats,
rtsSnapshotLiveBytes = gcdetails_live_bytes (gc stats),
rtsSnapshotMaxLiveBytes = max_live_bytes stats
}
majorGcStats :: IO RTSStats
majorGcStats =
performMajorGC *> getRTSStats