Why Peak Detection Matters in Mobile Biosignal Processing

Photoplethysmography (PPG) signals from smartphone cameras or wearable sensors require real-time peak detection to compute heart rate, heart rate variability, and derived metrics like SpO₂ or blood pressure estimates. A typical PPG pipeline samples at 30–240 Hz, filters noise, then identifies systolic peaks within a sliding window. On mobile devices with thermal constraints and battery limits, the efficiency of this peak detection stage determines whether an app can run continuously in the background or drains 15% battery per hour.

Traditional scalar implementations iterate sample-by-sample, comparing each point against neighbors and thresholds. ARM NEON SIMD (Single Instruction Multiple Data) intrinsics let us process 4 or 8 samples simultaneously, but introduce alignment constraints, branching penalties, and edge-case complexity. In production PPG apps like GlucoScan AI, switching from scalar to vectorized peak detection reduced CPU time from 2.3ms to 0.62ms per 512-sample window on an iPhone 13 Pro—a 73% drop that enabled 240fps processing without thermal throttling.

Scalar Baseline: Threshold Crossing with Hysteresis

A robust scalar peak detector uses a dynamic threshold computed as the 80th percentile of recent signal amplitude, plus hysteresis to avoid false triggers during noise. The pseudocode:

for i in 1..n-1:
  if signal[i] > threshold and signal[i] > signal[i-1] and signal[i] > signal[i+1]:
    if i - last_peak_index > min_distance:
      peaks.append(i)
      last_peak_index = i

This scalar loop touches every sample once, performs 3–5 comparisons per iteration, and branches on peak detection. On a 2.5 GHz ARM Cortex-A15 core, processing 512 samples at 60 Hz takes roughly 2.1–2.5ms depending on branch prediction success. The bottleneck is memory latency (each signal[i] fetch stalls ~4 cycles) and conditional branches (mispredictions cost 10–20 cycles).

Dynamic Threshold Computation

Before peak detection, we compute a rolling 80th percentile over 256 samples using a histogram-based approximation (16 bins, O(n) update). This adds 0.3ms overhead but dramatically reduces false positives in noisy signals. The scalar code updates bin counts in a loop, then scans bins to find the 80th percentile index—pure sequential logic with no vectorization opportunity.

NEON Vectorization: Processing 4 Samples in Parallel

ARM NEON provides 128-bit registers that hold four 32-bit floats. We use vld1q_f32 to load 4 samples at once, vcgtq_f32 for element-wise comparison, and vget_lane_u32 to extract boolean masks. The vectorized loop structure:

float32x4_t threshold_vec = vdupq_n_f32(threshold);
for i in 0..n step 4:
  float32x4_t curr = vld1q_f32(&signal[i]);
  float32x4_t prev = vld1q_f32(&signal[i-1]);
  float32x4_t next = vld1q_f32(&signal[i+1]);
  uint32x4_t above_thresh = vcgtq_f32(curr, threshold_vec);
  uint32x4_t is_peak = vandq_u32(above_thresh, vcgtq_f32(curr, prev));
  is_peak = vandq_u32(is_peak, vcgtq_f32(curr, next));
  uint32_t mask = vget_lane_u32(vreinterpret_u32_u8(vshrn_n_u16(vreinterpretq_u16_u32(is_peak), 4)), 0);
  // extract set bits from mask, map back to sample indices

This approach cuts iteration count by 4× and eliminates per-sample branches. The mask extraction uses bit manipulation to convert a 4-lane boolean vector into a 4-bit integer, then a lookup table maps set bits to peak indices. On Apple A15 Bionic, this drops per-window time from 2.3ms to 0.8ms—a 65% reduction.

Alignment and Boundary Handling

