Skip to content

perf(storage): verify CRC32C of large read chunks off the event-loop thread - #18628

Open
yuxin00j wants to merge 9 commits into
googleapis:mainfrom
yuxin00j:storage-offloop-crc32c
Open

yuxin00j wants to merge 9 commits into
googleapis:mainfrom
yuxin00j:storage-offloop-crc32c

Conversation

@yuxin00j

@yuxin00j yuxin00j commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Hash read chunks of 1 MiB or more on a small worker pool instead of the asyncio event-loop thread, and write each chunk to its buffer only after its checksum matches. On main, google_crc32c.value() is 10-12% of the event-loop thread's samples at 2 GB/s per process (py-spy, 1 process x 16 streams), time during which no other stream on that loop is served. The released google-crc32c already releases the GIL for bytes of 1 MiB or more, so the workers run in parallel with the loop thread and no other change is required.

Changes

  • asyncio/retry/reads_resumption_strategy.py:
    • Chunks of at least _CRC32C_OFFLOAD_MIN_BYTES (defaults to getattr(google_crc32c, "GIL_RELEASE_THRESHOLD", 1024 * 1024); configurable via GCS_PYTHON_CLIENT_CRC32C_OFFLOAD_MIN_BYTES, where 0 verifies every chunk inline) that are not part of a full-object read are hashed on a 2-thread ThreadPoolExecutor; smaller chunks and full-object reads (which feed the rolling checksum) are hashed inline as before.
    • Received chunks are held in state["pending_checksums"] and written in arrival order once their checksum matches (verify_pending_checksums). next_expected_offset advances on receipt and bytes_written only on write, so a retry resumes right after the last verified byte.
    • On a mismatch or a checksum that fails on the worker, discard_pending_checksums drops every held chunk, cancels queued hashes and rewinds each read to its written end; DataCorruption is raised as it is for an inline mismatch. recover_state_on_failure verifies held chunks before a stream is reopened.
    • The pool is replaced in forked children (os.register_at_fork); once interpreter shutdown refuses new work, chunks are hashed inline.
  • asyncio/async_multi_range_downloader.py: download_ranges writes verified chunks after each received message (holding at most _CRC32C_MAX_PENDING = 32), drains fully when an attempt ends or fails, discards held chunks on cancellation, and documents all of this.

Usage

Enabled automatically for non-full-object read chunks of at least 1 MiB (getattr(google_crc32c, "GIL_RELEASE_THRESHOLD", 1024 * 1024)). Set GCS_PYTHON_CLIENT_CRC32C_OFFLOAD_MIN_BYTES to override the threshold in bytes, or 0 to verify every chunk inline on the event-loop thread.

Memory

download_ranges bounds pending_checksums to _CRC32C_MAX_PENDING = 32 chunks per call before draining on the event loop. For 2 MiB chunks, each held _PendingChunk retains both the data slice and the enclosing BidiReadObjectResponse, bounding pending memory per download_ranges call to at most ~128 MiB with the generated proto-plus/upb parser (~4 MiB per chunk across the upb arena copy and .content bytes copy) or ~64 MiB with the zero-copy parser in #18627 (~2 MiB per chunk sharing the gRPC buffer).

Tests

tests/unit/asyncio/retry/test_reads_resumption_strategy.py and tests/unit/asyncio/test_async_multi_range_downloader.py: offload threshold, zero threshold and full-object exclusion; in-order writes behind a pending chunk; mismatch on an offloaded chunk (nothing from it on is written, offsets rewound, the retry requests the bytes again); a worker exception (queue emptied, queued futures cancelled); drain before a retry and at the end of an attempt; discard on cancellation; fork and shutdown behavior of the pool.

nox -s unit lint cover

Benchmark

Setup: GCE c4-standard-192, Rapid bucket in the same zone, 8 processes x 16 concurrent AsyncMultiRangeDownloader streams (128 streams total), 4 x 16 MiB ranges per download_ranges() call into preallocated buffers, 5 s warmup + 15 s timed window per round, 8 interleaved rounds per arm.

