1305d6531b18307e9d1228c9a0f9505cefbd94de
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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.
|