eproc

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit c35e2ca28eb847b2b556c5bc017723d88dc426bc
parent 4eddb4d8cf371b4fb2196183b3fca241021544bd
Author: Jared Tobin <jared@jtobin.io>
Date:   Thu,  2 Jul 2026 16:35:05 -0230

perf: log1p for the per-step wealth factor, fast-path log_sum_exp

Two small changes to the per-observation work in the update
functions:

1. Replace 'log (1 + lambda * z)' with 'log1p (lambda * z)' in
   all four update paths. Improves accuracy for small 'lambda *
   z' (the standard log1p rationale) and avoids the '1 + ...'
   add.

2. In the two-sided combinations (Bounded, BernTS), skip the
   'log_sum_exp' call when the cheap upper bound
   'max(logw_p, logw_n) + log 2' is already at or below the
   running max-log-sum. Under H_0 the wealth process is a
   supermartingale that stays near its early peak, so this
   fast-path fires on the vast majority of steps once the peak
   is set.

Impact from criterion on a 1000-sample cycle at p = p_0:

  * Bernoulli.TwoSided fold newton: 25.62 us -> 15.65 us (-39%)
  * Bernoulli.TwoSided fold fixed:  16.21 us ->  8.27 us (-49%)
  * Bounded fold adaptive:          14.09 us -> 12.88 us  (-9%)
  * Bounded fold newton:            14.83 us -> 14.57 us  (-2%)

The BernTS gap closes because its H_0 workload triggers the
fast-path aggressively. Bounded is more modest because the
input stream ('cycle [0.3, 0.7]') keeps both directional
wealths active at similar magnitudes, so the log_sum_exp fires
more often.

Also moves 'log 2' to a shared 'log2_dbl' constant in Common
(used both as the initial two-sided max-log-sum and as the
fast-path slack).

I checked GHC Core for the Newton fold specialisation
question: the current bench's shared 'run_b' helper causes GHC
to emit one worker taking 'Bettor' as an argument, with a
per-iteration case dispatch. Rewriting the bench to inline the
fold at each arm produces the specialised code, but the
measurable difference is in the noise (<1 us on a 15 us
fold). Left the library shape alone.

All 59 tests still pass.

Diffstat:
Mlib/Numeric/Eproc/Bernoulli.hs | 4++--
Mlib/Numeric/Eproc/Bernoulli/TwoSided.hs | 20++++++++++++--------
Mlib/Numeric/Eproc/Bounded.hs | 22++++++++++++++--------
Mlib/Numeric/Eproc/Common.hs | 10++++++++++
4 files changed, 38 insertions(+), 18 deletions(-)