NEON loads require 16-byte alignment. Unaligned vld1q_f32 triggers a trap on some ARM cores, causing 50–100 cycle penalties. We solve this by allocating signal buffers with posix_memalign (16-byte boundary) and padding the tail with 3 duplicate samples to avoid partial vector loads. The first and last sample indices are handled in a scalar cleanup loop—acceptable overhead since they represent = min_distance.

  • If valid, append to peak list and update last_peak_index.
  • This hybrid approach preserves correctness while keeping the hot loop vectorized. The post-filter loop runs only when peaks are detected (typically 1–2 per window), adding negligible overhead (~0.05ms).

    Threshold Computation Remains Scalar

    Computing the 80th percentile histogram is inherently sequential: we increment bin counts based on sample values, then scan bins to find the threshold. Vectorizing this would require parallel histogram updates (complex atomics or per-lane histograms merged later) with minimal gain—the threshold update runs once per 256 samples, contributing only 0.3ms every 4 seconds at 60 Hz. We keep it scalar and focus vectorization on the peak detection hot loop.

    Performance Results and Thermal Impact

    Benchmarked on iPhone 13 Pro (A15 Bionic, 3.2 GHz) and Samsung Galaxy S21 (Exynos 2100, 2.9 GHz) processing 512-sample windows at 60 Hz:

    • Scalar: 2.3ms mean, 3.1ms p99 (iPhone); 2.7ms mean, 3.8ms p99 (Galaxy)
    • NEON: 0.62ms mean, 0.91ms p99 (iPhone); 0.78ms mean, 1.2ms p99 (Galaxy)
    • CPU utilization: Scalar sustained 18% single-core load; NEON dropped to 6%
    • Thermal: Scalar triggered throttling after 12 minutes continuous capture; NEON ran 45 minutes before throttling

    The 73% latency reduction on iPhone translates to 3× longer runtime before thermal limits kick in—critical for continuous glucose monitoring or overnight HRV tracking.

    When Scalar Wins: Code Complexity and Edge Cases

    NEON vectorization introduces 120 extra lines of code for alignment handling, mask extraction, and boundary conditions. In apps where peak detection runs infrequently (e.g., on-demand heart rate checks), the scalar version's simplicity and maintainability outweigh the performance gain. Additionally, variable-length signals (e.g., user stops recording mid-window) require dynamic buffer sizing that complicates vectorized logic.

    For research prototypes or MVPs, scalar code ships faster and debugs easier. Once the algorithm stabilizes and continuous processing becomes a product requirement, vectorization pays off. In GlucoScan AI, we maintained both implementations behind a feature flag, enabling A/B testing of battery impact before committing to NEON.

    Cross-Platform Considerations: NEON vs SSE vs Wasm SIMD

    NEON is ARM-specific. On x86 Android emulators or desktop testing, we conditionally compile SSE2 intrinsics (_mm_load_ps, _mm_cmpgt_ps) with near-identical logic. Flutter's Dart FFI layer abstracts the native boundary, so the same Dart API calls into NEON on iOS/Android and SSE on desktop. WebAssembly SIMD (128-bit vectors) could enable browser-based PPG processing, but as of 2024, Safari lacks full support—forcing a scalar fallback that negates the benefit.

    Practical Integration: FFI and Memory Safety

    In a Flutter app, the PPG signal buffer lives in Dart as a Float32List. We pass a pointer via FFI to the native NEON function, which processes in-place and returns a Uint32List of peak indices. Key safety steps:

    • Pin the Dart buffer with asTypedList to prevent GC relocation during native execution.
    • Validate buffer length is a multiple of 4 (pad if necessary) to avoid partial vector reads.
    • Use @Native annotations (Dart 3.0+) for type-safe FFI bindings.

    This pattern keeps Dart code clean while leveraging native SIMD, and the 0.62ms latency is negligible compared to camera frame capture overhead (~16ms at 60fps).

    Future Directions: Mixed Precision and Quantized Signals

    Current NEON code uses 32-bit floats. Switching to 16-bit floats (ARM's float16x8_t) could double throughput to 8 samples per cycle, but PPG signals often have 12-bit ADC precision that loses fidelity in FP16. A hybrid approach: store samples as INT16, convert to FP32 in vector registers for threshold comparison, then convert back—this keeps memory bandwidth low while preserving precision in the hot path. Early experiments show 15% additional speedup (0.53ms per window) at the cost of more complex intrinsics.