Skip to content

Commit

Permalink
Improve documentation of performance differences between pylsl and mn…
Browse files Browse the repository at this point in the history
…e.lsl (#231)
  • Loading branch information
mscheltienne authored Mar 18, 2024
1 parent 94b8d6b commit e4e5abf
Show file tree
Hide file tree
Showing 11 changed files with 128 additions and 90 deletions.
17 changes: 10 additions & 7 deletions doc/api/lsl (low-level).rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ Compared to `pylsl <lsl python_>`_, ``mne_lsl.lsl`` pulls a chunk of *numerical*
faster thanks to ``numpy``. In numbers, pulling a 1024 samples chunk with 65 channels in
double precision (``float64``) to python takes:

* 7.87 ms ± 58 µs with ``pylsl``
* 4.35 µs ± 134 ns with ``mne_lsl.lsl``

More importantly, ``pylsl`` pulls a chunk in linear time ``O(n)``, scalings with the
number of values; while ``mne_lsl.lsl`` pulls a chunk in constant time ``O(1)``.
Additional details on the differences with ``pylsl`` and ``mne_lsl`` can be found
:ref:`here<resources/pylsl:Differences with pylsl>`.
* 4.33 ms ± 37.5 µs with ``pylsl`` default behavior
* 471 ns ± 1.7 ns with ``pylsl`` using a :class:`~numpy.ndarray` as ``dest_obj`` to
prevent memory re-allocation
* 268 ns ± 0.357 ns with ``mne_lsl.lsl`` which uses :func:`numpy.frombuffer` under the
hood

More importantly, ``pylsl`` dewfault behavior pulls a chunk in linear time ``O(n)``,
scalings with the number of values; while ``mne_lsl.lsl`` pulls a chunk in constant time
``O(1)``. Additional details on the differences with ``pylsl`` and ``mne_lsl`` can be
found :ref:`here<resources/pylsl:Differences with pylsl>`.

.. autosummary::
:toctree: ../generated/api
Expand Down
78 changes: 49 additions & 29 deletions doc/make.bat
Original file line number Diff line number Diff line change
@@ -1,35 +1,55 @@
@ECHO OFF
@echo off

pushd %~dp0
REM Minimal makefile for Sphinx documentation

REM Command file for Sphinx documentation
REM Set default options and commands
set SPHINXOPTS=-nWT --keep-going
set SPHINXBUILD=sphinx-build

if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build

%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)

if "%1" == "" goto help

%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
if "%1" == "html" goto html
if "%1" == "html-noplot" goto html-noplot
if "%1" == "clean" goto clean
if "%1" == "linkcheck" goto linkcheck
if "%1" == "linkcheck-grep" goto linkcheck-grep
if "%1" == "view" goto view

REM Define targets
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
echo Please use `make ^<target^>` where ^<target^> is one of
echo html to make standalone HTML files
echo html-noplot to make standalone HTML files without plotting
echo clean to clean HTML files
echo linkcheck to check all external links for integrity
echo linkcheck-grep to grep the linkcheck result
echo view to view the built HTML
goto :eof

:html
%SPHINXBUILD% . _build\html -b html %SPHINXOPTS%
goto :eof

:html-noplot
%SPHINXBUILD% . _build\html -b html %SPHINXOPTS% -D plot_gallery=0
goto :eof

:clean
rmdir /s /q _build generated
goto :eof

:linkcheck
%SPHINXBUILD% . _build\linkcheck -b linkcheck -D plot_gallery=0
goto :eof

:linkcheck-grep
findstr /C:"[broken]" _build\linkcheck\output.txt > nul
if %errorlevel% equ 0 (
echo Lines with [broken]:
findstr /C:"[broken]" _build\linkcheck\output.txt
) else (
echo No lines with [broken] found.
)
goto :eof

:end
popd
:view
python -c "import webbrowser; webbrowser.open_new_tab(r'file:///%cd%\_build\html\index.html')"
goto :eof
33 changes: 25 additions & 8 deletions doc/resources/pylsl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Faster chunk pull

Arguably the most important difference, pulling a chunk of numerical data with
:meth:`~mne_lsl.lsl.StreamInlet.pull_chunk` is much faster than with its
`pylsl <lsl python_>`_ counterpart. `pylsl <lsl python_>`_ loads the retrieved samples
one by one in a list of list, `here <pylsl pull_chunk_>`_.
`pylsl <lsl python_>`_ counterpart. By default, `pylsl <lsl python_>`_ loads the
retrieved samples one by one in a list of list, `here <pylsl pull_chunk_>`_.

