Repository navigation
Conversation
The generated BidiReadObject stub parses every response with proto-plus over upb, which copies each data chunk twice on the event-loop thread before it reaches the caller's buffer. Add a wire-format parser for plain data responses that exposes `checksummed_data.content` as a memoryview of the received message, and let `_AsyncReadObjectStream` use a stub built with it (wrapped like the generated method: error mapping, no default retry or timeout, same client-info header). Responses carrying object metadata, or anything that does not parse cleanly, are handed to the generated parser, so callers keep seeing the real message or its usual DecodeError. CRC32C verification in the read path is unchanged: the memoryview flows through google_crc32c.value(), the user buffer write and the rolling checksum. Because google-crc32c releases up to 1.9.0 only accept bytes there, the fast path is used only when the installed google-crc32c can checksum a memoryview; GOOGLE_CLOUD_STORAGE_FAST_BIDI_READ=0 forces the generated parser as well.
…sages and invalid tags The zero-copy BidiReadObjectResponse parser is meant to be indistinguishable from the generated one apart from the type of ``content``; a comparison against upb found a few places where it was not: - ReadRange.read_offset/read_length/read_id are int64, so a ten-byte two's-complement varint must come back negative, and bits above 64 in an over-long varint are dropped rather than kept. - A singular embedded message that occurs twice in its parent is merged; the sub-message parsers now fill in the existing object, which also removes two throw-away allocations per ObjectRangeData. - Field number 0, tags above 32 bits and tags encoded in more than five bytes are malformed and now reach the generated parser's DecodeError. Tests cover each case against the generated parser.
… GOOGLE_CLOUD_STORAGE_ZERO_COPY_BIDI_READ=1
…stants Explain the opt-in switch's accepted spellings, the four protobuf wire types, the 64-bit varint mask and the 32-bit tag limit.
Until now the only way to opt a downloader into the zero-copy
BidiReadObjectResponse parser was the GOOGLE_CLOUD_STORAGE_ZERO_COPY_BIDI_READ
environment variable, which _AsyncReadObjectStream re-reads on every open.
A library such as gcsfs that wants memoryview chunks therefore had to set a
process-wide variable, which also switches every other downloader in the
process (and in its children) to memoryview payloads on their next reopen.
create_mrd() and the constructor now take zero_copy: True asks for the parser,
False keeps the generated one, None (the default) defers to the environment
variable as before. The flag is passed to every stream the downloader opens,
including the ones created on resumption. _zero_copy_bidi_read.is_supported()
is now only the google-crc32c memoryview probe; the new is_enabled(zero_copy)
combines it with the argument or the environment, and wrapped_rpc() takes the
flag.
download_ranges() documents what a buffer receives: bytes, or with zero_copy
a read-only memoryview of the received message, which is immutable and stays
alive for as long as the view does, so a buffer may keep the view and copy
from it after write() returns.
Test plan: from packages/google-cloud-storage, with the upb and the pure-Python
protobuf backends:
PYTHONPATH=$PWD python -m pytest tests/unit/asyncio/test_zero_copy_bidi_read.py \
tests/unit/asyncio/test_async_read_object_stream.py \
tests/unit/asyncio/test_async_multi_range_downloader.py
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python PYTHONPATH=$PWD python -m pytest \
tests/unit/asyncio/test_zero_copy_bidi_read.py
102 and 55 tests pass; new tests cover is_enabled() precedence (argument over
environment, both gated by is_supported()), wrapped_rpc() with an explicit
flag, and the flag reaching _AsyncReadObjectStream from create_mrd().
Authored with an AI assistant.
…he zero-copy parser
wrapped_rpc built its BidiReadObject callable with DEFAULT_CLIENT_INFO, while
the generated method uses the ClientInfo the transport was created with. A
caller passing its own ClientInfo therefore sent a different x-goog-api-client
header on BidiReadObject only. AsyncGrpcClient now remembers the ClientInfo it
configures, AsyncMultiRangeDownloader hands it to each stream, and wrapped_rpc
uses it (the transport default when none is given).
deserialize() aliases chunk payloads to the buffer gRPC hands it, which is only
safe for an immutable bytes object. Anything else now goes to the generated
parser instead of being aliased.
Test plan: from packages/google-cloud-storage, upb and pure-Python protobuf:
PYTHONPATH=$PWD python -m pytest tests/unit/asyncio/test_zero_copy_bidi_read.py \
tests/unit/asyncio/test_async_read_object_stream.py \
tests/unit/asyncio/test_async_multi_range_downloader.py \
tests/unit/asyncio/test_async_grpc_client.py
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python PYTHONPATH=$PWD python -m pytest \
tests/unit/asyncio/test_zero_copy_bidi_read.py
126 and 57 pass. The fake-server test now runs both parser paths with a custom
ClientInfo and asserts its user agent and gapic version reach the server; a
new test feeds bytearray and memoryview buffers to deserialize().
Authored with an AI assistant.
There was a problem hiding this comment.
Code Review
This pull request introduces a zero-copy deserializer for BidiReadObjectResponse messages in _zero_copy_bidi_read.py to optimize performance by avoiding extra data copies on the event-loop thread. It integrates this parser into the async read path (async_multi_range_downloader.py and async_read_object_stream.py) with a configurable zero_copy option, and adds comprehensive unit tests. Feedback on the changes highlights a potential IndexError in the _varint decoding loop when parsing truncated inputs, suggesting a robust bounds check at the start of each iteration.
|
/gcbrun |
…e buffer destinations
… write() destinations download_ranges(accepts_memoryview=True) passes each chunk to a write() destination as the read-only memoryview the zero-copy parser produced instead of materializing bytes first. The default stays False, so existing callers keep receiving bytes. Buffer destinations are unaffected. Also accept a plain google.api_core.client_info.ClientInfo in wrapped_rpc: AsyncGrpcClient takes one (gcsfs passes it), but gapic_v1's wrap_method needs the gapic_v1 subclass for its x-goog-api-client header, so the zero-copy stream failed to open with AttributeError. Such an object now falls back to the transport default, which is the header main sends.
When a read ends on a chunk boundary the server's last message carries range_end and a crc32c but no content field, so the zero-copy parser left _ChecksummedData.content at its memoryview(b"") default even in the mode that slices bytes for google-crc32c releases that cannot hash views. The inline checksum then raised TypeError on stock google-crc32c 1.9.0, which is what failed the zonal system tests (test_basic_wrd for 10 and 20 MiB objects) since the parser became the default. Empty content is now b"" in both modes, as the generated parser returns it.
zhixiangli
left a comment
There was a problem hiding this comment.
Thanks for this. The throughput gains are compelling and the parity tests against the generated parser are thorough. Inline comments below; two PR-level points:
1. The title and description are out of date relative to the code (since d9e019e and a297106):
- Default on/off: the title says "opt-in" and the description says "Off by default", but
is_enabled()turns the parser on by default. - Env var: the description documents
GOOGLE_CLOUD_STORAGE_ZERO_COPY_BIDI_READ(1enables it). The code readsGCS_PYTHON_CLIENT_ZERO_COPY_BIDI_READ(0/false/no/offdisables it). zero_copy=was removed but is still accepted:AsyncMultiRangeDownloader.__init__swallows**kwargs, soAsyncMultiRangeDownloader(client, "b", "o", zero_copy=False)runs without error and has no effect. Consider rejecting unknown kwargs, or at least warning onzero_copy.- Old
wrapped_rpcsignature: the description showswrapped_rpc(transport, zero_copy, client_info); the code takes(transport, client_info). - Missing from the description:
accepts_memoryviewand the writable buffer destinations.write()now receives amemoryviewonly withaccepts_memoryview=True.
2. On-by-default changes what callers get back. _AsyncReadObjectStream.recv() and DataCorruption.response now return _ZeroCopyBidiReadObjectResponse instead of the proto-plus message for every async reader. If that's intended, it would be good to call it out in the description and release notes, together with the env-var kill switch.
| def write(self, data): | ||
| n = len(data) | ||
| end = self._pos + n | ||
| self._view[self._pos : end] = data | ||
| self._pos = end | ||
| return n |
There was a problem hiding this comment.
High: server overflow into a buffer destination writes past bytes_to_read, or raises the wrong error.
_ReadResumptionStrategy.update_state_from_response calls user_buffer.write(data) before the byte-count check, and only checks on range_end. That was harmless for BytesIO, but _BufferWriter writes into the caller's memory, and self._view covers the whole buffer rather than [:length]. With length=10 and a 12-byte chunk:
bytearray(10):ValueError: memoryview assignment: lvalue and rvalue have different structuresinstead ofDataCorruption.download_rangesonly closes the stream onDataCorruption, so it stays open.bytearray(b"B" * 20): becomesb"AAAAAAAAAAAABBBBBBBB"(bytes 10–11 overwritten).DataCorruptionis raised only atrange_end; a non-final chunk raises nothing.
Suggested fix: in reads_resumption_strategy.py, check before writing:
chunk_size = len(data)
if (
read_state.initial_length != 0
and read_state.bytes_written + chunk_size > read_state.initial_length
):
raise DataCorruption(
response,
f"Byte count mismatch for read_id {read_id}. "
f"Expected {read_state.initial_length}, got {read_state.bytes_written + chunk_size}",
)
read_state.user_buffer.write(data)Also limit the view to [:length] as a second guard (see the next comment).
Test: a 12-byte response for (0, 10, bytearray(10)) and for (0, 10, bytearray(b"B" * 20)) should raise DataCorruption, close the stream, and leave buf[10:] == b"B" * 10.
| view = memoryview(buffer) | ||
| if view.readonly or not view.c_contiguous: | ||
| view.release() | ||
| raise ValueError( | ||
| "Buffer destination must be a writable, C-contiguous buffer" | ||
| ) | ||
| nbytes = view.nbytes | ||
| if nbytes < length: | ||
| view.release() | ||
| raise ValueError( | ||
| f"Buffer destination ({nbytes} bytes) is smaller than " | ||
| f"bytes_to_read ({length})" | ||
| ) | ||
| self._view = view.cast("B") | ||
| self._pos = 0 |
There was a problem hiding this comment.
The view on the caller's buffer is never released.
Neither view nor self._view is ever .release()d. When download_ranges raises, the traceback keeps its frame (download_states -> _BufferWriter -> _view) alive, so the caller can't resize their buffer while handling the error:
buf = bytearray(4)
try:
await mrd.download_ranges([(0, 4, buf), (0, 0, bytearray(4))]) # 2nd range invalid
except ValueError:
buf.clear() # BufferError: Existing exports of data: object cannot be re-sizedThe same thing happens inside except DataCorruption:.
| view = memoryview(buffer) | |
| if view.readonly or not view.c_contiguous: | |
| view.release() | |
| raise ValueError( | |
| "Buffer destination must be a writable, C-contiguous buffer" | |
| ) | |
| nbytes = view.nbytes | |
| if nbytes < length: | |
| view.release() | |
| raise ValueError( | |
| f"Buffer destination ({nbytes} bytes) is smaller than " | |
| f"bytes_to_read ({length})" | |
| ) | |
| self._view = view.cast("B") | |
| self._pos = 0 | |
| view = memoryview(buffer) | |
| try: | |
| if view.readonly or not view.c_contiguous: | |
| raise ValueError( | |
| "Buffer destination must be a writable, C-contiguous buffer" | |
| ) | |
| nbytes = view.nbytes | |
| if nbytes < length: | |
| raise ValueError( | |
| f"Buffer destination ({nbytes} bytes) is smaller than " | |
| f"bytes_to_read ({length})" | |
| ) | |
| self._view = view.cast("B")[:length] | |
| finally: | |
| view.release() | |
| self._pos = 0 |
Then add def close(self): self._view.release(). In download_ranges, move the read_ranges loop inside the existing try: and close every _BufferWriter in its finally:.
Test: buf.clear() succeeds inside except ValueError: and inside except DataCorruption:.
| if not hasattr(client_info, "to_grpc_metadata"): | ||
| client_info = DEFAULT_CLIENT_INFO | ||
| rpc = gapic_v1.method_async.wrap_method( | ||
| stub, default_timeout=None, client_info=client_info | ||
| ) |
There was a problem hiding this comment.
The x-goog-api-client header now depends on the zero-copy setting.
AsyncGrpcClient builds the transport as transport_cls(channel=channel) without client_info, so the generated _wrapped_methods entry (and every other RPC on the client) sends the default header, while this path sends the caller's header. Measured:
client_info passed to AsyncGrpcClient |
zero-copy off | zero-copy on |
|---|---|---|
api_core.client_info.ClientInfo("gcsfs/1") |
gl-python/… gax/… gapic/3.16.0 pb/7.36.2 |
same |
gapic_v1.client_info.ClientInfo("gcsfs/1") |
gl-python/… gax/… gapic/3.16.0 pb/7.36.2 |
gcsfs/1 gcloud-python/3.16.0 … gccl/3.16.0 |
Two related points:
- Cache ignores
client_info:_rpcsis keyed by transport only, sowrapped_rpc(t, None)followed bywrapped_rpc(t, other)returns the first callable and ignoresother. - No
kind=: this callsgapic_v1.method_async.wrap_methoddirectly, whereas the transport's_wrap_methodalso passeskind=when api-core supports it.
Suggestion: pass the client info to the transport in AsyncGrpcClient (converting an api_core ClientInfo to gapic_v1.client_info.ClientInfo), and build this callable with transport._wrap_method(...) using the transport's own client info. Both parsers then send the same metadata, and the cache key and the client_info= plumbing go away.
Test: an end-to-end test parametrized over zero-copy on/off that asserts the same x-goog-api-client value reaches the server.
| def _length_prefixed(buf, i, end): | ||
| """Return ``(start, stop)`` of the length-delimited field body at ``i``.""" | ||
| n, i = _varint(buf, i, end) | ||
| stop = i + n | ||
| if stop > end: | ||
| raise _Malformed("field runs past its enclosing message") | ||
| return i, stop |
There was a problem hiding this comment.
Length prefixes longer than 5 bytes are accepted, unlike the generated parser.
_tag rejects tags longer than 5 bytes, but _length_prefixed uses _varint's 10-byte, low-64-bit result unchecked. No exception is raised, so the fallback to the generated parser never runs:
| input | generated (upb) | zero-copy |
|---|---|---|
b"\x32\x80\x80\x80\x80\x80\x00" (6-byte length 0) |
DecodeError |
parses |
b"\x32" + b"\x80"*9 + b"\x02" (length 2**64, masked to 0) |
DecodeError |
parses |
b"\x32" + b"\x80"*9 + b"\x00" (10-byte length 0) |
DecodeError |
parses |
| def _length_prefixed(buf, i, end): | |
| """Return ``(start, stop)`` of the length-delimited field body at ``i``.""" | |
| n, i = _varint(buf, i, end) | |
| stop = i + n | |
| if stop > end: | |
| raise _Malformed("field runs past its enclosing message") | |
| return i, stop | |
| def _length_prefixed(buf, i, end): | |
| """Return ``(start, stop)`` of the length-delimited field body at ``i``. | |
| Like upb, the length must be encoded in at most five bytes. | |
| """ | |
| start = i | |
| n, i = _varint(buf, i, end) | |
| if i - start > 5: | |
| raise _Malformed("field length longer than 5 bytes") | |
| stop = i + n | |
| if stop > end: | |
| raise _Malformed("field runs past its enclosing message") | |
| return i, stop |
Test: add the three inputs to test_deserialize_malformed_input_raises_like_generated_parser.
| def is_supported(): | ||
| """Whether the installed ``google_crc32c`` can hash ``memoryview`` inputs. | ||
|
|
||
| When true, :func:`deserialize` exposes chunk payloads as ``memoryview`` | ||
| slices of the gRPC wire buffer; when false, it slices ``bytes`` instead so | ||
| checksum verification remains compatible with older ``google_crc32c`` | ||
| releases while still avoiding the intermediate upb arena copy. | ||
| """ | ||
| try: | ||
| google_crc32c.value(memoryview(b"\0")) | ||
| except Exception: | ||
| return False | ||
| return True |
There was a problem hiding this comment.
Also probe Checksum.update, which whole-object reads use.
Whole-object reads ((0, 0, …) / (0, persisted_size, …)) also call rolling_checksum.update(data), but the probe only checks value. The companion crc32c change updates both entry points together, so real installs agree. The test fixtures here, however, patch only google_crc32c.value, so a whole-object read through this path fails with TypeError: argument 2 must be read-only bytes-like object, not memoryview. The new MRD tests don't catch this because _TEST_OBJECT_SIZE is 1 MiB and they read 18 bytes, so is_full_object_read is always False.
| def is_supported(): | |
| """Whether the installed ``google_crc32c`` can hash ``memoryview`` inputs. | |
| When true, :func:`deserialize` exposes chunk payloads as ``memoryview`` | |
| slices of the gRPC wire buffer; when false, it slices ``bytes`` instead so | |
| checksum verification remains compatible with older ``google_crc32c`` | |
| releases while still avoiding the intermediate upb arena copy. | |
| """ | |
| try: | |
| google_crc32c.value(memoryview(b"\0")) | |
| except Exception: | |
| return False | |
| return True | |
| def is_supported(): | |
| """Whether the installed ``google_crc32c`` can hash ``memoryview`` inputs. | |
| When true, :func:`deserialize` exposes chunk payloads as ``memoryview`` | |
| slices of the gRPC wire buffer; when false, it slices ``bytes`` instead so | |
| checksum verification remains compatible with older ``google_crc32c`` | |
| releases while still avoiding the intermediate upb arena copy. Both | |
| ``value`` and ``Checksum.update`` (used for full-object reads) must | |
| accept a ``memoryview``. | |
| """ | |
| probe = memoryview(b"\0") | |
| try: | |
| google_crc32c.value(probe) | |
| google_crc32c.Checksum().update(probe) | |
| except Exception: | |
| return False | |
| return True |
With this change, the tests that patch only google_crc32c.value (the autouse fixture in test_zero_copy_bidi_read.py and the two new MRD tests) need to patch Checksum.update too. With that, all tests in the three files pass. Please also add (0, 0, sink) and (0, persisted_size, bytearray(persisted_size)) cases with enable_checksum=True.
| :type accepts_memoryview: bool | ||
| :param accepts_memoryview: (Optional) If True, ``write()`` destinations | ||
| may receive each chunk as a read-only :class:`memoryview` of the | ||
| received gRPC message instead of ``bytes``. The SDK drops its | ||
| reference after ``write()`` returns; the message (the chunk plus | ||
| a few bytes of framing) is freed when the destination drops the | ||
| view. Destinations must still accept ``bytes``, which the generated | ||
| parser produces. No effect on buffer destinations. Defaults to | ||
| False. |
There was a problem hiding this comment.
Docstring nit: "The SDK drops its reference after write() returns" isn't quite accurate. The parsed response still holds checksummed_data.content, and _StreamMultiplexer._recv_loop / _BidiStreamRetryManager.execute keep the latest response alive while awaiting the next message. Memory use is the same as with the generated parser, so a wording fix is enough:
| :type accepts_memoryview: bool | |
| :param accepts_memoryview: (Optional) If True, ``write()`` destinations | |
| may receive each chunk as a read-only :class:`memoryview` of the | |
| received gRPC message instead of ``bytes``. The SDK drops its | |
| reference after ``write()`` returns; the message (the chunk plus | |
| a few bytes of framing) is freed when the destination drops the | |
| view. Destinations must still accept ``bytes``, which the generated | |
| parser produces. No effect on buffer destinations. Defaults to | |
| False. | |
| :type accepts_memoryview: bool | |
| :param accepts_memoryview: (Optional) If True, ``write()`` destinations | |
| may receive each chunk as a read-only :class:`memoryview` of the | |
| received gRPC message instead of ``bytes``. The view keeps that | |
| message (the chunk plus a few bytes of framing) alive while the | |
| destination or the SDK's most recently processed response | |
| references it; use ``bytes(data)`` to keep the data without the | |
| message. Destinations must still accept ``bytes``, which the | |
| generated parser produces. No effect on buffer destinations. | |
| Defaults to False. |
| If ``writeable_buffer`` has a ``write()`` method (e.g. | ||
| :class:`io.BytesIO`, an open file, or a custom stream), each | ||
| chunk is passed to ``writeable_buffer.write()`` as ``bytes`` | ||
| (or as a ``memoryview`` when ``accepts_memoryview`` is True). | ||
| Otherwise, ``writeable_buffer`` must be a writable, C-contiguous | ||
| buffer supporting the buffer protocol (e.g. :class:`bytearray` | ||
| or a writable :class:`memoryview` slice) of at least | ||
| ``bytes_to_read`` bytes (with ``bytes_to_read > 0``), and the SDK | ||
| copies each chunk directly into it in place from the gRPC | ||
| response buffer without an intermediate ``bytes`` allocation. |
There was a problem hiding this comment.
nit: the signature still says read_ranges: List[Tuple[int, int, BytesIO]] (and :type read_ranges: says "BytesIO"), so type checkers will reject the bytearray / memoryview destinations documented here. Suggest Union[IO[bytes], bytearray, memoryview] for the third element, and lock: Optional[asyncio.Lock] = None. _AsyncReadObjectStream.__init__ also gained client_info=None without a type hint or :param: entry; that may become moot with the client-info comment.
| class _ChecksummedData: | ||
| __slots__ = ("content", "crc32c", "_has_crc32c") | ||
|
|
||
| def __init__(self): | ||
| # An absent content field is b"" like the generated parser; the last | ||
| # message of a range that ends on a chunk boundary looks like that. | ||
| self.content = b"" | ||
| self.crc32c = 0 | ||
| self._has_crc32c = False | ||
|
|
||
| def HasField(self, name): | ||
| if name == "crc32c": | ||
| return self._has_crc32c | ||
| raise ValueError(f"Unknown field {name!r}") |
There was a problem hiding this comment.
nit: _ZeroCopyBidiReadObjectResponse has _pb -> self, but _ObjectRangeData and _ChecksummedData don't. With the generated parser, response.object_data_ranges[0]._pb works; here it raises AttributeError. Similarly, checksummed_data._pb.HasField("_crc32c") (the proto3 optional synthetic oneof) returns a bool on the real message but raises ValueError here. Nothing in this repo relies on either, but these objects are exposed via recv() and DataCorruption.response.
| "google.cloud.storage.asyncio.async_multi_range_downloader._AsyncReadObjectStream" | ||
| ) | ||
| @pytest.mark.asyncio | ||
| async def test_download_ranges_writes_into_writable_buffer( |
There was a problem hiding this comment.
Test gaps for buffer destinations. Resuming into a buffer after a retry works today (the retry asks for offset 10, length 8, and the buffer ends up correct), but nothing locks it in:
- Retry into a buffer: the first stream writes 10 of 18 bytes into
bytearray(18)and then raisesexceptions.Aborted; the second stream sends the rest atread_offset=10. Assertbytes(raw) == dataand that the second_AsyncReadObjectStream(...)call (from_create_stream_factory) receivedclient_info=mock_client._client_info. Today only theopen()call is asserted. - Checksum mismatch: a CRC mismatch on a zero-copy response into a
bytearrayraisesDataCorruption, leaves the buffer untouched, and closes the stream.
Summary
An opt-in parser for
BidiReadObjectResponsethat reads the message in place over thebytesobject gRPC hands to the deserializer, so_AsyncReadObjectStreampasses each chunk'schecksummed_data.contentto the read path as amemoryviewof the received message instead of the two copies the generated parser makes on the event-loop thread (the upb parse, then.contentinto a newbytes; 257 us per 2 MiB message, 5 us with this parser). Off by default. Requires agoogle-crc32crelease that acceptsmemoryview(#18626); with an older release the generated parser is used automatically.Changes
asyncio/_zero_copy_bidi_read.py(new):deserialize(buf)walks the wire format of data responses (object_data_ranges,read_handle) overmemoryview(buf)and returns stand-ins with the attributes the read path uses. A response carryingmetadata, bytes that do not parse cleanly, or a buffer that is not an immutablebytesobject is handed toBidiReadObjectResponse.deserialize, which returns the real message or raises its usualDecodeError. Varint, int64 and tag handling match upb.AsyncMultiRangeDownloader.create_mrd(..., zero_copy=None).Trueasks for the parser,Falsekeeps the generated one,None(default) defers toGOOGLE_CLOUD_STORAGE_ZERO_COPY_BIDI_READ(1/true/yes/on). Either way the parser is used only when the installedgoogle-crc32ccan hash amemoryview(is_supported()probe). The flag reaches every stream the downloader opens, including on resumption.wrapped_rpc(transport, zero_copy, client_info): the transport'sBidiReadObjectstub with the deserializer swapped in, wrapped like the generated_wrapped_methodsentry (same error mapping andx-goog-api-clientheader, built from the client'sClientInfo), cached per transport.download_rangescontract: withzero_copyon,writeable_buffer.write()receives a read-onlymemoryviewof the received message, which is immutable and stays alive as long as the view does; a buffer may keep the view and copy from it afterwrite()returns, and a buffer that needsbytesmust callbytes(data).io.BytesIOand binary files work unchanged.Tests
tests/unit/asyncio/test_zero_copy_bidi_read.py: field-by-field comparison with the generated parser on encoder-built and proto-plus-serialized messages (including unknown fields of every wire type at each nesting level), 16 malformed inputs on which both parsers raiseDecodeError, fallback cases (metadata, non-bytesbuffers),is_enabled()precedence (argument over environment, both gated by the probe),wrapped_rpccaching, and an in-processgrpc.aioserver driving_AsyncReadObjectStreamwith the parser on and off and a customClientInfo(user agent and gapic version reach the server).test_async_multi_range_downloader.py/test_async_read_object_stream.pycover the argument plumbing. 100% line and branch coverage of the new module, on the upb and the pure-Python protobuf backends.Benchmark
c4-standard-192(us-central1-c), Rapid bucket in the same zone, 16 concurrent streams per process, 4 x 16 MiB perdownload_rangescall into preallocated buffers, CRC32C verification on,google-crc32cfrom the companion PR; median of 3 interleaved runs, aggregate GB/s:main