1. Off-Loop CRC32C Only vs. main (Neutral Alone)

Arm Mean (GB/s) Median (GB/s) Min - Max (GB/s) vs. main (Mean / Median)
main (generated parser + inline CRC32C) 9.62 9.83 8.25 - 10.70 -
offloop only (GCS_PYTHON_CLIENT_ZERO_COPY_BIDI_READ=0) 9.64 9.96 7.32 - 10.85 +0.2% / +1.3%

With the generated proto-plus/upb parser active, each 2 MiB chunk is still copied twice on the event-loop thread under the GIL (~254 us per message), so the loop remains saturated and offloading CRC32C alone is neutral.

2. Zero-Copy Only vs. Zero-Copy + Off-Loop CRC32C (Additive on Top of #18627)

Arm Mean (GB/s) Median (GB/s) Min - Max (GB/s) vs. main (Mean / Median) vs. Zero-Copy Only (Mean / Median)
main (generated parser + inline CRC32C) 9.47 9.48 8.07 - 10.51 - -
zero-copy only (#18626 + #18627, inline CRC32C) 11.24 11.07 9.62 - 13.73 +18.6% / +16.7% -
zero-copy + offloop (#18626 + #18627 + this PR) 13.30 12.96 10.35 - 16.02 +40.4% / +36.7% +18.3% / +17.1%

Once #18627 removes the two upb/proto-plus copies from the event-loop thread (254 us -> 4.7 us per 2 MiB message, +18.6% mean / +16.7% median over main), synchronous google_crc32c.value() (~100-200 us per 2 MiB chunk) becomes the largest remaining per-message work on the loop thread; offloading >= 1 MiB chunks onto the worker pool adds another +18.3% mean / +17.1% median (+40.4% mean / +36.7% median total over main).

…thread

update_state_from_response hashed every chunk inline on the asyncio loop
thread, serialized with receive and parse. Chunks of at least 512 KiB are
now submitted to a small shared thread pool (google_crc32c releases the
GIL for read-only buffers of 1 MiB or more) and written immediately;
download_ranges() awaits the pending results after each response (bounded
to 32 outstanding) and fully before returning, raising DataCorruption on
the first mismatch. Small chunks are still verified inline.

On the 8-rank DCP load benchmark (test-c4-highmem, zonal bucket) read_data
throughput per rank went from 1.08/1.10 GB/s to 1.39/1.46 GB/s with
verification on; 1.57 GB/s is the same stack with verification off.
…l-object reads inline

- Drain pending offloaded chunk checksums at the start of
  `recover_state_on_failure` so a corrupt chunk already written to the
  user buffer surfaces as `DataCorruption` before reopening the stream
  from the advanced offset.
- Skip the worker-thread CRC32C offload when `rolling_checksum` is
  present (`is_full_object_read=True`), since full-object reads already
  fold every chunk into `rolling_checksum` on the event-loop thread.
- Store `pending_checksums` in a `collections.deque` for O(1) `popleft`
  and cancel remaining queued futures on the first checksum mismatch.
- Parse `GOOGLE_CLOUD_STORAGE_CRC32C_OFFLOAD_MIN_BYTES` via
  `_int_from_env` so malformed values fall back to the 512 KiB default
  and document the 1 MiB GIL-release threshold in `google_crc32c`.
- Always acquire `_crc32c_executor_lock` in `_get_crc32c_executor`.
Chunks checksummed on the worker pool were written to the caller's
buffer and counted in bytes_written before their CRC32C was known. A
mismatch left the corrupt bytes in the buffer, a retry resumed after
them, and an error or cancellation that arrived first hid the mismatch.

Hold received chunks in arrival order and write each one only once its
checksum matches. next_expected_offset still advances on receipt, so
offsets are validated as before. On a mismatch, drop every held chunk
and rewind each read to the end of its written bytes, so a retry asks
for the dropped bytes again. download_ranges() writes or rejects the
held chunks before an attempt ends and before another error propagates,
and drops them on cancellation.
A forked child inherits the module-level pool but none of its worker
threads, so offloaded checksums never finished there and the download
hung. Reset the pool and its lock in the child after fork().

Once interpreter shutdown starts, ThreadPoolExecutor.submit() raises
RuntimeError, which failed downloads still running in non-daemon
threads. Hash those chunks inline instead.
google-crc32c releases the GIL only for chunks of 1 MiB or more that are
backed by bytes, not for any read-only buffer. Received chunks are bytes or
memoryview slices of bytes, so offloaded hashes still run in parallel.
A ThreadPoolExecutor starts no threads until its first submit(), so creating it at import costs nothing and removes the lazy getter and its lock. The at-fork hook now just installs a fresh pool in the child, and the shutdown fallback in _submit_crc32c is unchanged.

(cherry picked from commit 49d5e29)
…d drop held chunks on worker errors

GOOGLE_CLOUD_STORAGE_CRC32C_OFFLOAD_MIN_BYTES=0 (or any value below 1) meant
"offload every chunk", the opposite of what an operator setting it to zero
expects, and there was no documented way to verify every chunk inline. The
threshold now only applies when it is positive, so 0 turns the offload off;
download_ranges() documents the knob and that chunks still waiting for their
checksum are discarded (and re-requested on retry) when the call is cancelled
or a mismatch is found.

If a checksum raised on the worker, verify_pending_checksums() re-raised it
without discarding the queue, so the drain in download_ranges() re-raised the
same error again and the queued futures were left behind. The exception is
now re-raised after discard_pending_checksums(), like a mismatch.

Test plan: from packages/google-cloud-storage:

    PYTHONPATH=$PWD python -m pytest tests/unit/asyncio/retry/test_reads_resumption_strategy.py \
        tests/unit/asyncio/test_async_multi_range_downloader.py

78 pass, including a worker exception with a queued future (queue emptied,
future cancelled, offset rewound) and a zero threshold (nothing submitted).

Authored with an AI assistant.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces offloaded CRC32C checksum verification for large chunks (1 MiB or more) using a background thread pool, preventing blocking of the main event loop during multi-range downloads. It implements a bounded queue to hold pending chunks and write them in arrival order, with robust handling for cancellations, stream failures, and process forks. The review feedback suggests catching AttributeError alongside RuntimeError when submitting tasks to the thread pool to gracefully handle interpreter shutdown when globals might be cleared.

@chandra-siri

chandra-siri commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Hash read chunks of 1 MiB or more on a small worker pool instead of the asyncio event-loop thread, and write each chunk to its buffer only after its checksum matches. On main, google_crc32c.value() is 10-12% of the event-loop thread's samples at 2 GB/s per process (py-spy, 1 process x 16 streams), time during which no other stream on that loop is served. The released google-crc32c already releases the GIL for bytes of 1 MiB or more, so the workers run in parallel with the loop thread and no other change is required.

In addition to summary section - Can you also add a Usage section informing how to opt-in this feature.

Also clearly call out the memory implications.

@chandra-siri

Copy link
Copy Markdown
Contributor

/gcbrun(ea73968)

@yuxin00j
yuxin00j requested a review from a team as a code owner October 20, 2026 09:04
@chandra-siri

Copy link
Copy Markdown
Contributor

/gcbrun

@yuxin00j

Copy link
Copy Markdown
Contributor Author

Summary

Hash read chunks of 1 MiB or more on a small worker pool instead of the asyncio event-loop thread, and write each chunk to its buffer only after its checksum matches. On main, google_crc32c.value() is 10-12% of the event-loop thread's samples at 2 GB/s per process (py-spy, 1 process x 16 streams), time during which no other stream on that loop is served. The released google-crc32c already releases the GIL for bytes of 1 MiB or more, so the workers run in parallel with the loop thread and no other change is required.

In addition to summary section - Can you also add a Usage section informing how to opt-in this feature.

Also clearly call out the memory implications.

Updated the PR description.

This branch has not been deployed

No deployments
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