KMPCharts/docs/PERFORMANCE.md
Trentin Davide 07197ed8df
Some checks are pending
CI / build (push) Waiting to run
chore(portfolio): v1.0.0 prep — docs, CI, demos
Autonomous portfolio prep batch. All gates green: 207 tests pass on
iosSimulatorArm64Test, assembleRelease green, apiCheck green, app builds.

Docs:
- CHANGELOG.md (Keep-a-Changelog, retroactive v0.1-v0.5)
- CONTRIBUTING.md (build + style + PR workflow + perf rules)
- chart-realtime/README.md rewrite for v0.5.0 API surface
- docs/PERFORMANCE.md (LoD perf table, alloc budget, memory budget,
  stability report, threading model)

CI (.github/):
- workflows/ci.yml — macos-latest, JDK 21, konan cache, full gate
- workflows/release.yml — tag-triggered, GitHub release with XCFramework
  + AAR; Maven Central as commented TODO (needs OSSRH + GPG secrets)
- dependabot.yml — weekly gradle + actions updates
- pull_request_template.md + 2 issue templates (bug, feature)

Android demo polish:
- DemoScreen + ControlPanel (Material3 surface)
- Sample rate slider (10..200Hz), 1-6 signal count, per-signal waveform
  picker (sine/square/triangle/noise), window seconds, LoD picker, Y
  label mode, Y formatter, interaction toggle, theme switch, status chips
- Single producer coroutine (cleaner crosshair readout, easier rate
  accounting)
- SignalGenerator + WaveformType + DemoConfig + PushCounter

iOS demo scaffold (samples/ios/ChartRealtimeDemo/):
- SPM Package.swift with local binaryTarget pointing to
  chart-realtime/build/XCFrameworks/debug/ChartRealtime.xcframework
- DemoApp.swift + DemoView.swift (smoke test confirming Kotlin types
  accessible from Swift)
- README documents the ComposeUIViewController shim needed in
  chart-realtime/src/iosMain/ for full SwiftUI hosting (deferred to
  v1.0.0 T1)
- xcodebuild against iOS Simulator builds green

Debt:
- gradle.properties: removed kotlin.internal.klibs.non-packed=false
  (Compose-MP 1.11.0 final no longer needs the bypass; apiCheck +
  native compile + 207 tests still green)
- 8 remaining deprecation warnings tied to com.android.library + KMP
  plugin combo; clearing them requires migration to
  com.android.kotlin.multiplatform.library 9.2.0 which forces ABI
  baseline regen + Maven publish restructure + XCFramework hierarchy
  expansion. Deferred as dedicated ticket.

Skipped (need user):
- CODE_OF_CONDUCT.md (content classifier blocks Contributor Covenant
  verbatim)
- Maven Central T1 (Sonatype + GPG)
- Benchmark vs competitors T4 (methodology + Maven coords)
- Logo/banner/GIF T7 (design assets)
- Tag + GitHub release T10 (rename pending)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:54:26 +02:00

5.9 KiB
Raw Blame History

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) 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<Float,Float> 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).