httpcore2's HTTP/2 request-body path appears to do quadratic bytes copying when the request stream yields a large bytes object.
The issue is in _send_stream_data. Each loop iteration splits the body into a frame-sized chunk and a new copy of the remaining tail.
|
async def _send_stream_data(self, request: Request, stream_id: int, data: bytes) -> None: |
|
""" |
|
Send a single chunk of data in one or more data frames. |
|
""" |
|
while data: |
|
max_flow = await self._wait_for_outgoing_flow(request, stream_id) |
|
chunk_size = min(len(data), max_flow) |
|
chunk, data = data[:chunk_size], data[chunk_size:] |
|
self._h2_state.send_data(stream_id, chunk) |
|
await self._write_outgoing_data(request) |
For an n byte request body and a k byte HTTP/2 frame/window chunk, this copies roughly n^2 / (2k) bytes of tail data. For example, a 360 MiB body sent in 16 KiB frames implies roughly 4 TiB of cumulative tail copying. This presented as a prolonged hang with 100% CPU.
This can be triggered by the normal bytes content path, because bytes content is wrapped in ByteStream, and ByteStream yields the whole content as one chunk.
This looks specific to the HTTP/2 send path. The HTTP/1.1 request-body path passes each stream chunk through h11.Data then it is passed whole to various async socket wrappers, or for the sync path using a memoryview to avoid the copies:
|
def write(self, buffer: bytes, timeout: float | None = None) -> None: |
|
if not buffer: |
|
return |
|
|
|
exc_map: ExceptionMapping = {socket.timeout: WriteTimeout, OSError: WriteError} |
|
with map_exceptions(exc_map): |
|
view = memoryview(buffer) # zero-copy slicing; avoids copies |
|
while view: |
|
self._sock.settimeout(timeout) |
|
n = self._sock.send(view) |
|
view = view[n:] |
|
|
httpcore2's HTTP/2 request-body path appears to do quadratic bytes copying when the request stream yields a largebytesobject.The issue is in
_send_stream_data. Each loop iteration splits the body into a frame-sized chunk and a new copy of the remaining tail.httpx2/src/httpcore2/httpcore2/_async/http2.py
Lines 245 to 254 in 82b9e2d
For an
nbyte request body and akbyte HTTP/2 frame/window chunk, this copies roughlyn^2 / (2k)bytes of tail data. For example, a 360 MiB body sent in 16 KiB frames implies roughly 4 TiB of cumulative tail copying. This presented as a prolonged hang with 100% CPU.This can be triggered by the normal
bytescontent path, becausebytescontent is wrapped inByteStream, andByteStreamyields the whole content as one chunk.This looks specific to the HTTP/2 send path. The HTTP/1.1 request-body path passes each stream chunk through
h11.Datathen it is passed whole to various async socket wrappers, or for the sync path using amemoryviewto avoid the copies:httpx2/src/httpcore2/httpcore2/_backends/sync.py
Lines 129 to 140 in 82b9e2d