Skip to content

Commit 495de36

Browse files
committed
perf: reduce Refit.Reflection memory allocations
Cut allocations across the reflection request builder's per-request and per-method-parse paths, behaviour-identical, guided by GcVerbose micro-benchmarks. - Query subsystem: cache per-type query-property metadata and the attribute provider, cache the enumerable classification, drop the iterator, and parse an existing query string span-based without a NameValueCollection - object/collection query flatten down 69-82%. - Object-path / multipart / headers: cache per-parameter attributes, build the route object-property lookup lazily, value-tuple bindings, emit static headers without a per-request copy. - Cold parse: read each parameter's attributes in one GetCustomAttributes pass, cache the declared factory-method lookup (FindDeclaredMethod -> 0 B, BuildRestResultFuncForMethod -76%), lazy/presize the parameter maps. - Add a Refit.Reflection.Benchmarks GcVerbose micro-benchmark project and a Refit.Reflection.Tests project pinning the request-builder contracts; widen the relevant private members to internal (fields stay private) for benchmarking. End-to-end reflection request build is ~21% faster on a query-object method (6.5 -> 5.2 us). No public API change; behaviour and serialised output unchanged.
1 parent 78271e1 commit 495de36

77 files changed

Lines changed: 3942 additions & 445 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
2+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using System.Reflection;
6+
7+
namespace Refit;
8+
9+
/// <summary>Wraps an <see cref="ICustomAttributeProvider"/> and materializes its <see cref="QueryAttribute"/> lookup once
10+
/// so the URL parameter formatter, which re-reads the attribute for every formatted value, never re-materializes it from
11+
/// metadata.</summary>
12+
/// <param name="inner">The underlying provider (a parameter, property or type) to read attributes from.</param>
13+
/// <remarks>Only the <c>(typeof(QueryAttribute), inherit: true)</c> lookup the default formatter performs is cached; every
14+
/// other query delegates to <paramref name="inner"/>, so a custom formatter that reads other attributes sees exactly the
15+
/// same values it would from the original provider. The cached array holds immutable metadata attributes, so sharing it
16+
/// across format calls is behaviourally identical to re-reading it.</remarks>
17+
internal sealed class CachedAttributeProvider(ICustomAttributeProvider inner) : ICustomAttributeProvider
18+
{
19+
/// <summary>The materialized <see cref="QueryAttribute"/> lookup, cached on first read.</summary>
20+
private object[]? _queryAttributes;
21+
22+
/// <inheritdoc/>
23+
public object[] GetCustomAttributes(bool inherit) => inner.GetCustomAttributes(inherit);
24+
25+
/// <inheritdoc/>
26+
public object[] GetCustomAttributes(Type attributeType, bool inherit) =>
27+
inherit && attributeType == typeof(QueryAttribute)
28+
? _queryAttributes ??= inner.GetCustomAttributes(typeof(QueryAttribute), true)
29+
: inner.GetCustomAttributes(attributeType, inherit);
30+
31+
/// <inheritdoc/>
32+
public bool IsDefined(Type attributeType, bool inherit) => inner.IsDefined(attributeType, inherit);
33+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
2+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
namespace Refit;
6+
7+
/// <summary>Receives formatted enumerable query values so they can be appended in place without an intermediate sequence
8+
/// or iterator state machine.</summary>
9+
internal interface IQueryValueSink
10+
{
11+
/// <summary>Appends one formatted value.</summary>
12+
/// <param name="value">The formatted value, or <see langword="null"/>.</param>
13+
void Add(string? value);
14+
}

src/Refit.Reflection/MethodTableKey.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ public MethodTableKey(string methodName, Type[] parameters, Type[] genericArgume
1818
}
1919

2020
/// <summary>Gets the methods name.</summary>
21-
private string MethodName { get; }
21+
internal string MethodName { get; }
2222

2323
/// <summary>Gets the Array containing the methods parameters.</summary>
24-
private Type[] Parameters { get; }
24+
internal Type[] Parameters { get; }
2525

2626
/// <summary>Gets the Array containing the methods generic arguments.</summary>
27-
private Type[] GenericArguments { get; }
27+
internal Type[] GenericArguments { get; }
2828

2929
/// <inheritdoc/>
3030
public override int GetHashCode()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
2+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
namespace Refit;
6+
7+
/// <summary>The request-shaping attributes read from a single method parameter in one metadata pass, so the request
8+
/// builder's construction classifies each parameter without re-enumerating its attribute records per lookup.</summary>
9+
/// <param name="Query">The parameter's <see cref="QueryAttribute"/>, or null.</param>
10+
/// <param name="Header">The parameter's <see cref="HeaderAttribute"/>, or null.</param>
11+
/// <param name="HeaderCollection">The parameter's <see cref="HeaderCollectionAttribute"/>, or null.</param>
12+
/// <param name="Property">The parameter's <see cref="PropertyAttribute"/>, or null.</param>
13+
/// <param name="Authorize">The parameter's <see cref="AuthorizeAttribute"/>, or null.</param>
14+
/// <param name="Body">The parameter's <see cref="BodyAttribute"/>, or null.</param>
15+
/// <param name="Url">The parameter's <see cref="UrlAttribute"/>, or null.</param>
16+
/// <param name="FormObject">The parameter's <see cref="FormObjectAttribute"/>, or null.</param>
17+
internal readonly record struct ParameterAttributeSet(
18+
QueryAttribute? Query,
19+
HeaderAttribute? Header,
20+
HeaderCollectionAttribute? HeaderCollection,
21+
PropertyAttribute? Property,
22+
AuthorizeAttribute? Authorize,
23+
BodyAttribute? Body,
24+
UrlAttribute? Url,
25+
FormObjectAttribute? FormObject);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
2+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
namespace Refit;
6+
7+
/// <summary>Appends formatted enumerable values as <see cref="QueryMapEntry"/> items under a fixed key.</summary>
8+
/// <param name="Entries">The list receiving the entries.</param>
9+
/// <param name="Key">The query key applied to every appended value.</param>
10+
internal readonly record struct QueryMapEntrySink(List<QueryMapEntry> Entries, string Key) : IQueryValueSink
11+
{
12+
/// <inheritdoc/>
13+
public void Add(string? value) => Entries.Add(new(Key, value));
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
2+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
namespace Refit;
6+
7+
/// <summary>Appends formatted enumerable values as <see cref="QueryParameterEntry"/> items under a fixed key.</summary>
8+
/// <param name="Entries">The list receiving the entries.</param>
9+
/// <param name="Key">The query key applied to every appended value.</param>
10+
internal readonly record struct QueryParameterEntrySink(List<QueryParameterEntry> Entries, string Key) : IQueryValueSink
11+
{
12+
/// <inheritdoc/>
13+
public void Add(string? value) => Entries.Add(new(Key, value));
14+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
2+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using System.Reflection;
6+
7+
namespace Refit;
8+
9+
/// <summary>One readable public property of a query object, together with the attribute-derived facts the reflection
10+
/// request builder would otherwise re-read from metadata on every request.</summary>
11+
/// <param name="Property">The readable public instance property.</param>
12+
/// <param name="IsIgnored">Whether the property carries a serialization-ignore attribute and is always skipped.</param>
13+
/// <param name="QueryAttribute">The property's <see cref="Refit.QueryAttribute"/>, or <see langword="null"/> when absent.</param>
14+
/// <param name="AliasAttribute">The property's <see cref="AliasAsAttribute"/>, or <see langword="null"/> when absent.</param>
15+
internal readonly record struct QueryPropertyMetadata(
16+
PropertyInfo Property,
17+
bool IsIgnored,
18+
QueryAttribute? QueryAttribute,
19+
AliasAsAttribute? AliasAttribute);

src/Refit.Reflection/Refit.Reflection.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
<ItemGroup>
1414
<InternalsVisibleTo Include="Refit.Tests"/>
15+
<InternalsVisibleTo Include="Refit.Reflection.Tests"/>
16+
<InternalsVisibleTo Include="Refit.Reflection.Benchmarks"/>
1517
<ProjectReference Include="..\Refit\Refit.csproj" PrivateAssets="Analyzers"/>
1618
</ItemGroup>
1719

src/Refit.Reflection/RequestBuilderImplementation.Execution.cs

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,26 @@ namespace Refit;
99
/// <summary>Reflection-based request builder that turns Refit interface calls into HTTP requests.</summary>
1010
internal partial class RequestBuilderImplementation
1111
{
12+
/// <summary>Gets the cancellation token declared by the interface method, if any.</summary>
13+
/// <param name="restMethod">The rest method being invoked.</param>
14+
/// <param name="paramList">The argument values for the call.</param>
15+
/// <returns>The method's cancellation token, or <see cref="CancellationToken.None"/>.</returns>
16+
internal static CancellationToken GetMethodCancellationToken(
17+
RestMethodInfoInternal restMethod,
18+
object[] paramList) =>
19+
restMethod.CancellationToken is not null
20+
? GetCancellationToken(paramList)
21+
: CancellationToken.None;
22+
23+
/// <summary>Determines whether the request body should be buffered before sending.</summary>
24+
/// <param name="restMethod">The rest method being invoked.</param>
25+
/// <param name="request">The built request message.</param>
26+
/// <returns><see langword="true"/> if the body should be buffered; otherwise <see langword="false"/>.</returns>
27+
internal static bool IsBodyBuffered(
28+
RestMethodInfoInternal restMethod,
29+
HttpRequestMessage request) =>
30+
(restMethod.BodyParameterInfo?.Item2 ?? false) && request.Content is not null;
31+
1232
/// <summary>Builds and streams the response for a method returning an asynchronous sequence.</summary>
1333
/// <typeparam name="T">The element type yielded to the caller.</typeparam>
1434
/// <param name="client">The HTTP client to send with.</param>
@@ -53,31 +73,11 @@ internal partial class RequestBuilderImplementation
5373
}
5474
}
5575

56-
/// <summary>Gets the cancellation token declared by the interface method, if any.</summary>
57-
/// <param name="restMethod">The rest method being invoked.</param>
58-
/// <param name="paramList">The argument values for the call.</param>
59-
/// <returns>The method's cancellation token, or <see cref="CancellationToken.None"/>.</returns>
60-
private static CancellationToken GetMethodCancellationToken(
61-
RestMethodInfoInternal restMethod,
62-
object[] paramList) =>
63-
restMethod.CancellationToken is not null
64-
? GetCancellationToken(paramList)
65-
: CancellationToken.None;
66-
67-
/// <summary>Determines whether the request body should be buffered before sending.</summary>
68-
/// <param name="restMethod">The rest method being invoked.</param>
69-
/// <param name="request">The built request message.</param>
70-
/// <returns><see langword="true"/> if the body should be buffered; otherwise <see langword="false"/>.</returns>
71-
private static bool IsBodyBuffered(
72-
RestMethodInfoInternal restMethod,
73-
HttpRequestMessage request) =>
74-
(restMethod.BodyParameterInfo?.Item2 ?? false) && request.Content is not null;
75-
7676
/// <summary>Builds a delegate that constructs and returns the request for a <c>Task&lt;HttpRequestMessage&gt;</c> method.</summary>
7777
/// <param name="restMethod">The rest method to build a delegate for.</param>
7878
/// <returns>A delegate that returns a task producing the built request without sending it.</returns>
7979
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
80-
private Func<HttpClient, object[], object?> BuildRequestMessageFuncForMethod(
80+
internal Func<HttpClient, object[], object?> BuildRequestMessageFuncForMethod(
8181
RestMethodInfoInternal restMethod) =>
8282
(client, paramList) => BuildRequestMessageWithoutSendingAsync(client, restMethod, paramList);
8383

@@ -90,7 +90,7 @@ private static bool IsBodyBuffered(
9090
/// logging, or manual dispatch. Any configured async authorization token getter runs at dispatch time and is
9191
/// therefore not applied to a request obtained this way.</remarks>
9292
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
93-
private Task<HttpRequestMessage> BuildRequestMessageWithoutSendingAsync(
93+
internal Task<HttpRequestMessage> BuildRequestMessageWithoutSendingAsync(
9494
HttpClient client,
9595
RestMethodInfoInternal restMethod,
9696
object[] paramList)
@@ -113,7 +113,7 @@ private Task<HttpRequestMessage> BuildRequestMessageWithoutSendingAsync(
113113
/// <param name="cancellationToken">A token to cancel the request.</param>
114114
/// <returns>A task that completes when the request finishes.</returns>
115115
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
116-
private async Task ExecuteVoidRequestAsync(
116+
internal async Task ExecuteVoidRequestAsync(
117117
HttpClient client,
118118
RestMethodInfoInternal restMethod,
119119
object[] paramList,
@@ -154,7 +154,7 @@ await RequestExecutionHelpers.SendVoidAsync(
154154
"SST2307:Generic method type parameters should be inferable from the parameters",
155155
Justification = "Type parameter intentionally specified explicitly by callers.")]
156156
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
157-
private async Task<T?> ExecuteRequestAsync<T, TBody>(
157+
internal async Task<T?> ExecuteRequestAsync<T, TBody>(
158158
HttpClient client,
159159
RestMethodInfoInternal restMethod,
160160
object[] paramList,
@@ -192,7 +192,7 @@ await RequestExecutionHelpers.SendVoidAsync(
192192
Justification = "Type parameter intentionally specified explicitly by callers.")]
193193
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
194194
[ExcludeFromCodeCoverage]
195-
private async IAsyncEnumerable<T?> ExecuteAsyncEnumerableRequestAsync<T>(
195+
internal async IAsyncEnumerable<T?> ExecuteAsyncEnumerableRequestAsync<T>(
196196
HttpClient client,
197197
RestMethodInfoInternal restMethod,
198198
object[] paramList,
@@ -218,7 +218,7 @@ await RequestExecutionHelpers.SendVoidAsync(
218218
"SST2307:Generic method type parameters should be inferable from the parameters",
219219
Justification = "Type parameter intentionally specified explicitly by callers.")]
220220
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
221-
private Func<HttpClient, CancellationToken, object[], Task<T?>> BuildCancellableTaskFuncForMethod<T, TBody>(
221+
internal Func<HttpClient, CancellationToken, object[], Task<T?>> BuildCancellableTaskFuncForMethod<T, TBody>(
222222
RestMethodInfoInternal restMethod) =>
223223
async (client, ct, paramList) =>
224224
{
@@ -254,7 +254,7 @@ await RequestExecutionHelpers.SendVoidAsync(
254254
"Design",
255255
"SST2307:Generic method type parameters should be inferable from the parameters",
256256
Justification = "Type parameter intentionally specified explicitly by callers.")]
257-
private Task<T?> SendAndProcessResponseAsync<T, TBody>(
257+
internal Task<T?> SendAndProcessResponseAsync<T, TBody>(
258258
HttpClient client,
259259
RestMethodInfoInternal restMethod,
260260
HttpRequestMessage request,

0 commit comments

Comments
 (0)