Skip to content

Commit f9ff621

Browse files
hyperpolymathclaude
andcommitted
test: add criterion benchmarks for LSM reservoir computation
Benchmarks cover single-step latency (small/medium reservoirs), step scaling by reservoir size, firing rate extraction, state vector retrieval, reset cost, and full 100Hz pipeline (100-step burst). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e52a24d commit f9ff621

2 files changed

Lines changed: 211 additions & 0 deletions

File tree

crates/lsm/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ tracing = { workspace = true }
1616

1717
[dev-dependencies]
1818
approx = { workspace = true }
19+
criterion = { workspace = true }
20+
21+
[[bench]]
22+
name = "lsm_bench"
23+
harness = false

crates/lsm/benches/lsm_bench.rs

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
3+
4+
//! Liquid State Machine (LSM) benchmarks — reservoir step latency,
5+
//! state extraction, and reset throughput.
6+
//!
7+
//! The LSM is the real-time hot path in the NeuroPhone pipeline:
8+
//! sensor data arrives at up to 100Hz and must be processed within
9+
//! the sample window. These benchmarks track:
10+
//! - `LiquidStateMachine::step` — single timestep forward pass
11+
//! - `LiquidStateMachine::get_firing_rates` — rate computation over a time window
12+
//! - `LiquidStateMachine::reset` — reservoir state clear between sessions
13+
//! - Full pipeline: N steps + firing rate extraction
14+
15+
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
16+
use lsm::{LiquidStateMachine, LsmConfig};
17+
use ndarray::Array1;
18+
19+
// ============================================================================
20+
// Helpers
21+
// ============================================================================
22+
23+
/// Build a minimal LSM config for benchmarking (5x5x5 = 125 neurons).
24+
fn make_small_config() -> LsmConfig {
25+
LsmConfig {
26+
dimensions: (5, 5, 5),
27+
p_exc: 0.3,
28+
p_inh: 0.2,
29+
frac_inh: 0.2,
30+
spectral_radius: 0.9,
31+
input_scale: 1.0,
32+
dt: 1.0,
33+
}
34+
}
35+
36+
/// Build a medium LSM config for benchmarking (10x10x10 = 1000 neurons).
37+
fn make_medium_config() -> LsmConfig {
38+
LsmConfig {
39+
dimensions: (10, 10, 10),
40+
..LsmConfig::default()
41+
}
42+
}
43+
44+
/// Build a zero-mean unit-variance input signal of the given dimension.
45+
fn make_input(dim: usize) -> Array1<f32> {
46+
Array1::linspace(0.0f32, 1.0, dim)
47+
}
48+
49+
/// Build a pre-initialized LSM with a given config and input dimension.
50+
fn make_lsm(config: LsmConfig, input_dim: usize) -> LiquidStateMachine {
51+
LiquidStateMachine::new(config, input_dim)
52+
.expect("Failed to create LiquidStateMachine for benchmark")
53+
}
54+
55+
// ============================================================================
56+
// Step benchmarks
57+
// ============================================================================
58+
59+
/// Benchmark a single LSM step on a small reservoir (125 neurons, 8-dim input).
60+
fn bench_step_small(c: &mut Criterion) {
61+
let mut lsm = make_lsm(make_small_config(), 8);
62+
let input = make_input(8);
63+
64+
c.bench_function("lsm_step_small_125neurons", |b| {
65+
b.iter(|| black_box(lsm.step(black_box(&input))))
66+
});
67+
}
68+
69+
/// Benchmark a single LSM step on the default reservoir (1000 neurons, 16-dim input).
70+
fn bench_step_medium(c: &mut Criterion) {
71+
let mut lsm = make_lsm(make_medium_config(), 16);
72+
let input = make_input(16);
73+
74+
c.bench_function("lsm_step_medium_1000neurons", |b| {
75+
b.iter(|| black_box(lsm.step(black_box(&input))))
76+
});
77+
}
78+
79+
/// Benchmark LSM step latency at varying reservoir sizes.
80+
///
81+
/// Shows how the O(n²) connectivity cost grows with reservoir dimension.
82+
fn bench_step_scaling(c: &mut Criterion) {
83+
let input_dim = 8;
84+
let mut group = c.benchmark_group("lsm_step_reservoir_size");
85+
86+
for (dx, dy, dz) in [(3usize, 3, 3), (5, 5, 5), (8, 8, 8)] {
87+
let n_neurons = dx * dy * dz;
88+
let config = LsmConfig {
89+
dimensions: (dx, dy, dz),
90+
..LsmConfig::default()
91+
};
92+
let mut lsm = make_lsm(config, input_dim);
93+
let input = make_input(input_dim);
94+
95+
group.bench_with_input(
96+
BenchmarkId::from_parameter(n_neurons),
97+
&n_neurons,
98+
|b, _| {
99+
b.iter(|| black_box(lsm.step(black_box(&input))))
100+
},
101+
);
102+
}
103+
group.finish();
104+
}
105+
106+
// ============================================================================
107+
// Firing rate extraction benchmarks
108+
// ============================================================================
109+
110+
/// Benchmark extracting firing rates over a 100ms window after 50 steps.
111+
fn bench_get_firing_rates(c: &mut Criterion) {
112+
let mut lsm = make_lsm(make_small_config(), 8);
113+
let input = make_input(8);
114+
115+
// Prime the reservoir with 50 steps so there is actual spike history.
116+
for _ in 0..50 {
117+
lsm.step(&input);
118+
}
119+
120+
c.bench_function("lsm_get_firing_rates_100ms", |b| {
121+
b.iter(|| black_box(lsm.get_firing_rates(black_box(100.0f64))))
122+
});
123+
}
124+
125+
/// Benchmark extracting state vector over varying window sizes.
126+
fn bench_get_state_window_scaling(c: &mut Criterion) {
127+
let mut lsm = make_lsm(make_small_config(), 8);
128+
let input = make_input(8);
129+
130+
for _ in 0..100 {
131+
lsm.step(&input);
132+
}
133+
134+
let mut group = c.benchmark_group("lsm_get_state_window_ms");
135+
for window_ms in [10.0f64, 50.0, 100.0, 500.0] {
136+
group.bench_with_input(
137+
BenchmarkId::from_parameter(window_ms as u64),
138+
&window_ms,
139+
|b, &w| {
140+
b.iter(|| black_box(lsm.get_state(black_box(w))))
141+
},
142+
);
143+
}
144+
group.finish();
145+
}
146+
147+
// ============================================================================
148+
// Reset benchmark
149+
// ============================================================================
150+
151+
/// Benchmark resetting the reservoir between processing sessions.
152+
///
153+
/// Called at the start of each new recording session — should be fast
154+
/// regardless of reservoir history depth.
155+
fn bench_reset(c: &mut Criterion) {
156+
let mut lsm = make_lsm(make_small_config(), 8);
157+
let input = make_input(8);
158+
159+
// Build up 200 steps of history first.
160+
for _ in 0..200 {
161+
lsm.step(&input);
162+
}
163+
164+
c.bench_function("lsm_reset_after_200_steps", |b| {
165+
b.iter(|| {
166+
lsm.reset();
167+
// Immediately do one step to prevent dead-code elimination.
168+
black_box(lsm.step(black_box(&input)));
169+
})
170+
});
171+
}
172+
173+
// ============================================================================
174+
// Full pipeline benchmark
175+
// ============================================================================
176+
177+
/// Benchmark a complete 100-step processing window (1 second at 100Hz).
178+
///
179+
/// This represents the real workload: process a sensor burst, then extract
180+
/// a state vector for the ESN layer.
181+
fn bench_full_pipeline_100hz(c: &mut Criterion) {
182+
let mut lsm = make_lsm(make_small_config(), 8);
183+
let inputs: Vec<Array1<f32>> = (0..100).map(|i| make_input(i % 8 + 1)).collect();
184+
185+
c.bench_function("lsm_full_pipeline_100_steps", |b| {
186+
b.iter(|| {
187+
lsm.reset();
188+
for inp in &inputs {
189+
black_box(lsm.step(black_box(inp)));
190+
}
191+
black_box(lsm.get_firing_rates(black_box(100.0f64)))
192+
})
193+
});
194+
}
195+
196+
criterion_group!(
197+
benches,
198+
bench_step_small,
199+
bench_step_medium,
200+
bench_step_scaling,
201+
bench_get_firing_rates,
202+
bench_get_state_window_scaling,
203+
bench_reset,
204+
bench_full_pipeline_100hz,
205+
);
206+
criterion_main!(benches);

0 commit comments

Comments
 (0)