Visitar URL original
Add optional executor offloading for client response decompression by PLPeeters · Pull Request #14316 · aio-libs/aiohttp · GitHub
Skip to content

Add optional executor offloading for client response decompression - #14316

Open
PLPeeters wants to merge 3 commits into
aio-libs:masterfrom
PLPeeters:feature/off-loop-decompression
Open

PLPeeters wants to merge 3 commits into
aio-libs:masterfrom
PLPeeters:feature/off-loop-decompression

Conversation

@PLPeeters

@PLPeeters PLPeeters commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

What do these changes do?

Add auto_decompress_in_executor to ClientSession and individual requests, allowing automatic response decompression to run in the event loop's default thread pool.

The implementation reuses the parser continuation mechanism, preserves streaming and backpressure, and supports all existing content encodings. Includes regression tests, a precompressed-response benchmark, documentation, and a threat-model update.

Are there changes in behavior for the user?

Existing behavior remains unchanged by default. Users can enable offloading for a session or a single request by passing auto_decompress_in_executor=True.

I'm still wondering whether it shouldn't just be enabled by default, as the measured offloading overhead was rather small locally, and the gains when processing many requests in parallel showed an increase in throughput of around 65%.

I suppose codspeed will tell us more when it runs on this PR :)

Is it a substantial burden for the maintainers to support this?

The change adds asynchronous decompression lifecycle handling across the Python and Cython parsers, particularly around connection loss, EOF, errors, and response cleanup. It uses the existing decompressor implementations and parser continuation mechanism without adding dependencies.

Tests cover both parser implementations, supported encodings, output backpressure, chunk boundaries, pending work during connection loss, decoding errors, cancellation, and connection reuse.

Related issue number

None.

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • Contributor listed in CONTRIBUTORS.txt as Pierre-Louis Peeters
  • Add the prepared news fragment under CHANGES/<pr_number>.feature.rst once the PR number is available

@codecov

codecov Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.12%. Comparing base (09f2de8) to head (659ee4e).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff            @@
##           master   #14316    +/-   ##
========================================
  Coverage   99.11%   99.12%            
========================================
  Files         135      135            
  Lines       53439    54138   +699     
  Branches     2723     2779    +56     
========================================
+ Hits        52967    53664   +697     
- Misses        358      360     +2     
  Partials      114      114            
