Repository navigation
Reduce per-request allocations and CPU in the Core request pipeline - #4566
Closed
martincostello wants to merge 9 commits into
Closed
martincostello wants to merge 9 commits into
martincostello wants to merge 9 commits into
Conversation
ChunkedUploadWrapperStream always allocated two buffers of DefaultChunkSize (~80 KB each) even when the wrapped stream was far smaller. When the remaining length of the wrapped stream is known and smaller than a chunk, size the input and output buffers to it instead. Measured with a mocked HTTP client: S3 PutObject with a 40-byte body drops from 205 KB to 45 KB allocated per request and is about 25% faster.
AWS_LAMBDA_FUNCTION_NAME and AWS_LAMBDA_MAX_CONCURRENCY are fixed for the lifetime of a Lambda process, but the marshaller read them from the environment on every request. Read them once and keep reading the per-invocation _X_AMZN_TRACE_ID per request. Tests that toggle the variables reset the cache through an internal hook.
TracingUtilities.CreateSpan built an attribute set, scope string, tracer and span for every call even with the default no-op TracerProvider, and the HTTP handler recorded request/response telemetry into them. Return a shared no-op span when the no-op provider is in use and skip RecordHttpTelemetryData when both tracing and metrics are no-ops. Saves about 1.5 KB of allocations per request.
ComputeHash(string) and ComputeKeyedHash(string) encoded the canonical request and string-to-sign into fresh byte arrays for every request. On .NET 8+ encode into a pooled buffer and hash the span with SHA256.HashData / HMACSHA256.HashData instead. Other targets and algorithms keep the existing path.
StreamingUtf8JsonReader only returned its pooled buffer when a caller read past the end of the stream, but generated unmarshallers stop at the closing token of the root value, so every JSON response rented (and effectively allocated) a new 4 KB buffer. Release the buffer once the root value's closing token has been read and guard against reading or double-returning afterwards. The Unmarshaller handler also now disposes the unmarshaller context for non-streaming responses, which returns the context's own pooled path buffer and closes its readers. Unmarshalling a small DynamoDB GetItem response drops from 6.5 KB to 2.4 KB; DynamoDB and SQS requests allocate 16-18% less and run measurably faster.
Services whose auth schemes come from the endpoint ruleset (such as S3) resolve the endpoint in the auth resolver and again in the endpoint resolver. Keep the result on the request context and reuse it when the endpoint parameters are unchanged, re-applying only the URL normalization. Partition(region) built a six-entry property bag (with dictionary growth and boxed booleans) on every call. Build one prototype per partition shape and hand out exact-size copies so callers still get their own instance. S3 PutObject allocates 16% less and is about 10% faster; ListBuckets allocates 13% less.
Logger.GetLogger took a lock around a ConcurrentDictionary lookup on every call; use GetOrAdd. CanonicalizeResourcePathV2 built the arguments for a debug message on every signed request; only build them when a debug logger is attached. On .NET 8+ copy response headers through HttpHeaders.NonValidated so the header values are not parsed and validated for every response.
ChunkedUploadWrapperStream - Keep full-size buffers for encrypting (read-and-copy) streams, whose reported length can exceed Length - Position, and clamp every read-and-copy read to the space left in the chunk buffer so it can no longer overflow. The interim read buffer is allocated once, sized to min(buffer size, chunk size). - Treat ObjectDisposedException and IOException from the length probe like NotSupportedException so a bad stream still fails at first read. - Drop the exact-size copy of a short final chunk: SigV4 hashes only the bytes read through a new internal string-to-sign overload (the public overload keeps its contract and delegates to it), and SigV4a signs a MemoryStream slice. The chunk header is written straight into the output buffer without a StringBuilder or temporary byte arrays, the signing key is captured once instead of copied per chunk, and the empty-body hash uses the existing constant. - Expose InputBufferSize for tests. StreamingUtf8JsonReader - Null the buffer reference once it is returned to the pool and reject refills explicitly, so misuse after the root value fails fast. BaseEndpointResolver - Only cache a resolved endpoint when parameters were mapped, and guard the cache lookup against a null parameter set (the base resolver maps none). Remove a dead null check. HttpClientResponseData - Revert the .NET 8 NonValidated header read so every target returns the first parsed header value as before. Use the enumerated values instead of a second lookup per header, and drop the per-response HashSet in favour of the case-insensitive header dictionary. Tests cover the exact-size and full-size chunk buffers, the read-and-copy clamp with an encrypting stream, multi-chunk round-tripping with chunk signatures recomputed independently, the endpoint cache hit/miss/null paths, the early pooled-buffer release (with invalid bytes after the root value), and response header ordering and lookups.
martincostello
marked this pull request as ready for review
October 26, 2026 23:39
martincostello
requested review from
a team,
boblodgett,
dscpinheiro and
jterapin
and
a balanced review from Copilot
and removed request for
a team
October 26, 2026 23:39
Contributor
|
Thanks, Martin. This is well organized and well measured. Several of these changes overlap draft PRs we already have open:
The JSON pooled-buffer release, the endpoint reuse with the *Edited to remove prior suggestion to target feature/4.1 branch. |
This was referenced Oct 27, 2026
Contributor
Author
8 of 16 tasks
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Reduces per-request CPU and allocations in
AWSSDK.Core's request pipeline. Each change was profiled (dotnet-trace, GC allocation ticks, and a mocked-HTTP harness) and measured in isolation against a baseline build; only changes with a measurable win were kept.One commit per logical change:
ChunkedUploadWrapperStreamallocated two ~80 KB buffers per request regardless of body size. Buffers are now sized to the known remaining length for plain streams (encrypting streams keep the full size), the read-and-copy path clamps reads to the space left in the chunk, the short final chunk is signed in place instead of being copied, and the chunk header is written directly into the output buffer.AWS_LAMBDA_FUNCTION_NAME/AWS_LAMBDA_MAX_CONCURRENCYare read once per process instead of on every request; the per-invocation trace id is still read per request.TracingUtilities.CreateSpanreturns a shared no-op span when no tracer is registered, and HTTP telemetry recording is skipped when both the tracer and meter providers are no-ops.byte[]copies.StreamingUtf8JsonReaderonly returned its 4 KB pooled buffer when a caller read past the end of the stream, but generated unmarshallers stop at the root value's closing token, so every JSON response effectively allocated a new 4 KB array. The buffer is released when the root value closes, and theUnmarshallerhandler disposes the context for non-streaming responses (returning its pooled path buffer too). Extracted to Return pooled JSON reader buffers after each response #4567.Partition(region)builds one prototype bag per partition and hands out exact-size copies. Extracted to Reduce endpoint resolution allocations #4568.Logger.GetLoggerno longer locks around itsConcurrentDictionary, andCanonicalizeResourcePathV2only builds its debug message arguments when a debug logger is attached.HttpClientResponseDatadrops a redundant per-responseHashSet. Extracted to Trim per-request logger overhead #4569.Motivation and Context
Steady-state cost of a request through the pipeline (mocked HTTP, no network), branch vs
development(9ab8dcf), best of three interleaved rounds. Ratios are branch ÷ baseline.Allocation figures are exact (
GC.GetAllocatedBytesForCurrentThread) and identical across rounds. Timings are per operation on an i7-13700H (process pinned to the P-cores), .NET 10 runtime against thenet8.0build of the SDK, after a 6 s warm-up so tiered compilation has settled; each row is the best of three interleaved baseline/branch rounds of 20 × 2000 operations. Individual commit messages record the effect measured for that commit alone.Benchmark code
The harness is not checked in. It is a console project that references the SDK projects directly, replaces the HTTP client with a canned-response handler, and measures a full client call through the pipeline. Build it once from the default branch and copy
bin/Release/net10.0toCoreBench-base, then build it from the branch and runab.sh "ddb sqs put list" 3.CoreBench.csprojBenchmarks.csMockHttp.csProgram.csab.shTesting
AWSSDK.Core.NetStandard.csprojandAWSSDK.Core.NetFramework.csprojbuild for all targets with no warnings.AWSSDK.UnitTests.Coreonnet472: 1562 passed, 2 failed (DefaultCredentialsResolverTests.CredentialsAreReevaluated*), and those two fail identically ondevelopmenton the same machine because they depend on local credential state.net8.0test target compiles but could not be executed locally (MSTest adapter/framework mismatch in this environment), so the net8-only code paths and the newHttpClientResponseDataTestsrely on CI for their first run.Dry-runs
Breaking Changes Assessment
ChunkedUploadWrapperStreambuffer sizes are now payload-dependent for seekable, non-encrypting streams; the emitted aws-chunked content is unchanged (tests pin chunk sizes and signatures).StreamingUtf8JsonReader.Readreturnsfalseafter the root value's closing token instead of attempting to read past it; reading further would previously have thrown on trailing non-JSON bytes.Types of changes
Checklist
License
This change was generated by AI tools (Claude Code), and reviewed by Martin Costello (@martincostello).
🤖 Generated with Claude Code