Commit Graph

2 Commits

Author SHA1 Message Date
jgrusewski
db9fd1b2da feat(sp20): parallelise imbalance-bar OHLCV reduction via two-pass decomposition
Bottleneck B of two: replaces the sequential `ImbalanceBarSampler` walk
in `mbp10_to_imbalance_bars` with a two-pass parallel decomposition.
Bit-identical to sequential — verified by 6/6 passing tests in
`crates/ml-features/tests/imbalance_bars_parallel_bit_equiv_test.rs` at
both dense (T=20, ~23k bars) and sparse (T=500, ~300 bars) emission
regimes, exact 0.0 diff on every f64 field.

Why two-pass (NOT time-bucket sharding)
=======================================
Time-bucket sharding (the SP20 OFI pattern in
`compute_ofi_per_bar_parallel`) bit-equates to sequential because OFI
features derive from BOUNDED rolling windows (VPIN ≤50, Kyle ≤100,
trade-imb ≤100, OFI-stats ≤300). After ≥window-size warmup updates,
two rolling buffers started from different initial states converge
bit-identically.

`ImbalanceBarSampler` is fundamentally different: its `cumulative_imbalance`
is a path integral that resets only when crossing ±threshold. There is
NO bounded-lookback window. Two replays of the same trade tape starting
from different cum_imb offsets emit at DIFFERENT trade indices, and the
phase offset is NOT guaranteed to vanish at any future trade — even
after many emissions, a bounded phase difference can persist
indefinitely. An earlier time-bucket-sharded prototype with WARMUP=10000
trades passed K=4/K=8 at threshold=20 (dense emissions, ~30 emissions
per shard's warmup window) but FAILED at threshold=500 with a 1-bar
count drift (golden=303, parallel=304) — exactly the kind of phase-
offset divergence predicted by the path-integral analysis.

Two-pass decomposition
======================
Pass 1 (sequential, lightweight): walk the trade tape ONCE tracking only
cum_imb, prev_price, last_direction. Record the trade index of every
emission-triggering trade as the end of a segment. No OHLCV state, no
Vec<OHLCVBar> allocation, no per-trade conditional bar construction.
O(N) simple arithmetic — for 50k-2M trades it runs in 1-15ms.

Pass 2 (parallel rayon): each emission segment [boundary[i-1]+1,
boundary[i]] produces exactly one bar via independent OHLCV reduction
over its trade slice. Segments are fully independent; `par_iter()` over
segment ranges is trivially correct.

Bit-equivalence guarantee
=========================
Pass 1 mirrors `ImbalanceBarSampler::update` arithmetic verbatim:
- zero-volume early-return (alternative_bars.rs:484-486)
- direction tie-break (alternative_bars.rs:495-506)
- cum_imb update (alternative_bars.rs:508-510)
- emission threshold check (alternative_bars.rs:522-523)
- prev_price/last_direction kept across emissions (reset() at :558-566)
- NO trailing-partial-bar flush (matches sequential exactly)

Pass 2's per-segment OHLCV reduction matches the sampler's per-bar OHLCV
update verbatim: open = first non-zero-volume trade in segment, close =
last non-zero-volume trade, high/low = max/min over non-zero-volume
trades, volume = sum, timestamp = open's timestamp.

Performance (release-mode bench, 16-thread rayon pool)
======================================================
n=100k:   seq=1.39ms,  par=1.88ms,  speedup=0.74x  (rayon overhead dominates)
n=500k:   seq=8.04ms,  par=3.35ms,  speedup=2.40x
n=2M:     seq=27.59ms, par=12.39ms, speedup=2.23x

Speedup is bounded by Pass 1 (sequential, ~5-7ms at 2M trades) since
Pass 2 (parallel, ~2-3ms at 2M trades on 16 threads) is ~3x faster
than Pass 1's sequential floor. At production scale (50k-500k trades
per `mbp10_to_imbalance_bars` call) we get 2-2.4x speedup over the
all-sequential baseline.

The `min_bars_per_task` cutoff falls back to a sequential Pass 2 when
segment count < 256 (the rayon spawn overhead exceeds the parallel
benefit). Tunable via the second function arg.

Files changed
=============
- crates/ml-features/src/alternative_bars.rs (+1 -1):
    derive `Clone` on `ImbalanceBarSampler` for parallel-shard use
    (carried through this commit even though Pass 1's hand-rolled walk
    in `mbp10_loader.rs` doesn't need it — keeps the type clonable for
    future test/bench infrastructure).
- crates/ml-features/src/lib.rs (+3 -2):
    re-export `imbalance_bars_parallel`, `imbalance_bars_sequential`,
    `DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK`.
- crates/ml-features/src/mbp10_loader.rs (+253 -15):
    new `imbalance_bars_parallel`, `imbalance_bars_sequential`,
    `DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK` const. Replace the inline
    sampler-walk loop in `mbp10_to_imbalance_bars` with a call into
    `imbalance_bars_parallel`. Module note explains why time-bucket
    sharding doesn't apply.
- crates/ml-features/tests/imbalance_bars_parallel_bit_equiv_test.rs
    (+272 NEW): hermetic synthetic-trade bit-equivalence tests at:
        * default cutoff (par_iter Pass 2)
        * forced par_iter (min_bars_per_task=1)
        * forced sequential Pass 2 (min_bars_per_task=usize::MAX)
        * empty input
        * small input (Pass 2 below par cutoff)
        * high-threshold sparse emissions (the case that broke the
          time-bucket sharding prototype). All pass exact 0.0 diff.

