# Performance — chart-realtime Measured numbers for `0.5.0-SNAPSHOT` on `iosSimulatorArm64` and Android. Benchmark module (vs Vico / KoalaPlot / MPAndroidChart) lands in `1.0.0`. ## Test surface - **207** tests on `iosSimulatorArm64Test`, all green - ABI baseline locked via Binary Compatibility Validator - `chart-realtime.api`: 428 LOC - `chart-realtime.klib.api`: 501 LOC ## LoD algorithm performance Measured on `iosSimulatorArm64` (Apple Silicon iOS simulator). Input: 100_000 samples, output: 512 points. Median of 5 runs. | Algorithm | Time | vs pure LTTB | |---|---:|---:| | `LttbLodStrategy` (pure LTTB) | 5071 µs | 1.00× | | `MinMaxLodStrategy` | 2400 µs (approx) | 2.1× | | `MinMaxLttbLodStrategy` | **2814 µs** | **1.80×** | Paper claim ([arXiv 2305.00332](https://arxiv.org/abs/2305.00332)) is 10× under Rust + SIMD. Gap to reference attributed to Kotlin/Native scalar code generation (no SIMD primitives exposed to Kotlin; LLVM autovectorization less aggressive than Rust's). Algorithm-level win matches paper; implementation-level gap stays open. ### Visual fidelity (MinMaxLTTB vs MinMax baseline) Input: 100_000 samples of `sin(t / 100) + noise(0.1)`, output: 512 points. Min/max envelope drift: **< 5%** of pure-MinMax range. Single tall spike preserved across all three strategies (preselection step guarantees boundary preservation). ## Buffer / snapshot performance `TieredBuffer.snapshotWindow` (T1, v0.5.0) uses bisect instead of linear scan. | Operation | Tier0 (5min @ 200Hz) | Tier1 (10min @ 10Hz) | Tier2 (45min @ 1Hz) | |---|---:|---:|---:| | `snapshot()` full | 60_000 copies | 24_000 copies | 10_800 copies | | `snapshotWindow(10s)` linear | 60_000 scans, 2_000 writes | 24_000 scans, 100 writes | 10_800 scans, 10 writes | | `snapshotWindow(10s)` bisect (T1) | 2_000 writes | 100 writes | 10 writes | **30× fewer per-frame copies** for the common case (10s window inside a 5min Tier0 ring). `CircularBuffer.bisectStart` is O(log n), zero-alloc, overflow-safe midpoint (`ushr 1`). ## Render hot path — allocation budget Steady state, 4 signals, 200Hz push, 30fps render. Per-frame allocations: | Site | Pre-v0.4.0 | Post-v0.4.0 | Post-v0.5.0 | |---|---:|---:|---:| | `Pair` from `resolveYRange` | 1 | 1 | **0** (FloatArray out-param) | | `Map.entries` iterator | 3 | 3 | **0** (cached `signalsArray`) | | `Map.Entry` boxing | 12 | 12 | **0** | | `Stroke(width)` per signal | 4 | 4 | **0** (cached in `LineSignalRenderer`) | | `TextStyle` per tick | ~12 | ~12 | **0** (cached, 16-entry cap) | | `TextLayoutResult` per tick | ~12 | ~12 | depends on `TextMeasurer` LRU | | `buffer.snapshot` Long+Float arrays | 4×2 (8) | 4×1 (4) | 4×1 (4)¹ | ¹ `buffer.snapshot` writes into pre-allocated `SignalEntry.scratchTs` / `scratchV` (T11). No allocation; only the per-signal scratch (sized at `addSignal` time) is touched. Total per-frame steady-state alloc: < 1 KB / s (driven by Compose-internal `drawText` / `TextMeasurer` machinery; everything inside `chart-realtime` is 0). ## Memory budget Per signal, full 1h capacity: ``` TIER0_CAPACITY = 5min × 200Hz × 1 sample = 60_000 records TIER1_CAPACITY = 10min × 10Hz × 4 M4 records = 24_000 records TIER2_CAPACITY = 45min × 1Hz × 4 M4 records = 10_800 records TOTAL_CAPACITY = 94_800 records per signal ``` Per record: 8 bytes (`Long` ts) + 4 bytes (`Float` value) = 12 bytes. ``` Buffer memory: 94_800 × 12 B = 1_137_600 B ≈ 1.11 MB Scratch memory: 94_800 × 12 B ≈ 1.11 MB (per-signal pre-allocated) LoD scratch: ~2048 pixels × strategies (shared) ≈ 64 KB (one-shot) ≈ 2.22 MB per signal at full 1h capacity ``` 10 signals × 1h: **~22 MB**. Plain Compose+Kotlin overhead aside. ## Compose stability report All public types verified `stable class` by the Compose compiler (T6): | Class | Verdict | |---|---| | `RealtimeChartState` | stable (`@Stable`) | | `ChartConfig`, `DataConfig`, `AxisConfig`, `RenderConfig` | stable (`@Immutable`) | | `SignalConfig`, `ChartTheme` | stable (`@Immutable`) | | `T0`, `T0.FirstSample`, `T0.Fixed` | stable (`@Immutable` sealed) | | `YRange`, `YRange.Auto`, `YRange.Fixed` | stable (`@Immutable` sealed) | | `FrameRate`, `FrameRate.Display`, `FrameRate.Fixed` | stable (`@Immutable` sealed) | | `LodMode`, `AxisLabelMode` | stable (enum, Compose-inferred) | | `SignalRenderer` (interface) | stable (`@Stable`) | | `LineSignalRenderer` (object) | stable (singleton) | | `AxisFormatter` (interface + 4 impls) | stable (`@Immutable`) | | `ChartInteractionState` | stable (`@Stable`) | | `ViewportMode`, `CrosshairState`, `InteractionConfig` | stable (`@Immutable`) | Recomposition driven by `dataVersion: mutableLongStateOf` only. Idle = 0 recompositions verified by snapshot lambda equality check. ## Threading - **Producer** (any thread) — `push(name, ts, value)` routes through `Snapshot.withMutableSnapshot` with `SnapshotApplyConflictException` retry helper. Tested under 100 interleaved push+clear cycles - **Renderer** (UI thread) — reads `state.signalsArray` + per-signal `entry.scratch*`. Visibility via `@Volatile` on `_signals` / `resolvedT0Ms` / `SignalEntry.lastPushedTs` - **Pointer-input lambdas** read from `LongArray` cache slots populated inside the draw lambda. 1-frame stale tolerance is acceptable for gesture handling ## What is not measured yet The following land in `1.0.0` with a dedicated benchmark module: - Sustained frame time @ 200Hz × N signals on real hardware (Pixel 6, iPhone 13) - Battery impact over 1h sessions - Comparison vs Vico / KoalaPlot / MPAndroidChart (push throughput, frame time, memory steady-state) - Android macrobench (Jetpack Macrobenchmark) for cold/warm startup + render frame-time distribution - iOS Instruments allocation tracker capture If the library loses any specific comparison, the result is documented honestly. The bet is: chart-realtime wins on sustained high-frequency streaming; loses on static dataset rendering convenience (Vico has more polish there).