.. code-block:: python
Expand Down Expand Up @@ -39,13 +39,27 @@ the entire buffer at once with :func:`numpy.frombuffer`.
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``) is ~1800 times slower with
`pylsl <lsl python_>`_:
65 channels in double precision (``float64``) takes:

* 7.87 ms ± 58 µs with ``pylsl``
* 4.35 µs ± 134 ns with ``mne_lsl.lsl``
* 4.33 ms ± 37.5 µs with ``pylsl`` (default behavior)
* 268 ns ± 0.357 ns with ``mne_lsl.lsl``

Note that this performance improvement is absent for ``string`` based streams.
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 :class:`~numpy.ndarray` is used as ``dest_obj``, the memory re-allocation step
described abvove is skipped, yielding similar performance to ``mne_lsl.lsl``. For the
same 1024 samples with 65 channels in double precision (``float64``), the pull operation
takes:

* 471 ns ± 1.7 ns with ``pylsl`` (with ``dest_obj`` argument as :class:`~numpy.ndarray`)

Note that this performance improvement is absent for ``string`` based streams. Follow
:issue:`225` for more information.

Convenience methods
~~~~~~~~~~~~~~~~~~~
Expand All @@ -64,7 +78,10 @@ channel attributes: names, types, units.
* :meth:`~mne_lsl.lsl.StreamInfo.set_channel_units`

Those methods eliminate the need to interact with the ``XMLElement`` underlying tree,
present in the :py:attr:`mne_lsl.lsl.StreamInfo.desc` property.
present in the :py:attr:`mne_lsl.lsl.StreamInfo.desc` property. The description can even
be set or retrieved directly from a :class:`~mne.Info` object with
:meth:`~mne_lsl.lsl.StreamInfo.set_channel_info` and
:meth:`~mne_lsl.lsl.StreamInfo.get_channel_info`.

Improve arguments
~~~~~~~~~~~~~~~~~
Expand Down
2 changes: 1 addition & 1 deletion mne_lsl/lsl/tests/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def test_resolve_streams():
assert len(streams) == 0

# detect all streams
sinfo = StreamInfo("test", "", 1, 0.0, "int8", uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 1, 0.0, "int8", uuid.uuid4().hex)
outlet = StreamOutlet(sinfo)
streams = resolve_streams(timeout=2)
assert isinstance(streams, list)
Expand Down
38 changes: 19 additions & 19 deletions mne_lsl/lsl/tests/test_stream_info.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from time import strftime
import uuid

import numpy as np
import pytest
Expand All @@ -15,7 +15,7 @@

def test_stream_info_desc(caplog):
"""Test setters and getters for StreamInfo."""
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", strftime("%H%M%S"))
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", uuid.uuid4().hex)
assert sinfo.get_channel_names() is None
assert sinfo.get_channel_types() is None
assert sinfo.get_channel_units() is None
Expand Down Expand Up @@ -69,7 +69,7 @@ def test_stream_info_desc(caplog):
assert sinfo.get_channel_units() == ["101"] * 3
assert "elements for 3 channels" not in caplog.text

sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", strftime("%H%M%S"))
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", uuid.uuid4().hex)
channels = sinfo.desc.append_child("channels")
ch = channels.append_child("channel")
ch.append_child_value("label", "tempered-label")
Expand All @@ -87,7 +87,7 @@ def test_stream_info_desc(caplog):

def test_stream_info_invalid_desc():
"""Test invalid arguments for the channel description setters."""
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", strftime("%H%M%S"))
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", uuid.uuid4().hex)
assert sinfo.get_channel_names() is None
assert sinfo.get_channel_types() is None
assert sinfo.get_channel_units() is None
Expand Down Expand Up @@ -128,33 +128,33 @@ def test_stream_info_invalid_desc():
)
def test_create_stream_info_with_numpy_dtype(dtype, dtype_str):
"""Test creation of a StreamInfo with a numpy dtype instead of a string."""
sinfo = StreamInfo("pytest", "eeg", 3, 101, dtype_str, strftime("%H%M%S"))
sinfo = StreamInfo("pytest", "eeg", 3, 101, dtype_str, uuid.uuid4().hex)
assert sinfo.dtype == dtype
del sinfo
sinfo = StreamInfo("pytest", "eeg", 3, 101, dtype, strftime("%H%M%S"))
sinfo = StreamInfo("pytest", "eeg", 3, 101, dtype, uuid.uuid4().hex)
assert sinfo.dtype == dtype
del sinfo


