Synchronous (Zero-Copy) Outlet Mode#256
Conversation
a33e2a2 to
0f7bdc2
Compare
f7e32c2 to
ce58eb6
Compare
|
This is awesome, I will have to try it out! I have relatively low sample rate, but I have the additional burden of thread communication in my app, so the pointer to the data struct can be sent directly instead of a locked, reusable buffer per stream, this should reduce the overhead considerably (e.g. 1/2 to 1/4 number of ops, which actually would scale with the number of consumers in my case despite the slight increase in latency, due to it not needing to be copied for each For reference This is a general performance test I run on my Dart API wrapper. Despite the numbers being slightly higher in the new version, I wouldn't take that as indication of a performance regression, but it is a useful point of reference to make sure the performance wont be worse when I test the zero-copy mode. Liblsl.dart performance test results, liblsl v1.16.2
Liblsl.dart performance test results, liblsl v1.17.5
|
ce58eb6 to
d5ae90c
Compare
1. User creates outlet with transp_sync_blocking flag:
lsl::stream_outlet outlet(info, 0, 360, transp_sync_blocking);
2. When a consumer connects, the socket is handed off from client_session to sync_write_handler after the feed header handshake (no transfer thread is spawned).
3. When push_sample() is called:
- Timestamp is encoded and stored in sync_timestamps_
- User's data buffer pointer is wrapped in asio::const_buffer (zero copy)
- If pushthrough=true, all buffers are written to all consumers via blocking gather-write
…per consistent with stream_info and properly throws on construction failure.
- Fix have_consumers()/wait_for_consumers() to detect sync consumers - Handle DEDUCED_TIMESTAMP in sync mode for proper chunk timing - Change sync_timestamps_ to deque to prevent pointer invalidation - Add optimized enqueue_chunk_sync() for batched chunk transfers - Add have_sync_consumers() to tcp_server - Add sync outlet tests and benchmark tool
… in the namespace) 2. Added #include <algorithm> for std::sort
…ync<std::string>, which resolves the Windows linker error
2. Replaced C++17 structured bindings with .first/.second pair access for C++11/14 compatibility
Addresses review findings on the synchronous (zero-copy) outlet: #1 Memory safety: the byte-swap path grew its scratch buffer with resize() inside the loop while holding pointers into it, so a reallocation dangled earlier const_buffers and sent freed memory to byte-swapped clients. The new sync_swap_buffers() reserves the exact size up front. #2 Correctness: buffers were classified by size, so an 8-byte sample (e.g. 2x int32 or 4x int16) was mistaken for a timestamp and reversed as one double instead of per channel value. Swapping is now driven by the sample tag, not buffer size, so it is correct for any sample_bytes. #3 Portability: the per-sample tag was stored in a uint64_t and sent via its first byte, which is 0x00 on a big-endian host. It is now a uint8_t (sync_ts_entry), so the correct tag byte goes on the wire everywhere. #4 Lifetime: sync mode now flushes on every push and ignores pushthrough. The gather buffers alias the caller's memory, so deferring the write (pushthrough == false) would retain dangling pointers once the caller reuses the buffer. #5/#7 Docs: document sync mode as single-producer (the push path is unsynchronized) and note that disconnected consumers are detected lazily. Also removes the unused scratch_ member and extracts the swap into an inline, unit-testable helper (src/sync_serialization.h). New internal tests drive that helper across the 8-byte-collision and many-sample (reallocation) cases that no little-endian integration test can reach. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#221) A synchronous (transp_sync_blocking) push blocks until every consumer accepts the sample, which provides the self-regulating backpressure requested in #221 (push runs only as fast as the slowest receiver drains) but could otherwise stall the producer forever on a dead/stalled consumer. Add a per-consumer send timeout, configurable via [tuning] SyncSendTimeout (seconds). It defaults to the reconnect watchdog threshold (WatchdogTimeThreshold, 15s) so a consumer that can't keep up for as long as a stalled connection is treated like one; 0 means block forever (previous behavior). No new exported symbols -- the knob is config-only. Implementation uses SO_SNDTIMEO on each handed-off sync socket (forcing blocking mode first), so the hot path stays a plain blocking asio::write with no per-push overhead. On expiry the write returns would_block/timed_out, which reuses the existing close-and-drop-consumer path; TCP framing means we cannot resume a half-written sample, so a timed-out consumer is disconnected. runtime_config test verifies SyncSendTimeout defaults to WatchdogTimeThreshold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
d5ae90c to
e61ba87
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a new transp_sync_blocking transport option to support synchronous, blocking, zero-copy outlet sending (direct gather-write from the caller’s buffer to connected TCP sockets), plus configuration, tests, and examples to exercise the new behavior.
Changes:
- Add sync (zero-copy) outlet mode, including TCP socket handoff to a new synchronous write handler and a blocking write path.
- Add
tuning.SyncSendTimeout(defaulting to watchdog threshold) to disconnect slow sync consumers instead of stalling forever. - Add new integration/internal tests and example programs for sync mode + endian swapping.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| testing/int/sync_endian.cpp | Adds endian conversion + sync wire-layout swap tests (includes a simulation test that needs alignment-safe swapping). |
| testing/int/runtime_config.cpp | Extends runtime config test coverage for WatchdogTimeThreshold and derived SyncSendTimeout. |
| testing/ext/sync_outlet.cpp | Adds end-to-end tests for sync outlet basic sending, chunks, multiple consumers, and disconnect handling. |
| testing/CMakeLists.txt | Registers the new internal/external test sources. |
| src/tcp_server.h | Extends tcp_server API for sync mode and adds sync_write_handler ownership. |
| src/tcp_server.cpp | Implements sync_write_handler, socket handoff, and blocking write path. |
| src/sync_serialization.h | Adds sync_swap_buffers() helper for cross-endian sync gather-write swapping. |
| src/stream_outlet_impl.h | Adds sync-mode members and sync enqueue/flush helpers. |
| src/stream_outlet_impl.cpp | Implements the sync zero-copy push path and sync-mode consumer detection/waiting. |
| src/sample.h | Adds factory::datasize() helper used by sync path. |
| src/api_config.h | Adds sync_send_timeout() getter and member. |
| src/api_config.cpp | Loads tuning.SyncSendTimeout defaulting to watchdog threshold. |
| include/lsl/common.h | Introduces transp_sync_blocking flag and documents its semantics/limitations. |
| include/lsl_cpp.h | Makes lsl::stream_outlet throw if creation fails (avoids null handle use). |
| examples/SendDataSyncBlocking.cpp | Adds a simple sync-outlet example. |
| examples/CMakeLists.txt | Builds the new examples. |
| examples/BenchmarkSyncVsAsync.cpp | Adds a benchmark tool comparing sync vs async outlet performance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address Copilot review on PR #256: - sync_swap_buffers(): byte-swap the gather-write buffers in place with byte operations instead of sample::convert_endian(). The scratch store is a std::vector<char> with values packed at arbitrary offsets, so the typed dereferences in convert_endian() can be unaligned -- UB on strict-alignment targets. Swapping bytes directly avoids that; same wire output, async path untouched. - tcp_server.cpp: include <algorithm> for std::remove_if (was relying on a transitive include). - sync_endian.cpp: make the swap simulation test alignment-safe the same way and read values back via memcpy into aligned locals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
One genuine behaviour change for all C++ users (not async-specific):
Net: the async data path is preserved exactly; the only universal change is turning a latent null-handle crash into a thrown exception at construction. |
Expose the lsl_transport_options_t flags (transp_default, transp_bufsize_samples, transp_bufsize_thousandths, transp_sync_blocking) and a transport_flags argument on StreamOutlet, routed through lsl_create_outlet_ex. transp_sync_blocking enables the new synchronous (zero-copy) outlet mode (sccn/liblsl#256) for high-bandwidth streams. Default behavior is unchanged (async via lsl_create_outlet).
This PR adds a new transp_sync_blocking transport flag that enables synchronous, zero-copy data transfer for stream outlets. Instead of copying sample data into an internal buffer for async delivery, sync mode writes directly from the user's buffer to connected sockets, eliminating memory allocation and copy overhead.
It is intended to be a replacement for #170 , which has not been updated in some time.
Motivation
For high-channel-count, high-sample-rate applications (e.g., 1000+ channels at 30kHz), the async outlet's per-sample memory allocation and copying becomes a significant CPU bottleneck. Sync mode addresses this by:
This makes all the difference for me on a lower power embedded system
Usage
Limitations
Benchmark Results
Test configuration: 1000 channels, 30kHz sample rate, macOS (Apple Silicon)
CPU Usage by Chunk Size (1 consumer)
Scaling with Multiple Consumers (chunk=4)
CPU savings remain significant (~50%) across consumer counts. However, push latency increases linearly with consumers in sync mode (async latency stays constant).
Implementation Details