Differences with pylsl๐Ÿ”—

Safer chunk pull default๐Ÿ”—

Arguably the most important difference, pulling a chunk of numerical data with pull_chunk() is faster than with its pylsl counterpart, except if using the argument dest_obj. By default, pylsl loads the retrieved samples one by one in a list of list, here.

# return results (note: could offer a more efficient format in the
# future, e.g., a numpy array)
num_samples = num_elements / num_channels
if dest_obj is None:
    samples = [
        [data_buff[s * num_channels + c] for c in range(num_channels)]
        for s in range(int(num_samples))
    ]
    if self.channel_format == cf_string:
        samples = [[v.decode("utf-8") for v in s] for s in samples]
        free_char_p_array_memory(data_buff, max_values)

The creation of the variable samples is expensive and is performed in linear time O(n), scaling with the number of values. Instead, numpy can be used to pull the entire buffer at once with numpy.frombuffer().

samples = np.frombuffer(
    data_buffer, dtype=self._dtype)[:n_samples_data].reshape(-1, self._n_channels
)

Now, samples is created in constant time O(1). The performance gain varies depending on the number of values pulled, for instance retrieving 1024 samples with 65 channels in double precision (float64) takes:

  • 3.91 ยฑ 0.12 ms with pylsl (default behavior: list comprehension)

  • 419.08 ยฑ 38.10 ns with mne_lsl.lsl (numpy.frombuffer())

Increasing the number of channels to 650, simulating an higher sample count yields:

  • 46.56 ยฑ 1.06 ms per loop with pylsl (default behavior: list comprehension)

  • 424.29 ยฑ 36.41 ns with mne_lsl.lsl (numpy.frombuffer())

Timeit python code
import ctypes
import timeit

import numpy as np

n_samples = 1024
n_channels = 650
max_values = n_samples * n_channels

# create a data buffer, 'retrieved from liblsl'
data_buffer = (ctypes.c_double * max_values)()
for i in range(max_values):
    data_buffer[i] = float(i)


def method_list_comprehension():
    """List comprehension, default method for pylsl."""
    samples = [
        [data_buffer[s * n_channels + c] for c in range(n_channels)]
        for s in range(n_samples)
    ]
    return samples


def method_frombuffer():
    """Numpy frombuffer, default method for mne-lsl."""
    samples = np.frombuffer(data_buffer, dtype=np.float64)[:max_values].reshape(
        n_samples, n_channels
    )
    return samples


repeat = 5
number = 100
for func in (method_list_comprehension, method_frombuffer):
    timer = timeit.Timer(func)
    results = timer.repeat(repeat, number=number)
    times = [t / number for t in results]
    best = min(times)
    worst = max(times)
    avg = sum(times) / len(times)

    # format with the correct unit
    if best < 1e-6:
        unit, factor = "ns", 1e9
    elif best < 1e-3:
        unit, factor = "ยตs", 1e6
    elif best < 1:
        unit, factor = "ms", 1e3
    else:
        unit, factor = "s", 1

    formatted_time = f"{best * factor:.2f} ยฑ {(avg - best) * factor:.2f} {unit}"
    plural = "s" if number != 1 else ""
    print(f"{number} loop{plural}, best of {repeat}: {formatted_time} per loop")

Note that pylsl pulling function support a dest_obj argument described as:

A Python object that supports the buffer interface.
If this is provided then the dest_obj will be updated in place and the samples list
returned by this method will be empty. It is up to the caller to trim the buffer to
the appropriate number of samples. A numpy buffer must be order='C'.

If a ndarray is used as dest_obj, the memory re-allocation step described above is skipped, yielding better performance than mne_lsl.lsl at the cost of code complexity as the user is now responsible for the memory management.

Note

This performance improvement is absent for string based streams. Follow #225 for more information.

Convenience methods๐Ÿ”—

A StreamInfo has several convenience methods to retrieve and set channel attributes: names, types, units.

Those methods eliminate the need to interact with the XMLElement underlying tree, present in the mne_lsl.lsl.StreamInfo.desc property. The description can even be set or retrieved directly from a Info object with set_channel_info() and get_channel_info().

Improve arguments๐Ÿ”—

The arguments of a StreamInfo, StreamInlet, StreamOutlet support a wider variety of types. For instance:

  • dtype, which correspond to the channel_format in pylsl, can be provided as a string or as a supported numpy.dtype, e.g. np.int8.

  • processing_flags can be provided as strings instead of the underlying integer mapping.

Overall, the arguments are checked in mne_lsl.lsl. Any type or value mistake will raise an helpful error message.

Unique resolve function๐Ÿ”—

pylsl has several stream resolving functions:

  • resolve_streams which resolves all streams on the network.

  • resolve_byprop which resolves all streams with a specific value for a given property.

  • resolve_bypred which resolves all streams with a given predicate.

mne_lsl.lsl.resolve_streams() simplifies stream resolution with a unique function with similar functionalities.