Flag Coverage Δ
Autobahn 21.49% <8.53%> (-0.19%) ⬇️
CI-GHA 99.06% <100.00%> (+0.01%) ⬆️
OS-Linux 98.71% <100.00%> (+0.01%) ⬆️
OS-Windows 97.52% <100.00%> (+0.03%) ⬆️
OS-macOS 98.38% <100.00%> (+0.03%) ⬆️
Py-3.11 98.31% <100.00%> (+0.02%) ⬆️
Py-3.12 98.40% <100.00%> (+0.02%) ⬆️
Py-3.13 98.40% <100.00%> (+0.03%) ⬆️
Py-3.14 98.42% <100.00%> (+0.02%) ⬆️
Py-3.15 98.42% <100.00%> (+0.03%) ⬆️
Py-3.15t 98.27% <100.00%> (+0.02%) ⬆️
Py-pypy-3.12 96.92% <95.44%> (-0.04%) ⬇️
VM-macos-latest 98.38% <100.00%> (+0.03%) ⬆️
VM-ubuntu-latest 98.71% <100.00%> (+0.01%) ⬆️
VM-windows-latest 97.52% <100.00%> (+0.03%) ⬆️
cython-coverage 84.34% <100.00%> (+0.52%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed

codspeed Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 36.92%

3 warnings

⚠️ 52 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 7 benchmarks with a significant change ran on different hardware

Their base and head results were measured on machines with a different CPU, core count or memory, so the changes might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚠️ 7 benchmarks with a significant change ran in a different software environment

Their base and head results were measured with a different OS or toolchain, such as compiler, runtime or library versions, so the changes might come from this difference rather than from the code. Open an affected benchmark to see what changed.

See affected benchmarks

⚡ 7 improved benchmarks
✅ 95 untouched benchmarks
🆕 2 new benchmarks
⏩ 83 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ test_ten_compressed_responses_iter_chunked_1mb[isal.isal_zlib] 184.4 ms 80.1 ms ×2.3
⚡ test_ten_compressed_responses_iter_chunked_1mb[zlib_ng.zlib_ng] 229 ms 126.1 ms +81.62%
⚡ test_get_request_with_251308_compressed_chunked_payload[isal.isal_zlib] 156.4 ms 119.6 ms +30.83%
⚡ test_read_compressed_post_body[zlib_ng.zlib_ng] 60.8 ms 52.3 ms +16.38%
⚡ test_read_compressed_post_body[isal.isal_zlib] 67.9 ms 59.2 ms +14.71%
⚡ test_ten_compressed_responses_iter_chunked_1mb[zlib] 686.9 ms 605 ms +13.54%
⚡ test_get_request_with_251308_compressed_chunked_payload[zlib_ng.zlib_ng] 448.6 ms 412.4 ms +8.78%
🆕 test_get_precompressed_response_decode[gzip] N/A 152.6 ms N/A
🆕 test_get_precompressed_response_decode[raw] N/A 2.9 ms N/A

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing PLPeeters:feature/off-loop-decompression (659ee4e) with master (09f2de8)

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@PLPeeters
PLPeeters force-pushed the feature/off-loop-decompression branch 2 times, most recently from 72f87fa to 3eca42a Compare November 9, 2026 14:39
Comment thread tests/test_http_parser.py Fixed
@PLPeeters
PLPeeters force-pushed the feature/off-loop-decompression branch 4 times, most recently from 27f8533 to 7b5d2da Compare November 10, 2026 07:44
@PLPeeters
PLPeeters marked this pull request as ready for review November 10, 2026 07:45
@greptile-apps

greptile-apps Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; all four numbered findings are addressed.

Reviews (5) · Last reviewed commit: "Fix executor decompression shutdown, err..." · Reviewed by Greptile

Comment thread aiohttp/http_parser.py Resolved
Comment thread tests/test_benchmarks_client.py Resolved
@PLPeeters

Copy link
Copy Markdown
Contributor Author

Missed the remaining coverage issue, fixing that now.

@PLPeeters
PLPeeters force-pushed the feature/off-loop-decompression branch from 628fdbc to 60b566b Compare November 10, 2026 09:02
Comment thread aiohttp/client_proto.py Resolved
Comment thread aiohttp/http_parser.py Resolved
Comment thread aiohttp/http_parser.py Resolved
@PLPeeters
PLPeeters force-pushed the feature/off-loop-decompression branch from 60b566b to 8507973 Compare November 10, 2026 09:22
Comment thread aiohttp/client_proto.py Resolved
@PLPeeters
PLPeeters force-pushed the feature/off-loop-decompression branch 2 times, most recently from 863eff1 to 75b22c0 Compare November 12, 2026 18:06
@pyba-chronographer pyba-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Nov 12, 2026
@PLPeeters
PLPeeters force-pushed the feature/off-loop-decompression branch from 75b22c0 to 659ee4e Compare November 12, 2026 18:09
@bdraco

bdraco commented Nov 13, 2026

Copy link
Copy Markdown
Member

@codspeedbot explain why performance improved

@codspeed

codspeed Bot commented Nov 13, 2026

Copy link
Copy Markdown

@codspeedbot explain why performance improved

I compared the CodSpeed Simulation flamegraphs for this PR's head (659ee4e) against master (09f2de8). Short version: the big client-side gains are real. They don't come from moving CPU time onto another thread. They come from aiohttp no longer copying the decompressed bytes over and over.

Moving work to the executor doesn't hide it

Simulation adds up CPU time from every thread, and the executor threads show up in the profile. In test_ten_compressed_responses_iter_chunked_1mb[isal.isal_zlib] (181.6 ms → 79.9 ms), ZLibDecompressor.decompress_sync still costs about 47 ms on the head. It now runs under concurrent.futures.thread._worker rather than cb_on_body. Decompression itself costs about the same before and after.

Where the time went: copying in StreamReader

On master, 137.5 ms of the 181.6 ms (76%) is memcpy, and most of it is in the stream reader:

memcpy caller (base) Time
StreamReader._read_nowait (b"".join(chunks)) 42.2 ms
StreamReader._read_nowait_chunk (first_buffer[offset:offset+n]) 37.3 ms
_int_realloc inside isal_zlib decompress ~36 ms

Here is what happened on master during iter_chunked(1 MiB):

  1. _read_nowait_chunk() drops the buffer below the low-water mark and calls resume_reading().
  2. That call goes straight into data_received() → feed_data() → DeflateBuffer.feed_data() and decompresses the next block synchronously while the read is still in progress.
  3. The while self._buffer: loop in _read_nowait() then finds the freshly added data. It slices the partially read large chunk, which copies it, and finally runs b"".join(chunks) over several MB, which copies it again.

With offloading, resume_reading() → DeflateBuffer.feed_data() only queues the next decode with run_in_executor and pauses reading. Nothing new reaches the buffer during the read. _read_nowait() now usually returns a single buffered chunk without slicing or joining. On the head, StreamReader.read dropped from 145.2 ms to 0.63 ms, and the memcpy under it is gone. realloc inside the decompressor also halved (~36 ms → ~18 ms).

The new cost of offloading on the event loop is small: run_in_executor, ThreadPoolExecutor.submit, Condition/Future locking and the extra call_soon_threadsafe wakeups come to well under 1 ms per run here.

The zlib-ng and [zlib] variants of the same benchmark, and test_get_request_with_251308_compressed_chunked_payload, improve the same way. They gain less because the decompressor's own work makes up more of their total.

Caveats

  • test_read_compressed_post_body[*] (+15–16%) is probably not from this PR. It measures the server: web_protocol's request parser doesn't get offload_decompression, and the flamegraph still shows decompression inline in cb_on_body. The whole difference is in memcpy/realloc cost.
  • CodSpeed reported that the base and head ran on different CPUs (EPYC 9V74 → 9V45) and that the compiled .so build IDs changed, for all 7 improved benchmarks. That explains changes like the server one above. It doesn't explain the client wins, because those come from code paths that are gone on the head.
  • Most of these benchmarks are dominated by system calls, which Simulation excludes. Simulation also measures total CPU work, not how much runs in parallel, so the throughput gain you saw locally with many requests in parallel won't show up in these numbers. You'd need Walltime on macro runners for that.
  • The win in iter_chunked partly comes from a side effect: reading no longer re-enters decompression synchronously. You could get some of that win in the synchronous path too, for example by not calling resume_reading() from inside _read_nowait_chunk() while a multi-chunk read is still running. Then the speedup wouldn't depend on offloading.

Comparison: https://app.codspeed.io/aio-libs/aiohttp/runs/compare/6af4c9209c0b923757c59ac5..6af603624cee452e9b868112

Comment @codspeedbot try avoiding the re-entrant decompression in StreamReader._read_nowait without offloading if you want me to try that change and benchmark it on its own.

Summary
  • Warming up the performance wizard...
  • Handling comment — Comment handling finished successfully

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@aiolibsbot

aiolibsbot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

PR Review — Add optional executor offloading for client response decompression

Solid, carefully tested mechanism, but the PR head silently turned the opt-in feature into always-on with no escape hatch, and the title/description/request-level docs still describe the opt-in version.

What is done well: the offload reuses the existing pause/continuation protocol instead of adding a parallel path, keeps exactly one worker per response, and preserves output limits and chunk-count backpressure. The deferred-EOF state is cleaned up consistently across close(), abort(), parser errors and cancellation, _wait() now surfaces the real payload error instead of a bare "Connection closed", and resume_reading() no longer re-arms sock_read while parsing is still paused. The test suite covers both parsers and the genuinely awkward orderings (completion callback racing a consumer resume, EOF landing between a finished decode and its callback, upgrade tail preservation, concatenated members, executor shutdown). All six prior Greptile findings are addressed at this commit; the CodeQL BaseException note at test_http_parser.py:1780 no longer matches any code there.

  • Offloading is unconditional (_offload_decompression = auto_decompress), the opt-in flag was deleted, and sock_read is suspended for the entire executor wait, so a busy default executor becomes an unbounded wait for streams without total. The PR title/description and the request-level auto_decompress docs need to match whichever direction is intended, and an opt-out or size threshold (existing MAX_SYNC_CHUNK_SIZE convention) is worth keeping.
  • A cancelled worker future escapes DeflateBuffer.feed_data's except Exception; catching it there would unify both parsers and remove the CancelledError special case in data_received.
  • The Cython feed_eof tail guard and the connection_lost pre-drain are gated on offloading but also change plain-body connection-loss behavior on the C parser only; gate on the real condition and test it, or restrict to DeflateBuffer.
  • _decompression_done casts to ResponseHandler and re-implements StreamReader's high-water predicate from the shared parser module; a StreamReader predicate plus a protocol hook would remove that coupling.

🟡 Important

1. Offloading is now unconditional with no opt-out, contradicting the PR title/description
aiohttp/client_proto.py:317-333

The PR title and description still describe an opt-in auto_decompress_in_executor flag, but the second commit ("Always offload client response decompression") removed that flag from ClientSession, request(), and the docs. At the PR head, set_response_params sets _offload_decompression = auto_decompress, so every compressed response for every user now runs through the loop's default executor, with at least one run_in_executor hop per response plus one per empty-input drain. The only way to keep decoding inline is auto_decompress=False.

Why this matters beyond a stale description:

  • DeflateBuffer.feed_data calls protocol.pause_reading() while a worker is in flight, and ResponseHandler.pause_reading() drops the sock_read timer. The timer is only re-armed once reading fully resumes. So the time spent waiting on the default executor is excluded from sock_read. A busy or deliberately small default executor (shared with getaddrinfo, file I/O, user run_in_executor work) turns a stream configured with sock_read but no total into an unbounded wait. Previously the user could at least avoid this by not enabling the feature.
  • Small compressed responses (the common JSON/API case) now pay a thread hop and a context switch for a few hundred bytes of inflate, which the inline path did for free. CodSpeed only reports the new benchmark without a baseline, so this cost is not visible in the numbers posted so far.
  • docs/client_reference.rst still documents the request-level auto_decompress (lines 586 and 1057) as plain "automatically decompress response body" with no mention of the thread pool; only the session parameter got the versionchanged note.

Suggested path: if always-on is the intended direction, say so in the PR title/description and docs, and give users an escape hatch: either keep a session/request knob, or follow the existing compression_utils convention (MAX_SYNC_CHUNK_SIZE, zlib_executor_size in web_response.py) and only offload when the input chunk exceeds a threshold or when it is an empty-input drain (which implies retained input). If opt-in is still the intent, the second commit needs to be reverted.

        self._offload_decompression = auto_decompress

        self._read_bufsize = read_bufsize
        self._read_timeout = read_timeout

        self._timeout_ceil_threshold = timeout_ceil_threshold

        self._parser = HttpResponseParser(
            self,
            self._loop,
            read_bufsize,
            timer=timer,
            payload_exception=ClientPayloadError,
            response_with_body=not skip_payload,
            read_until_eof=read_until_eof,
            auto_decompress=auto_decompress,
            offload_decompression=auto_decompress,

🟢 Suggestions

2. Catch worker cancellation in DeflateBuffer so both parsers take the normal payload-error path
aiohttp/http_parser.py:1255-1261

pending.result() raises asyncio.CancelledError when the executor cancels a queued job (for example executor.shutdown(cancel_futures=True) while a session is still live). That is a BaseException, so it escapes this except Exception, escapes the pure-Python HttpParser.feed_data handler at lines 576-596 (which only catches Exception and is what sets the payload exception), and surfaces in ResponseHandler.data_received. The protocol compensates with a dedicated branch at client_proto.py:425-430, but only when _deferred_eof is set. In the live-transport case the CancelledError is re-raised out of the _decompression_done loop callback (logged by asyncio as "Exception in callback"), the Python-parser payload gets no exception until connection_lost arrives, and the Cython parser (whose cb_on_body catches BaseException) behaves differently.

Catching the cancellation here and converting it to ContentEncodingError would make the two parsers behave identically and let you delete the CancelledError special case in data_received:

except (Exception, asyncio.CancelledError) as exc:
    raise ContentEncodingError(...) from exc

This is a narrow edge (executor shutdown under a live session), so non-blocking.

try:
    decompressed = pending.result()
except Exception as exc:
    raise ContentEncodingError(
        "Can not decode content-encoding: %s" % self.encoding
    ) from exc
3. Deferred-EOF plumbing is gated on offloading but also fires for uncompressed bodies on the C parser
aiohttp/_http_parser.pyx:624-625

This guard, and the pre-drain self.data_received(b"") added to ResponseHandler.connection_lost (client_proto.py:165-170), are keyed on _offload_decompression, which at the PR head equals auto_decompress and is therefore on by default for every response. But _tail on the C parser is not decompression-specific: a plain body paused for StreamReader high water also leaves unconsumed bytes in _tail (cb_on_body returns HPE_PAUSED). So with the default settings a plain-body connection loss on the C parser now returns None here, flips _deferred_eof on, and completes later from the consumer's resume_reading(), while the pure-Python parser (which never retains body bytes) never enters that state. On master the same scenario raised ContentLengthError spuriously, so the new behavior is arguably better, but it is an undocumented, parser-specific change hidden behind a flag named for something else (threat-model §1.12 territory).

Suggestions:

  • Gate on the real condition, the parser still holding unconsumed input, independent of offloading, and add a plain-body test for it on both parsers; or restrict the guard to isinstance(self._payload, DeflateBuffer) if the intent is decompression only.
  • The protocol-level data_received(b"") in connection_lost duplicates what both parsers' feed_eof already do internally (Python: self.feed_data(b"") when _payload_has_more_data; Cython: the _more_data_available loop). If it is load-bearing for some ordering, a comment saying which one would save the next reader from re-deriving it; otherwise drop it.
if self._offload_decompression and self._payload is not None and self._tail:
    return None
4. Completion callback couples the shared parser module to client-protocol and StreamReader internals
aiohttp/http_parser.py:1314-1324

http_parser.py is shared by the server and the client, yet this callback casts the protocol to ResponseHandler and reads protocol._payload, then re-implements the StreamReader high-water test by poking _size, _high_water, _http_chunk_splits and _high_water_chunks. That same predicate already exists inline in three places in streams.py (feed_data at 301, end_http_chunk_receiving at 340, _read_nowait_chunk at 581), each with slightly different comparison operators, and now a fourth copy lives in a different module.

Two small moves would remove the coupling:

  • Give StreamReader one predicate, for example _is_above_high_water() returning the size-or-chunk-count condition, and use it both here and in streams.py.
  • Replace protocol._payload is not self.out with a protocol-level question the parser is allowed to ask, for example a BaseProtocol method payload_is_current(payload) -> bool that ResponseHandler overrides, mirroring how can_receive_data was already introduced in this PR. Then the cast("ResponseHandler", ...) and the private-attribute reads disappear.

Non-blocking; it does not change behavior, but it keeps client-only knowledge out of the shared parser.

protocol = cast("ResponseHandler", self.out._protocol)
if protocol._payload is not self.out or self.out.exception() is not None:
    self._pending = None
elif self.out._size <= self.out._high_water and (
    self.out._http_chunk_splits is None
    or len(self.out._http_chunk_splits) <= self.out._high_water_chunks
):
    protocol.resume_reading()

Checklist

  • PR description matches the delivered change — warning #1
  • Backward-compatible default behavior / opt-out for new runtime behavior — warning #1
  • User-visible API changes documented under docs/ — warning #1
  • Error paths wake payload readers (close, abort, connection loss, worker failure)
  • Worker cancellation handled uniformly across parsers — suggestion #2
  • Cython and pure-Python parsers behave the same — suggestion #3
  • Backpressure and output limits preserved under offloading
  • Layering: shared parser module free of client-only knowledge — suggestion #4
  • New behavior covered by tests on both parsers
  • Changelog fragment present and attributed
  • THREAT_MODEL.md updated for the new scheduling surface
  • No hardcoded secrets or unsafe operations

To rebase and address feedback, mention me: @aiolibsbot rebase critical (fixes 🔴 only), @aiolibsbot rebase important (fixes 🔴 + 🟡), or @aiolibsbot rebase --fix for all. (A bare @aiolibsbot rebase only rebases onto the base branch.)


Silent Failure Analysis

🟠 **5. HIGH** — base exception bypasses error conversion
aiohttp/http_parser.py:1255-1264

Risk: A cancelled executor future (e.g. executor shutdown with cancel_futures, or loop shutdown) raises asyncio.CancelledError, which is not an Exception; with the pure-Python parser it then skips HttpParser.feed_data's except Exception payload handler (http_parser.py:580) and, while the connection is still open (_deferred_eof is False), ResponseHandler.data_received re-raises it out of the future done-callback without ever calling set_exception on the StreamReader, so the consumer is left waiting on a payload whose read timeout was already dropped by pause_reading, or later receives a truncated body as a clean EOF after the forced transport close; the Cython parser's cb_on_body catches BaseException, so the two backends also diverge here.

pending, self._pending = self._pending, None
try:
    decompressed = pending.result()
except Exception as exc:
    raise ContentEncodingError(
        "Can not decode content-encoding: %s" % self.encoding
    ) from exc

Fix: Catch BaseException (or at least asyncio.CancelledError) in the pending-result branch and convert it to ContentEncodingError, or fail self.out directly, so a cancelled decode always surfaces on the payload regardless of parser backend or connection state.

🟡 **6. MEDIUM** — unguarded call on cleanup path
aiohttp/client_proto.py:165-170

Risk: This drain runs before _deferred_eof is computed, so a non-Exception error escaping data_received (the same CancelledError case as above) propagates out of connection_lost, skipping the _deferred_eof/_parser/_payload reset and super().connection_lost(), which leaves the transport attribute set and can_receive_data reporting True for a dead connection.

if (
    self._offload_decompression
    and self._parser is not None
    and not self._upgraded
):
    self.data_received(b"")

Fix: Wrap the pre-EOF drain so that any BaseException fails the current payload via _fail_deferred_payload or set_exception and then lets the rest of connection_lost's teardown proceed.


Automated review by Kōan (Claude) HEAD=659ee4e 8 min 21s

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

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants