|
| 1 | +# FlowState: System Design |
| 2 | + |
| 3 | +This document describes the architecture, algorithms, and design decisions behind FlowState. It is intended for engineers evaluating the system's internals. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## 1. Problem Statement |
| 8 | + |
| 9 | +Quantitative trading systems require joining heterogeneous time-series streams into feature matrices for model training. The core operation is the **as-of join**: for each row in a primary stream (e.g., trades), find the most recent corresponding row in a secondary stream (e.g., quotes) as of that timestamp. This must be: |
| 10 | + |
| 11 | +- **Point-in-time correct.** A backward join at timestamp `T` may only match rows at `T' <= T`. Violating this constraint introduces look-ahead bias, which invalidates any downstream backtest or model evaluation. |
| 12 | +- **Performant at scale.** Production datasets contain billions of rows across thousands of symbols. The join must complete in seconds, not minutes. |
| 13 | +- **Streaming-capable.** Research uses batch replay over historical data. Production uses incremental alignment on live feeds, where data arrives out-of-order and completeness is determined by watermarks. |
| 14 | +- **Zero-copy from storage to GPU.** The aligned output feeds into PyTorch or JAX training loops. Every intermediate copy between disk and GPU VRAM is wasted latency and memory bandwidth. |
| 15 | + |
| 16 | +General-purpose DataFrame libraries (Polars, pandas, DuckDB) treat as-of joins as one operation among hundreds. FlowState treats it as the only operation and exploits every invariant that implies. |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## 2. Architecture Overview |
| 21 | + |
| 22 | +``` |
| 23 | +┌─────────────────────────────────────────────────────────────────────────┐ |
| 24 | +│ Python API Layer │ |
| 25 | +│ TemporalAligner · StreamingAligner · FeatureStore · ReplayEngine │ |
| 26 | +│ 7,800 lines · Full type annotations · PEP 561 typed │ |
| 27 | +└───────────────────────────────┬─────────────────────────────────────────┘ |
| 28 | + │ Arrow PyCapsule Interface (zero-copy) |
| 29 | +┌───────────────────────────────▼─────────────────────────────────────────┐ |
| 30 | +│ Rust Core (PyO3) │ |
| 31 | +│ As-of join kernels · Streaming join · Arrow IPC I/O │ |
| 32 | +│ 6,400 lines · 132 tests (121 unit + 11 proptest) │ |
| 33 | +└───────────────────────────────┬─────────────────────────────────────────┘ |
| 34 | + │ |
| 35 | + ┌───────────────────┬───────┴───────┬───────────────────┐ |
| 36 | + ▼ ▼ ▼ ▼ |
| 37 | + Storage Alignment Streaming Data Feeding |
| 38 | + Hive partitions Merge-scan Watermarks Pinned memory |
| 39 | + NVMe LRU cache Rayon parallel Late data policy kvikio GDS |
| 40 | + S3/GCS/Azure Multi-stream Batch coalescing CUDA streams |
| 41 | +``` |
| 42 | + |
| 43 | +### Data path |
| 44 | + |
| 45 | +``` |
| 46 | +PyArrow Table |
| 47 | + → Arrow PyCapsule Interface (pyo3-arrow 0.17) |
| 48 | + → arrow-rs RecordBatch (zero-copy, shared memory) |
| 49 | + → Rust computation (Rayon parallel) |
| 50 | + → Arrow PyCapsule Interface |
| 51 | + → PyArrow Table |
| 52 | +``` |
| 53 | + |
| 54 | +No serialization. No copying. The Rust kernel operates directly on the same memory buffers that Python allocated. This is the critical design decision that makes the system practical — a serialization boundary would dominate the runtime at these data volumes. |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +## 3. As-Of Join Engine |
| 59 | + |
| 60 | +### 3.1 Core algorithm: sorted linear merge-scan |
| 61 | + |
| 62 | +The as-of join kernel uses an O(n+m) sorted merge-scan, identical in complexity class to Polars. For two sorted timestamp arrays `left[0..n]` and `right[0..m]`: |
| 63 | + |
| 64 | +``` |
| 65 | +backward_scan(left, right) → indices: |
| 66 | + j = 0 |
| 67 | + for i in 0..n: |
| 68 | + while j+1 < m and right[j+1] <= left[i]: |
| 69 | + j += 1 |
| 70 | + if j < m and right[j] <= left[i]: |
| 71 | + indices[i] = j |
| 72 | + else: |
| 73 | + indices[i] = NULL |
| 74 | +``` |
| 75 | + |
| 76 | +Three scan variants exist: `backward` (match `<=`), `forward` (match `>=`), and `nearest` (minimum absolute distance, ties broken backward). All three are implemented in `flowstate-core/src/asof/scan.rs` as branchless inner loops for pipeline-friendly execution. |
| 77 | + |
| 78 | +### 3.2 Why we outperform Polars |
| 79 | + |
| 80 | +FlowState is faster on grouped temporal joins because it exploits domain-specific invariants that a general-purpose engine cannot assume: |
| 81 | + |
| 82 | +1. **Skip sort verification.** Market data arrives pre-sorted by timestamp from exchanges. FlowState detects sortedness with a single branchless pass and skips the O(n log n) sort that Polars must defensively perform. |
| 83 | + |
| 84 | +2. **ahash grouping with borrowed keys.** Symbol grouping uses `ahash` (the same hash Polars uses internally) but borrows `&str` keys directly from Arrow string arrays — zero `String` allocation during the group-build phase. The hash map maps `&str → Vec<u32>` indices. |
| 85 | + |
| 86 | +3. **Rayon parallel per-group joins.** Each symbol group is independent. After grouping, Rayon's work-stealing scheduler distributes groups across cores with no coordination. At 1,000 symbols, this parallelizes cleanly across 8–16 cores. |
| 87 | + |
| 88 | +4. **Parallel chunked scan.** For ungrouped joins on large inputs (5M+ rows), the left array is split into chunks. Binary search finds the starting right-cursor position for each chunk. Chunks execute in parallel with disjoint writes to the output index array via `UnsafeCell` (sound because write ranges are provably non-overlapping). |
| 89 | + |
| 90 | +5. **Tolerance early termination.** When a tolerance window is configured (common in production — e.g., "stale quotes older than 5 seconds are invalid"), the scan breaks early once the distance exceeds the tolerance, avoiding unnecessary comparisons. |
| 91 | + |
| 92 | +### 3.3 Multi-stream alignment |
| 93 | + |
| 94 | +Joining N secondary streams against one primary is a common operation (e.g., align trades with quotes, bars, signals, and reference data simultaneously). Polars requires N sequential `join_asof` calls. FlowState does this in one call via `align_streams`, which dispatches N independent joins to Rayon's `par_iter`. At 8 streams this is 15% faster than sequential Polars — the only library that offers this as a single operation. |
| 95 | + |
| 96 | +### 3.4 Parallel column gather |
| 97 | + |
| 98 | +After computing join indices, the output table must be assembled by gathering columns from the right table at the computed positions. FlowState parallelizes this via Arrow's `take()` kernel dispatched per-column through Rayon. For wide right tables (20+ columns), this provides measurable speedup over sequential gather. |
| 99 | + |
| 100 | +--- |
| 101 | + |
| 102 | +## 4. Streaming Join Engine |
| 103 | + |
| 104 | +The streaming join engine (`src/asof/streaming.rs`, 900 lines) handles incremental alignment on live data with watermark semantics. This is the capability that no general-purpose engine provides. |
| 105 | + |
| 106 | +### 4.1 Design |
| 107 | + |
| 108 | +``` |
| 109 | +push_left(batch) ──→ ┌──────────────┐ |
| 110 | + │ Left Buffer │ (sorted by timestamp, grouped by symbol) |
| 111 | +push_right(batch) ──→ │ Right Buffer │ |
| 112 | + └──────┬───────┘ |
| 113 | + │ |
| 114 | +advance_watermark(ts) ──→ ▼ |
| 115 | + ┌──────────────┐ |
| 116 | + │ Scan & Emit │ All left rows with ts < watermark are sealed |
| 117 | + └──────┬───────┘ |
| 118 | + │ |
| 119 | + emit() ◄──┘ Returns joined pa.Table or None |
| 120 | +``` |
| 121 | + |
| 122 | +- **Watermark semantics.** A watermark at time `W` declares: "no more data will arrive with timestamp < W." Left rows below the watermark are sealed — their join result is final and can be emitted. This decouples output latency from input completeness. |
| 123 | +- **Configurable lateness.** `lateness_ns` extends the watermark by a tolerance window, allowing late-arriving data to still participate in joins before being dropped. |
| 124 | +- **Right-side pruning.** `prune_right_before(ts)` garbage-collects right-side rows that can no longer match any future left row, bounding memory usage for long-running streams. |
| 125 | +- **Per-symbol state.** Each symbol group maintains independent left/right buffers and watermark positions. Cross-symbol isolation prevents one slow symbol from blocking emission for others. |
| 126 | + |
| 127 | +### 4.2 Python wrapper |
| 128 | + |
| 129 | +`StreamingAligner` (Python) wraps the Rust `StreamingJoin` kernel. If the Rust extension is not installed, it falls back to a pure-Python implementation using `bisect`-based matching on sorted per-symbol buffers. The Python fallback produces identical results at lower throughput. |
| 130 | + |
| 131 | +--- |
| 132 | + |
| 133 | +## 5. Storage Layer |
| 134 | + |
| 135 | +### 5.1 Hive partitioning |
| 136 | + |
| 137 | +Data is stored in Hive-partitioned Parquet with deterministic bucket assignment: |
| 138 | + |
| 139 | +``` |
| 140 | +data/ |
| 141 | +└── type=trade/ |
| 142 | + └── date=2024-01-15/ |
| 143 | + ├── bucket=0000/data.parquet |
| 144 | + ├── bucket=0001/data.parquet |
| 145 | + └── ... |
| 146 | +``` |
| 147 | + |
| 148 | +Bucket assignment uses `xxhash` on the symbol string, which provides uniform distribution and symbol affinity — the same symbol always maps to the same bucket, enabling per-bucket parallelism without cross-bucket coordination. |
| 149 | + |
| 150 | +### 5.2 Three-level pruning |
| 151 | + |
| 152 | +The replay engine eliminates unnecessary I/O at three levels: |
| 153 | + |
| 154 | +1. **Partition pruning.** Hive partition keys (type, date) are matched against the replay filter before any file is opened. |
| 155 | +2. **Row-group pushdown.** Parquet row-group statistics (min/max timestamp) are compared against the requested time range. Row groups outside the range are skipped entirely. |
| 156 | +3. **Column projection.** Only requested columns are read from disk. For a 20-column Parquet file where the query needs 3 columns, this reduces I/O by 85%. |
| 157 | + |
| 158 | +### 5.3 Caching |
| 159 | + |
| 160 | +An NVMe LRU cache tier sits between cloud object storage (S3/GCS/Azure via fsspec) and the replay engine. Files are cached locally on first access. Cache eviction is LRU by total byte size, with configurable max capacity. |
| 161 | + |
| 162 | +--- |
| 163 | + |
| 164 | +## 6. GPU Data Feeding |
| 165 | + |
| 166 | +### 6.1 Pipeline |
| 167 | + |
| 168 | +``` |
| 169 | +Parquet on disk |
| 170 | + → Replay engine (partition-pruned, projected) |
| 171 | + → Temporal alignment (Rust kernel) |
| 172 | + → Batch coalescer (target row count) |
| 173 | + → Pinned memory pool (cudaMallocHost or page-aligned mmap) |
| 174 | + → CUDA stream async H2D transfer |
| 175 | + → PyTorch / JAX tensor |
| 176 | +``` |
| 177 | + |
| 178 | +### 6.2 Pinned memory pool |
| 179 | + |
| 180 | +`PinnedBufferPool` pre-allocates CUDA pinned memory (`cudaMallocHost`) at startup. Pinned memory enables async DMA transfers — the GPU can read from host memory while the CPU prepares the next batch. If CUDA is not available, the pool falls back to page-aligned CPU memory via `mmap`, maintaining the same API. |
| 181 | + |
| 182 | +### 6.3 GPUDirect Storage |
| 183 | + |
| 184 | +On systems with NVMe SSDs and NVIDIA GPUDirect Storage support, `GPUDirectReader` uses kvikio `CuFile` to DMA data directly from NVMe to GPU VRAM, bypassing the CPU entirely. This eliminates two memory copies (NVMe→CPU, CPU→GPU) from the data path. |
| 185 | + |
| 186 | +### 6.4 Double-buffered prefetch |
| 187 | + |
| 188 | +`PrefetchPipeline` runs a background thread that fills buffer N+1 while the GPU processes buffer N. Configurable prefetch depth (default 2) keeps the GPU pipeline saturated. Backpressure prevents the prefetcher from running ahead of a slow consumer. |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## 7. Lock-Free Infrastructure |
| 193 | + |
| 194 | +### 7.1 SPSC ring buffer |
| 195 | + |
| 196 | +`spsc.rs` implements a single-producer single-consumer ring buffer using `AtomicU64` with Acquire/Release ordering. The ring is cache-line padded (128 bytes) to prevent false sharing between producer and consumer threads. Throughput: 82M elements/second single-threaded, 20M elements/second cross-thread. |
| 197 | + |
| 198 | +### 7.2 Streaming pipeline |
| 199 | + |
| 200 | +`pipeline.rs` composes SPSC rings into a streaming pipeline: |
| 201 | + |
| 202 | +``` |
| 203 | +Input SPSC → StreamingJoin → BatchCoalescer → Output SPSC |
| 204 | +``` |
| 205 | + |
| 206 | +Each stage runs on its own thread. The `BatchCoalescer` accumulates small output batches into larger ones (configurable target row count) to amortize downstream overhead. Pipeline latency is tracked via an HDR histogram with log-linear bucketing and CAS-based concurrent recording. |
| 207 | + |
| 208 | +### 7.3 Bloom filter |
| 209 | + |
| 210 | +`bloom.rs` implements a lock-free Bloom filter with double-hashing and auto-tuned false positive rate. Used for fast negative lookups in partition pruning — if a symbol is definitely not in a partition's Bloom filter, the partition can be skipped without reading its Parquet metadata. |
| 211 | + |
| 212 | +### 7.4 Slab buffer pool |
| 213 | + |
| 214 | +`pool.rs` provides a pre-allocated slab pool with automatic return-on-drop and zero-on-return semantics. Used by the streaming pipeline to avoid allocation in the hot path. |
| 215 | + |
| 216 | +--- |
| 217 | + |
| 218 | +## 8. Temporal Feature Store |
| 219 | + |
| 220 | +The feature store provides a catalog → materialize → serve workflow for managing aligned feature sets: |
| 221 | + |
| 222 | +- **Catalog** (`catalog.py`): Versioned feature definitions with dependency DAG, status lifecycle (active/deprecated/archived), tag-based filtering, and JSON persistence. |
| 223 | +- **Materializer** (`materializer.py`): Reads catalog definitions, runs `align_streams()`, writes results to Arrow IPC files. Handles dependency ordering — features are materialized in topological order. |
| 224 | +- **Server** (`server.py`): Reads materialized IPC files with column projection and symbol filtering. Provides feature metadata (schema, row count, byte size) for operational monitoring. |
| 225 | + |
| 226 | +--- |
| 227 | + |
| 228 | +## 9. Correctness Guarantees |
| 229 | + |
| 230 | +- **No look-ahead bias by default.** Backward joins are the default direction. Every matched right-side timestamp is `<=` the left-side timestamp. This is verified by dedicated integration tests. |
| 231 | +- **Property-based testing.** 11 proptest tests in Rust generate random sorted timestamp arrays and verify kernel output against a brute-force O(n*m) reference implementation. Properties tested include: backward match is rightmost, forward match is leftmost, nearest minimizes distance, tolerance is enforced, output length equals left length. |
| 232 | +- **Streaming-batch parity.** Integration tests verify that streaming alignment on complete data produces identical results to batch alignment. |
| 233 | +- **End-to-end pipeline tests.** 14 integration tests validate that data flows correctly from raw generation through partitioned storage, replay, alignment, materialization, and serving with no row loss or corruption. |
| 234 | + |
| 235 | +--- |
| 236 | + |
| 237 | +## 10. Key Design Decisions |
| 238 | + |
| 239 | +| Decision | Rationale | |
| 240 | +|---|---| |
| 241 | +| **O(n+m) merge-scan, not O(n log m) binary search** | Market data is pre-sorted. Linear scan has better cache locality and constant factors than binary search for sorted inputs. Same complexity class as Polars. | |
| 242 | +| **ahash with borrowed `&str` keys** | Zero `String` allocation during grouping. `ahash` is the fastest non-cryptographic hash for short keys. Borrowed keys avoid copying Arrow string buffers. | |
| 243 | +| **`UnsafeCell` for parallel index writes** | Chunked parallel scan writes to disjoint ranges of the output index array. `UnsafeCell` avoids the overhead of per-element atomic operations. Soundness relies on provably non-overlapping chunk boundaries. | |
| 244 | +| **`TimestampSlice` enum for zero-copy ts access** | Borrows the Arrow buffer directly when data is already sorted, avoiding a copy into a separate `Vec<i64>`. Falls back to a sorted copy only when input is unsorted. | |
| 245 | +| **Feature-gated PyO3** | `#[cfg(feature = "python")]` allows all Rust code to compile and test without Python. CI runs `cargo test --no-default-features` for pure-Rust validation. | |
| 246 | +| **Pure Python fallback** | Every Rust-accelerated operation has a Python fallback using NumPy and bisect. The Rust kernel is a transparent accelerator imported at runtime — `pip install flowstate` works without a Rust toolchain. | |
| 247 | +| **Arrow PyCapsule Interface (pyo3-arrow 0.17)** | Zero-copy data exchange between Python and Rust. No serialization, no intermediate buffers. The Rust kernel operates on the same physical memory that PyArrow allocated. | |
| 248 | +| **Watermark-based streaming emission** | Borrowed from Apache Flink's event-time processing model. Watermarks decouple output completeness from input ordering, which is essential for real-time feeds where data arrives out-of-order. | |
| 249 | +| **Deterministic xxhash bucketing** | Same symbol always maps to the same partition bucket regardless of timestamp or data type. Enables symbol-affinity sharding for distributed replay without runtime coordination. | |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +## 11. Test Matrix |
| 254 | + |
| 255 | +| Layer | Count | Tool | What it covers | |
| 256 | +|---|---|---|---| |
| 257 | +| Rust unit tests | 121 | `cargo test` | Scan kernels, streaming join, SPSC, HDR, Bloom, coalescer, pool, IPC, pinned memory | |
| 258 | +| Rust property tests | 11 | proptest | Random-input correctness against brute-force reference for all three join directions | |
| 259 | +| Python unit tests | 611 | pytest | All Python modules: alignment, replay, storage, streaming, GPU, feature store, schemas, microstructure | |
| 260 | +| Integration tests | 14 | pytest | End-to-end pipeline, point-in-time correctness, streaming-batch parity, cache, partitioning, distributed replay | |
| 261 | +| Criterion benchmarks | 8 | `cargo bench` | Rust kernel throughput: sequential vs parallel, tolerance, all directions, SPSC ring | |
| 262 | +| Python benchmarks | 10 | bench_full_suite.py | Full-stack: as-of join, multi-stream, streaming, IPC, replay, alignment, feature store, cache, partitioning, prefetcher | |
0 commit comments