def test_create_stream_info_with_invalid_numpy_dtype():
"""Test creation of a StreamInfo with an invalid numpy dtype."""
with pytest.raises(ValueError, match="provided dtype could not be interpreted as"):
StreamInfo("pytest", "eeg", 3, 101, np.uint8, strftime("%H%M%S"))
StreamInfo("pytest", "eeg", 3, 101, np.uint8, uuid.uuid4().hex)


def test_stream_info_equality():
"""Test == method."""
sinfo1 = StreamInfo("pytest", "eeg", 3, 101, "float32", strftime("%H%M%S"))
sinfo1 = StreamInfo("pytest", "eeg", 3, 101, "float32", uuid.uuid4().hex)
assert sinfo1 != 101
sinfo2 = StreamInfo("pytest-2", "eeg", 3, 101, "float32", strftime("%H%M%S"))
sinfo2 = StreamInfo("pytest-2", "eeg", 3, 101, "float32", uuid.uuid4().hex)
assert sinfo1 != sinfo2
sinfo2 = StreamInfo("pytest", "gaze", 3, 101, "float32", strftime("%H%M%S"))
sinfo2 = StreamInfo("pytest", "gaze", 3, 101, "float32", uuid.uuid4().hex)
assert sinfo1 != sinfo2
sinfo2 = StreamInfo("pytest", "eeg", 3, 10101, "float32", strftime("%H%M%S"))
sinfo2 = StreamInfo("pytest", "eeg", 3, 10101, "float32", uuid.uuid4().hex)
assert sinfo1 != sinfo2
sinfo2 = StreamInfo("pytest", "eeg", 101, 101, "float32", strftime("%H%M%S"))
sinfo2 = StreamInfo("pytest", "eeg", 101, 101, "float32", uuid.uuid4().hex)
assert sinfo1 != sinfo2
sinfo2 = StreamInfo("pytest", "eeg", 3, 101, np.float64, strftime("%H%M%S"))
sinfo2 = StreamInfo("pytest", "eeg", 3, 101, np.float64, uuid.uuid4().hex)
assert sinfo1 != sinfo2
sinfo2 = StreamInfo("pytest", "eeg", 3, 101, np.float32, "pytest")
assert sinfo1 != sinfo2
Expand All @@ -164,7 +164,7 @@ def test_stream_info_equality():

def test_stream_info_representation():
"""Test the str() representation of an Info."""
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", strftime("%H%M%S"))
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", uuid.uuid4().hex)
repr_ = str(sinfo)
assert "'pytest'" in repr_
assert "eeg" in repr_
Expand All @@ -175,7 +175,7 @@ def test_stream_info_representation():

def test_stream_info_properties(close_io):
"""Test properties."""
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", strftime("%H%M%S"))
sinfo = StreamInfo("pytest", "eeg", 3, 101, "float32", uuid.uuid4().hex)
assert isinstance(sinfo.created_at, float)
assert sinfo.created_at == 0.0
assert isinstance(sinfo.hostname, str)
Expand Down Expand Up @@ -218,15 +218,15 @@ def test_stream_info_properties(close_io):
def test_invalid_stream_info():
"""Test creation of an invalid StreamInfo."""
with pytest.raises(ValueError, match="'n_channels' must be a strictly positive"):
StreamInfo("pytest", "eeg", -101, 101, "float32", strftime("%H%M%S"))
StreamInfo("pytest", "eeg", -101, 101, "float32", uuid.uuid4().hex)
with pytest.raises(ValueError, match="'sfreq' must be a positive"):
StreamInfo("pytest", "eeg", 101, -101, "float32", strftime("%H%M%S"))
StreamInfo("pytest", "eeg", 101, -101, "float32", uuid.uuid4().hex)


