Skip to content

(improvement) perf: remove copies on the read path (us improvements - x1.5-3.7 speedup!) - #734

Draft
mykaul wants to merge 5 commits into
scylladb:masterfrom
mykaul:remove_copies
Draft

(improvement) perf: remove copies on the read path (us improvements - x1.5-3.7 speedup!)#734
mykaul wants to merge 5 commits into
scylladb:masterfrom
mykaul:remove_copies

Conversation

@mykaul

@mykaul mykaul commented Mar 9, 2026

Copy link
Copy Markdown

This patch set aims to reduce the memory copies we perform in the read path, to improve overall performance - mainly reduce latency on the processing side of the driver receiving the payload.
This is more effective on larger payloads of course.

Comparison to master (Cython path, 10 iterations, CPU pinned, all times in microseconds):

Scenario Body Size master (median) remove_copies (median) Speedup
small_100 7.3 KB 273.8 us 75.0 us 3.7x
medium_1k_256B 273.5 KB 1906.7 us 536.4 us 3.6x
medium_1k_1KB 1.0 MB 6166.6 us 1740.1 us 3.5x
large_5k_1KB 5.0 MB 24822.5 us 9482.1 us 2.6x
large_1k_4KB 3.9 MB 4575.3 us 2346.7 us 1.9x
wide_5k_doubles 586.1 KB 13539.2 us 7163.0 us 1.9x
wide_1k_20cols 762.0 KB 5858.3 us 3831.4 us 1.5x
blob_1k_16KB 15.6 MB 62233.0 us 34668.1 us 1.8x

(note - we can see that wide_5k_doubles is bottlenecked on something else - CPU processing of this payload - unpacking it. This may be optimized in a different PR)

  • I have split my patch into logically separate commits.
  • All commit messages clearly explain what they change and why.
  • I added relevant tests for new features and bug fixes.
  • All commits compile, pass static checks and pass test.
  • PR description sums up the changes and reasons why they should be introduced.
  • I have provided docstrings for the public items that I want to introduce.
  • I have adjusted the documentation in ./docs/source/.
  • I added appropriate Fixes: annotations to PR description.

@mykaul
mykaul marked this pull request as draft March 9, 2026 07:50
@mykaul

mykaul commented Mar 9, 2026

Copy link
Copy Markdown
Author

#630 would have helped with wide_5k_doubles of course.

@mykaul mykaul changed the title Remove copies (improvement) perf: remove copies on the read path Mar 13, 2026
@mykaul
mykaul force-pushed the remove_copies branch 6 times, most recently from 3e11f41 to 213c0e8 Compare April 5, 2026 13:39
@mykaul mykaul changed the title (improvement) perf: remove copies on the read path (improvement) perf: remove copies on the read path (us improvements - x1.5-3.7 speedup) Apr 7, 2026
@mykaul mykaul changed the title (improvement) perf: remove copies on the read path (us improvements - x1.5-3.7 speedup) (improvement) perf: remove copies on the read path (us improvements - x1.5-3.7 speedup!) Apr 7, 2026
Copilot AI review requested due to automatic review settings July 29, 2026 20:28
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1bf29132-df48-4877-82a8-81cf7b8d6359

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@mykaul

mykaul commented Jul 29, 2026

Copy link
Copy Markdown
Author

Rebased onto current master (was ~89 commits behind, including the new SCYLLA_USE_METADATA_ID extension work) and force-pushed. Mechanical conflicts were in tests/unit/test_connection.py and tests/unit/test_protocol.py (import lists and appended test classes); resolved by taking the union of both sides.

Since this PR's whole point is removing copies on the hot read path, I went through every copy elimination looking specifically for use-after-free/aliasing risk (a stale reference outliving the buffer it points into, or a reference to something that later gets mutated in place):

  • connection.py _read_frame_header / process_io_buffer: now use getbuffer() memoryview slices instead of .read(), but always explicitly del the memoryview before any seek()/reset on the same BytesIO, and the frame body handed to process_msg is still built via bytes(buf[...]) — a real, independent copy. So everything downstream (BytesReader, the Cython row parser) is working from a standalone, immutable buffer, not a view into the connection's rotating I/O buffer.
  • row_parser.pyx zero-copy handoff (f.remaining_buffer()BytesIOReader(buf_data, buf_offset)): BytesIOReader.buf is a cdef bytes field, so Cython keeps a real refcounted reference to the underlying bytes for the reader's lifetime — no dangling pointer even after the outer BytesReader/frame body goes out of scope elsewhere. The exception fallback path also correctly re-derives buf_ptr from the new _initial_offset field (<char*>reader.buf + reader._initial_offset) instead of the old absolute-start reset, which is necessary now that reader.buf is the whole frame body rather than just the row-data suffix.
  • Verified empirically too: wrote a throwaway script that runs rows through the real Cython ProtocolHandler (list) and LazyProtocolHandler (generator) paths, drops the original frame bytes object, churns the allocator with a few thousand unrelated buffers + gc.collect(), and confirms row values are still exactly correct. Also forced the except/fallback path (invalid UTF-8 mid-stream) and confirmed it reports the correct column instead of misreading from the wrong offset.

One real gap I did close (amended into Add BytesReader to replace BytesIO in decode_message): BytesReader.__init__ only special-cased memoryview for copying, so a hypothetical caller passing a mutable bytearray would have been aliased rather than copied. No current call site does this (decompressors return bytes, and the sole production caller always builds body via bytes(...)), so it wasn't reachable today, but given the buffer-lifetime theme of this PR it seemed worth closing defensively — it now copies anything that isn't already a plain immutable bytes.

CI: all required checks (Integration tests on libev/asyncio/asyncore × Python 3.11–3.14t, Docs build, security/snyk) are green on the latest push. "Test wheels building" shows red, but it's a startup_failure with zero jobs executed (not even a real check run — it's absent from the PR's merge-blocking status rollup), consistent with an external workflow-dispatch issue rather than anything caused by this change.

Ran the full tests/unit/ suite locally after rebuilding the Cython extensions: 799 passed, 38 skipped (pre-existing, environment-gated), 0 failed.

No open review threads to resolve. Still a draft as requested.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR reduces memory copying along the driver read/decode path to improve latency and throughput, especially for large payloads, by replacing io.BytesIO-based reads with lighter and more zero-copy-friendly primitives.

Changes:

  • Introduces BytesReader and wires it into ProtocolHandler.decode_message() to avoid io.BytesIO(body) copying.
  • Adds zero-copy handoff support to the Cython row parser via BytesIOReader(..., offset) and remaining_buffer().
  • Optimizes connection buffering/reset and adds unit tests plus an isolated decode benchmark script.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
cassandra/protocol.py Adds BytesReader and switches decode to use it instead of io.BytesIO.
cassandra/row_parser.pyx Avoids f.read() copy by using (buffer, offset) handoff into BytesIOReader.
cassandra/bytesio.pyx / cassandra/bytesio.pxd Adds offset support to BytesIOReader for suffix reads without copying.
cassandra/connection.py Reduces copies by using getbuffer(), adds _reset_buffer, and bumps recv buffer size.
tests/unit/test_protocol.py Adds unit tests for BytesReader and end-to-end decode_message coverage.
tests/unit/test_connection.py Adds tests for _ConnectionIOBuffer._reset_buffer.
tests/unit/test_bytesio_reader.py Adds Cython-only tests for BytesIOReader offset construction/validation.
benchmarks/decode_benchmark.py Adds a standalone benchmark to measure decode performance across scenarios.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cassandra/connection.py
Comment thread cassandra/connection.py
Comment thread cassandra/connection.py
Comment thread cassandra/protocol.py
Comment thread cassandra/protocol.py
Comment thread cassandra/protocol.py Outdated
mykaul added 5 commits July 31, 2026 21:02
Replace getvalue() with getbuffer() memoryview in _read_frame_header
and frame body extraction to avoid full-buffer copies. Add _reset_buffer()
helper using getbuffer()[pos:] instead of read() to reduce allocations.
Wrap memoryview usage in try/finally to ensure release before mutation.
Increase in_buffer_size from 4096 to 16384 to reduce recv() call overhead.
Introduce a lightweight BytesReader class that operates directly on
bytes data without BytesIO overhead. Materializes memoryview to bytes
once in __init__ instead of checking on every read(). Includes
remaining_buffer() method for zero-copy handoff to Cython parsers.
Add offset parameter to BytesIOReader so it can start reading from the
middle of an existing buffer, avoiding the full-body copy at the
Python-to-Cython boundary. Update row_parser.pyx to use
f.remaining_buffer() for zero-copy handoff with hasattr fallback.
Track _initial_offset for error recovery.
13 BytesReader tests covering read operations, remaining_buffer(),
memoryview materialization, empty data, and EOFError handling.
9 BytesIOReader tests covering offset initialization, boundary
conditions, read behavior with offset, and error cases.
…pact

Standalone benchmark (no cluster required) that constructs synthetic
RESULT/ROWS wire-format bodies and measures ProtocolHandler.decode_message()
throughput across 8 scenarios (small to 16MB, narrow to 20-column wide).

Pins to a single CPU core via sched_setaffinity for consistent results.
Supports both Cython and pure-Python paths, with --cprofile option.
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.

2 participants