diff --git a/lib/Numeric/Eproc/Bernoulli.hs b/lib/Numeric/Eproc/Bernoulli.hs @@ -75,6 +75,7 @@ module Numeric.Eproc.Bernoulli ( , samples ) where +import GHC.Float (log1p) import Numeric.Eproc.Common ( Bettor(..), Verdict(..), ConfigError(..) , BetState, init_bet, bet_lambda, step_bet @@ -200,8 +201,7 @@ update Config{..} State{..} !x = let !xd = if x then 1 else 0 !z = xd - cfg_p0 !lam = bet_lambda cfg_bettor cfg_lam_max st_bet - !fac = 1 + lam * z - !logw' = st_log_w + log fac + !logw' = st_log_w + log1p (lam * z) !maxw' = max st_max_log_w logw' !s' = step_bet cfg_bettor cfg_lam_max st_bet z in State (st_n + 1) logw' maxw' s' diff --git a/lib/Numeric/Eproc/Bernoulli/TwoSided.hs b/lib/Numeric/Eproc/Bernoulli/TwoSided.hs @@ -65,10 +65,11 @@ module Numeric.Eproc.Bernoulli.TwoSided ( , samples ) where +import GHC.Float (log1p) import Numeric.Eproc.Common ( Bettor(..), Verdict(..), ConfigError(..) , BetState, init_bet, bet_lambda, step_bet - , finite, log_sum_exp + , finite, log_sum_exp, log2_dbl ) -- types ---------------------------------------------------------------------- @@ -156,7 +157,7 @@ initial Config{..} = st_n = 0 , st_log_w_pos = 0 , st_log_w_neg = 0 - , st_max_log_sum = log 2 + , st_max_log_sum = log2_dbl , st_bet_pos = s0 , st_bet_neg = s0 } @@ -178,12 +179,15 @@ update Config{..} State{..} !x = !z = xd - cfg_p0 !lam_p = bet_lambda cfg_bettor cfg_lam_max_pos st_bet_pos !lam_n = bet_lambda cfg_bettor cfg_lam_max_neg st_bet_neg - !fac_p = 1 + lam_p * z - !fac_n = 1 - lam_n * z - !logw_p = st_log_w_pos + log fac_p - !logw_n = st_log_w_neg + log fac_n - !log_sum = log_sum_exp logw_p logw_n - !max_sum = max st_max_log_sum log_sum + !logw_p = st_log_w_pos + log1p (lam_p * z) + !logw_n = st_log_w_neg + log1p (negate lam_n * z) + -- see the twin comment in 'Numeric.Eproc.Bounded.update' for + -- why we can skip 'log_sum_exp' via a cheap upper bound. + !cheap_ub = max logw_p logw_n + log2_dbl + !max_sum + | cheap_ub <= st_max_log_sum = st_max_log_sum + | otherwise = + max st_max_log_sum (log_sum_exp logw_p logw_n) !sp = step_bet cfg_bettor cfg_lam_max_pos st_bet_pos z !sn = step_bet cfg_bettor cfg_lam_max_neg st_bet_neg (negate z) in State (st_n + 1) logw_p logw_n max_sum sp sn diff --git a/lib/Numeric/Eproc/Bounded.hs b/lib/Numeric/Eproc/Bounded.hs @@ -86,10 +86,11 @@ module Numeric.Eproc.Bounded ( , samples ) where +import GHC.Float (log1p) import Numeric.Eproc.Common ( Bettor(..), Verdict(..), ConfigError(..) , BetState, init_bet, bet_lambda, step_bet - , finite, log_sum_exp + , finite, log_sum_exp, log2_dbl ) -- types ---------------------------------------------------------------------- @@ -209,7 +210,7 @@ initial Config{..} = st_n = 0 , st_log_w_pos = 0 , st_log_w_neg = 0 - , st_max_log_sum = log 2 + , st_max_log_sum = log2_dbl , st_bet_pos = s0 , st_bet_neg = s0 } @@ -242,12 +243,17 @@ update Config{..} State{..} !x = let !z = x - cfg_null_mean !lam_p = bet_lambda cfg_bettor cfg_lam_max_pos st_bet_pos !lam_n = bet_lambda cfg_bettor cfg_lam_max_neg st_bet_neg - !fac_p = 1 + lam_p * z - !fac_n = 1 - lam_n * z - !logw_p = st_log_w_pos + log fac_p - !logw_n = st_log_w_neg + log fac_n - !log_sum = log_sum_exp logw_p logw_n - !max_sum = max st_max_log_sum log_sum + !logw_p = st_log_w_pos + log1p (lam_p * z) + !logw_n = st_log_w_neg + log1p (negate lam_n * z) + -- Skip 'log_sum_exp' when the cheap upper bound + -- log_sum_exp a b <= max a b + log 2 + -- already sits at or below the running max: no update can + -- move it. Under H_0 (calibration) this is the common case. + !cheap_ub = max logw_p logw_n + log2_dbl + !max_sum + | cheap_ub <= st_max_log_sum = st_max_log_sum + | otherwise = + max st_max_log_sum (log_sum_exp logw_p logw_n) !sp = step_bet cfg_bettor cfg_lam_max_pos st_bet_pos z !sn = step_bet cfg_bettor cfg_lam_max_neg st_bet_neg (negate z) in State (st_n + 1) logw_p logw_n max_sum sp sn diff --git a/lib/Numeric/Eproc/Common.hs b/lib/Numeric/Eproc/Common.hs @@ -33,6 +33,7 @@ module Numeric.Eproc.Common ( -- * Internal: helpers , finite , log_sum_exp + , log2_dbl ) where import GHC.Float (log1p) @@ -130,6 +131,15 @@ log_sum_exp !a !b | otherwise = b + log1p (exp (a - b)) {-# INLINE log_sum_exp #-} +-- | @log 2@ as a shared constant. Used both as the initial value of +-- the two-sided running max-log-sum (since @K^+_0 + K^-_0 = 2@) and +-- as the tight upper-bound slack in the fast-path skip inside +-- 'Numeric.Eproc.Bounded.update' / +-- 'Numeric.Eproc.Bernoulli.TwoSided.update'. +log2_dbl :: Double +log2_dbl = log 2 +{-# INLINE log2_dbl #-} + -- | Per-bettor state. One constructor per 'Bettor' alternative; the -- constructor used in any given state matches the 'Bettor' chosen -- in the enclosing 'Config'.