def test_stream_info_desc_from_info(close_io):
"""Test filling a description from an Info object."""
info = create_info(5, 1000, "eeg")
sinfo = StreamInfo("test", "eeg", 5, 1000, np.float32, strftime("%H%M%S"))
sinfo = StreamInfo("test", "eeg", 5, 1000, np.float32, uuid.uuid4().hex)
sinfo.set_channel_info(info)
info_retrieved = sinfo.get_channel_info()
compare_infos(info, info_retrieved)
Expand All @@ -235,7 +235,7 @@ def test_stream_info_desc_from_info(close_io):
fname = testing.data_path() / "sample_audvis_raw.fif"
raw = read_raw_fif(fname, preload=False)
sinfo = StreamInfo(
"test", "", len(raw.ch_names), raw.info["sfreq"], np.float32, strftime("%H%M%S")
"test", "", len(raw.ch_names), raw.info["sfreq"], np.float32, uuid.uuid4().hex
)
sinfo.set_channel_info(raw.info)
info_retrieved = sinfo.get_channel_info()
Expand Down
18 changes: 9 additions & 9 deletions mne_lsl/lsl/tests/test_stream_inlet.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_pull_numerical_sample(dtype_str, dtype, close_io):
x = np.array([1, 2], dtype=dtype)
assert x.shape == (2,) and x.dtype == dtype
# create stream description
sinfo = StreamInfo("test", "", 2, 0.0, dtype_str, uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 0.0, dtype_str, uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=1)
inlet = StreamInlet(sinfo)
inlet.open_stream(timeout=5)
Expand All @@ -48,7 +48,7 @@ def test_pull_str_sample(close_io):
"""Test pull_sample with strings."""
x = ["1", "2"]
# create stream description
sinfo = StreamInfo("test", "Gaze", 2, 10.0, "string", uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "Gaze", 2, 10.0, "string", uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=1)
inlet = StreamInlet(sinfo)
inlet.open_stream(timeout=5)
Expand Down Expand Up @@ -79,7 +79,7 @@ def test_pull_numerical_chunk(dtype_str, dtype, close_io):
x = np.array([[1, 4], [2, 5], [3, 6]], dtype=dtype)
assert x.shape == (3, 2) and x.dtype == dtype
# create stream description
sinfo = StreamInfo("test", "", 2, 1.0, dtype_str, uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 1.0, dtype_str, uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=3)
inlet = StreamInlet(sinfo)
inlet.open_stream(timeout=5)
Expand Down Expand Up @@ -121,7 +121,7 @@ def test_pull_str_chunk(close_io):
"""Test pull_chunk on a string chunk."""
x = [["1", "4"], ["2", "5"], ["3", "6"]]
# create stream description
sinfo = StreamInfo("test", "", 2, 0.0, "string", uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 0.0, "string", uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=3)
inlet = StreamInlet(sinfo)
inlet.open_stream(timeout=5)
Expand Down Expand Up @@ -154,7 +154,7 @@ def test_pull_str_chunk(close_io):

def test_get_sinfo(close_io):
"""Test getting a StreamInfo from an Inlet."""
sinfo = StreamInfo("test", "", 2, 0.0, "string", uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 0.0, "string", uuid.uuid4().hex)
outlet = StreamOutlet(sinfo) # noqa: F841
inlet = StreamInlet(sinfo)
with pytest.raises(RuntimeError, match=r"StreamInlet\.open_stream"):
Expand Down Expand Up @@ -185,7 +185,7 @@ def test_inlet_methods(dtype_str, dtype, close_io):
x = np.array([[1, 4], [2, 5], [3, 6]], dtype=dtype)
assert x.shape == (3, 2) and x.dtype == dtype
# create stream description
sinfo = StreamInfo("test", "", 2, 0.0, dtype_str, uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 0.0, dtype_str, uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=3)
inlet = StreamInlet(sinfo)
inlet.open_stream(timeout=5)
Expand Down Expand Up @@ -223,7 +223,7 @@ def test_processing_flags(dtype_str, flags, close_io):
"""Test that the processing flags are working."""
x = np.array([[1, 4], [2, 5], [3, 6]])
# create stream description
sinfo = StreamInfo("test", "", 2, 0.0, dtype_str, uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 0.0, dtype_str, uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=3)
inlet = StreamInlet(sinfo, processing_flags=flags)
inlet.open_stream(timeout=5)
Expand All @@ -238,7 +238,7 @@ def test_processing_flags(dtype_str, flags, close_io):

def test_processing_flags_invalid():
"""Test the use of invalid processing flags combination."""
sinfo = StreamInfo("test", "", 2, 0.0, "float32", uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 0.0, "float32", uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=3) # noqa: F841
with pytest.raises(ValueError, match="should not be used without"):
StreamInlet(sinfo, processing_flags=("monotize",))
Expand All @@ -248,7 +248,7 @@ def test_processing_flags_invalid():

def test_time_correction(close_io):
"""Test time_correction method."""
sinfo = StreamInfo("test", "", 2, 0.0, "int8", uuid.uuid4().hex[:6])
sinfo = StreamInfo("test", "", 2, 0.0, "int8", uuid.uuid4().hex)
outlet = StreamOutlet(sinfo, chunk_size=3) # noqa: F841
inlet = StreamInlet(sinfo)
inlet.open_stream(timeout=5)
Expand Down
Loading

0 comments on commit e4e5abf

Please sign in to comment.