Skip to content

perf: numpy-native chunk path with as_numpy opt-in - #123

Merged
cboulay merged 5 commits into
mainfrom
perf/numpy-chunk-path
Sep 20, 2026
Merged

cboulay merged 5 commits into
mainfrom
perf/numpy-chunk-path

Conversation

@cboulay

@cboulay cboulay commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

First follow-up to the benchmark harness (#122). Makes numpy the native currency for chunked data while keeping the existing list-returning API unchanged.

StreamInlet.pull_chunk

  • Numeric streams now pull straight into numpy memory.
  • New as_numpy keyword (per call) and StreamInlet(..., as_numpy=False) constructor default. With as_numpy=True, samples is an (n_samples, n_channels) array in the stream's dtype and timestamps is a float64 array. Fresh arrays are allocated per call, so results never alias a reused buffer.
  • Default (as_numpy=False) still returns lists with identical types and values, now via ndarray.tolist() instead of ctypes slicing.
  • min_samples two-phase pulls concatenate correctly in both modes.
  • dest_obj and as_numpy are orthogonal: dest_obj chooses whose memory the data lands in, as_numpy chooses the return types. With both, samples is a view of dest_obj trimmed to the samples received (a single view even on the min_samples path) and timestamps is a float64 array. Without as_numpy, dest_obj behaviour is unchanged (None and a list).
  • String streams ignore as_numpy and always return lists.

StreamOutlet.push_chunk

  • Numeric input goes through np.ascontiguousarray(x, dtype=stream_dtype), so nested lists, flat multiplexed lists, and arrays of any dtype or memory order are all handled by one path.
  • Fixes a silent data-corruption bug: a float64 array pushed to a float32 stream was previously handed to liblsl byte-wise via from_buffer, producing garbage. Fortran-ordered arrays were similarly mis-sent. Both are now converted.
  • Wrong channel count still raises ValueError; empty input is still a no-op.

String streams

  • push_chunk joins the encoded values into one NUL-separated buffer and derives the char* table with a vectorised cumsum instead of building a ctypes c_char_p array element by element.
  • The free loop after a string pull reads liblsl's pointers through a numpy view rather than indexing a ctypes pointer array. With liblsl >= 1.18.0.b4 (inlet: allocate only filled string slots; add lsl_destroy_string_array sccn/liblsl#302) it uses the new lsl_destroy_string_array in a single call; older liblsl falls back to one lsl_destroy_string per string. NULL slots are skipped either way, so both allocation behaviours are supported.

Also adds pylsl.lib.fmt2npdtype and an as_numpy row to bench/bench_pylsl.py.

Benchmark (Apple Silicon, liblsl 1.18.0.b3, chunk=1000, --compare vs the #122 baseline)

benchmark before µs after µs speedup
pull_chunk as_numpy float32 ch=64 (n/a) 26 28x vs list
pull_chunk as_numpy float32 ch=8 (n/a) 11 12x vs list
pull_chunk list float32 ch=64 964 734 1.31x
pull_chunk list float32 ch=8 188 131 1.43x
pull_chunk list float32 ch=1 87 53 1.64x
pull_chunk dest_obj float32 ch=64 40 30 1.34x
push_chunk list-of-lists float32 ch=64 2854 940 3.04x
push_chunk list-of-lists float32 ch=8 428 259 1.65x
push_chunk numpy float32 ch=64 48 46 1.06x
push_chunk list-of-lists float32 ch=1 151 168 0.90x
push_chunk list-of-lists string ch=64 9864 3990 2.47x
push_chunk list-of-lists string ch=8 1229 621 1.98x
pull_chunk list string ch=64 (liblsl b3) 12684 10757 1.18x
pull_chunk list string ch=64 (liblsl b4, bulk free) 12684 3617 3.5x

as_numpy at 64 channels sustains ~2.4 Gvalues/s, i.e. memcpy-bound. The remaining cost in the default list path is creating Python float objects, which is inherent to returning lists. The one regression (single-channel list push, +10%) is the fixed cost of np.asarray on 1000 one-element lists; I consider it acceptable given the correctness fix on the same path. ctypes call overhead is unchanged.

Compatibility

  • No signature removals. pull_chunk gains a keyword; StreamInlet.__init__ gains a keyword.
  • Default return types unchanged. Existing tests that assert isinstance(..., list) still pass.
  • push_chunk now accepts inputs it previously mishandled; inputs that used to work still work.

Test plan

  • 46 passed, 1 expected skip. New tests: as_numpy round-trip dtype/shape/values, inlet-level default plus per-call override, string streams ignore as_numpy, as_numpy result survives the next pull (no aliasing), min_samples concatenation in numpy mode, push_chunk conversion for float64 / Fortran-order / nested / flat / view inputs, and channel-count rejection.
  • Existing white-box tests updated for the helper's new as_numpy argument.
  • ruff check/format clean.

pull_chunk now pulls numeric data straight into numpy memory. With
as_numpy=True (per call, or as a StreamInlet constructor default) it
returns a (n_samples, n_channels) array in the stream's dtype plus a
float64 timestamp array, skipping list conversion entirely. The default
list return is unchanged in type and values but now uses ndarray.tolist().

push_chunk routes numeric input through np.ascontiguousarray, so nested
lists, flat lists, and arrays of any dtype or memory order are converted
correctly. Previously a float64 array pushed to a float32 stream was
reinterpreted byte-wise (silent garbage), and list input built a ctypes
array element by element.

String streams are unchanged apart from a tidier decode loop.

Adds fmt2npdtype to pylsl.lib, an as_numpy row to the benchmark, and
round-trip tests for the new paths.
dest_obj chooses whose memory the data lands in; as_numpy chooses what
comes back. With both, samples is a view of dest_obj trimmed to the
samples received (a single view even on the two-phase min_samples path)
and timestamps is a float64 array. Without as_numpy, dest_obj behaviour
is unchanged: None and a list. Docstring rewritten around that split.
…n free

push_chunk for string streams now joins the encoded values into one
NUL-separated buffer and derives the char* table with a cumsum, instead
of constructing a ctypes c_char_p array element by element. 2.5x faster
at 64 channels.

free_char_p_array_memory reads the pointers liblsl allocated through a
numpy view rather than indexing a ctypes pointer array. The per-string
lsl_destroy_string call remains: liblsl has no bulk-free API.
lsl_pull_chunk_str mallocs a string into all data_buffer_elements slots,
not just the ones it filled. The numpy-path refactor freed only
num_elements, leaking the rest on every partial or empty pull. Restore
freeing max_values (as before) and memset the pointer table afterwards
so a stale pointer can never reach lsl_destroy_string twice.
liblsl 1.18.0.b4 adds a bulk free and NULLs unfilled chunk slots. Bind
lsl_destroy_string_array optionally and use it from
free_char_p_array_memory (one call instead of one per string), falling
back to the per-string loop on older liblsl. NULL entries are skipped in
both paths, so pylsl works with either allocation behaviour.

String pull_chunk at 64 channels x 1000 samples: 10.8 ms -> 3.6 ms.
@cboulay
cboulay merged commit c6ff323 into main Sep 20, 2026
19 checks passed
@cboulay
cboulay deleted the perf/numpy-chunk-path branch September 20, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant