Repository navigation
Conversation
Deserializing typed request parameters from the JsonRpcRequest.Params node re-serialized and re-parsed the whole node. Because RequestParams.Meta is a JsonObject, System.Text.Json copied the entire _meta payload into a new unpooled buffer, which put a second full copy of large metadata on the large object heap for every request. When _meta is an object, it is now left out of the re-parse and assigned with DeepClone(). Params nodes are backed by non-disposable documents, so the clone shares the already-parsed bytes and is still an independent, detached node. Any other shape uses the existing path, so behavior and errors don't change. For a stateless HTTP tools/call with a large _meta, allocations per request drop from about 2.0x to 1.0x of the _meta size for a single large string, and from 3.2x to 1.6x for a nested object. Co-authored-by: Copilot App <[email protected]>
This branch has not been deployed
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.
Motivation
We profiled allocations on a production stateless Streamable HTTP server built on this SDK. Clients there routinely send a large
params._meta. About 16% of sampled allocations were large-object-heapbyte[]arrays, and about a third of those came from the SDK copying_metawhile turning a request'sParamsinto typed params. The data is a small sample, so take the proportions as directional.Each request with
_metalarger than ~85 KB currently makes two full, unpooled copies of it, and both go on the large object heap:JsonRpcMessage.Converterreadsparamsas aJsonNode, and System.Text.Json copies the value into a new, non-pooledJsonDocument. Reading into a single contiguous buffer first doesn't help: the span path makes the same copy.RequestHandlerscallsJsonSerializer.Deserialize(request.Params, typeInfo), which writes the node out and parses it again.RequestParams.Metais aJsonObject, so_metagets copied a second time.This PR removes the second copy.
Change
RequestHandlersnow goes through aDeserializeParamshelper. When the target type derives fromRequestParams, uses the default object converter, andparams._metais a JSON object:_metais written to a pooled buffer and deserialized from there; the buffer is cleared before it goes back to the pool;Metais then set to_meta.DeepClone(). The params node comes from a document that is never disposed, soJsonElement.Clone()hands back the same element. The clone reuses the bytes already parsed instead of copying them.The clone is still a detached, independent node. Changing it doesn't affect
request.Params, and it can be attached to another object. Any other shape of_metaor params uses the existing path unchanged, including its errors.There are no public API changes.
Measurements
Allocated bytes per request, relative to
_metasize, on net10.0:_metashapetools/calltools/callLarge-object-heap bytes per request also halve; at 1 MB they go from 2,048 KB to 1,024 KB.
Trade-off: in an unusual case the change costs slightly more. That's a
_metawith tens of thousands of flat top-level keys whose dictionary has already been materialized, for example by trace-context extraction. The clone duplicates the top-level dictionary entries, so total allocations go up about 5%, while large-object-heap bytes still drop about 16%.Not addressed
Paramsafter the handler returns, such as filters, captured state, or task-augmented work._metacosts. Diagnostics and per-request metadata projection materialize the whole top-level_metadictionary to read a few keys, and line-based transports also allocate a UTF-16 string per message.These may be worth following up on separately.
Tests
RequestParamsMetaTestscovers:Metamatches the request_meta;Metacan be attached elsewhere;_meta;_metaand invalid non-_metaparams are still rejected.These pass both with and without the change.
RequestParamsMetaAllocationTests(DisableParallelization, .NET only) handles a request with a 4 MB_meta. Before the change it allocated about 12.6 MB and failed; it now stays well under the 2 MB threshold.ModelContextProtocol.Testspasses on net10.0, net8.0 and net472, except that on net10.0 theClientIntegrationTestsfail locally because they neednpm ci.ModelContextProtocol.AspNetCore.Testspasses on net10.0. Tests weren't run on net9.0 because that runtime isn't installed locally.Note
This PR description and change were generated with GitHub Copilot.