Also note: 293/293 ml-features lib tests pass (no regression).

Pairs with the file-level par_iter trade extraction in
8f5c64e10 (Bottleneck A). Together they parallelise both halves of
`mbp10_to_imbalance_bars`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:53:51 +02:00
jgrusewski
032026dc07 feat(sp20): parallelise per-bar OFI extraction via time-bucket sharding
Replaces the sequential per-bar OFI loop in
`crates/ml/examples/precompute_features.rs:578-734` with a call into a
new `ml-features` library function that shards bars across CPU cores
with warmup overlap. On `ci-compile-cpu` (28 cores), large fxcache
runs (~50-200k bars) get a meaningful speedup; small datasets (<10k
bars) may run slightly slower due to amortisation cost — see test
output below.

Strategy: time-bucket sharding with warmup overlap

- Partition core bars [0, n) into K contiguous shards (K = rayon
  current threads, or `OFI_SHARD_COUNT` env override).
- For each shard [start_k, end_k):
    1. Construct a fresh `OFICalculator`.
    2. Replay the `WARMUP_BARS = 5000` preceding bars (or fewer if
       start_k < 5000) by feeding trades + calling
       `calculate(last_snap)` and DISCARDING outputs. This populates
       VPIN (50 buckets × 50k contracts), Kyle (100 trades),
       trade-imbalance (100 trades), OFI-stats (300 snapshots), and
       prev_snapshot to bit-identical sequential-walk state.
    3. Process core bars and emit ofi_per_bar rows.
- Concatenate shard outputs in order, then run post-loop passes
  (lag-1 deltas, log_bar_duration) over the full Vec — both are pure
  functions of the concatenated output / bar timestamps and have no
  shard interaction.

Bit-equivalence guarantee
=========================
Each shard's warmup phase replays the same prefix of trades+snapshots
into a freshly initialised local `OFICalculator`. Once the warmup has
covered enough bars to fully populate every rolling window AND any
post-warmup-window state has been carried forward, the shard's
calculator state at the warmup→core boundary is bit-identical to the
sequential walk. `MicrostructureState` is constructed fresh per-bar
(no cross-bar state) so its semantics are unchanged.

Verified by `crates/ml-features/tests/ofi_parallel_bit_equiv_test.rs`:

| test                                            | result | max abs diff |
|-------------------------------------------------|--------|--------------|
| parallel_matches_sequential_within_1e9_k4       | pass   | ≤ 1e-9       |
| parallel_matches_sequential_within_1e9_k8       | pass   | ≤ 1e-9       |
| parallel_k1_is_sequential                       | pass   | exactly 0    |
| parallel_handles_no_trades                      | pass   | ≤ 1e-9       |
| parallel_speedup_smoke (ignored, bench-shaped)  | n/a    | n/a          |

Speedup smoke (release, K=8, synthetic data):

  n=8000 bars : seq=16.36ms par=18.98ms speedup=0.86x
  n=30000 bars: seq=50.89ms par=29.56ms speedup=1.72x
  n=80000 bars: seq=142.12ms par=55.13ms speedup=2.58x

Speedup grows with N because the 5000-bar warmup overhead
amortises. At production scale (50-200k bars × ~3 snap/bar × ~5
trades/bar) real workloads will see closer to K-1.x speedup. Small
datasets (<10k) regress slightly — by design; warmup must be ≥
rolling-window depth for bit-equivalence and we will not relax that
for marginal wall-clock gains on tiny inputs.

Diff
====

- `crates/ml-features/src/ofi_calculator.rs` (+356 LOC):
    `compute_ofi_per_bar_sequential` (reference impl),
    `compute_ofi_per_bar_parallel` (rayon par_iter over shard ranges),
    `process_one_bar` (shared per-bar body — single source of truth
    for the loop semantics, no duplication between paths),
    `apply_post_loop_passes`, `PerBarOfiInputs` struct,
    `PER_BAR_OFI_DIM=32` const, `DEFAULT_OFI_PARALLEL_WARMUP_BARS=5000`
    const.
- `crates/ml-features/src/lib.rs` (+4 LOC): re-export new public API.
- `crates/ml/examples/precompute_features.rs` (-132 +35 LOC): replace
    the inline 132-line OFI loop with a call to
    `compute_ofi_per_bar_parallel`. Reads `OFI_SHARD_COUNT` env or
    falls back to `rayon::current_num_threads()`.
- `crates/ml-features/tests/ofi_parallel_bit_equiv_test.rs` (+~280 LOC,
    new): synthetic-data bit-equivalence tests at K=4, K=8, K=1, and
    no-trades.

Memory budget per shard: one cloned `OFICalculator` (≤10 MB carrying
the rolling-window state). K=8 ≈ 80 MB extra. Manageable on the 28-core
CPU node.

No `mbp10_to_imbalance_bars` / `filter_front_month_mbp10` changes
(out of scope; those were just fixed and a separate smoke is running).
No audit-doc update required: changes are confined to ml-features +
ml/examples and do not touch crates/ml/src/(cuda_pipeline|trainers/dqn)/
which is the trigger scope for the Invariant-7 audit-doc check.
2026-05-10 12:17:40 +02:00