perf: cache reflection results to eliminate PropertyInfo regeneration overhead#3118
perf: cache reflection results to eliminate PropertyInfo regeneration overhead#3118baal2000 wants to merge 2 commits intogoogleapis:mainfrom
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
Summary of ChangesHello @baal2000, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly improves the performance of API request parameter processing by introducing a robust caching mechanism for reflection results. Previously, reflection operations were performed repeatedly, leading to potential performance bottlenecks. The new ReflectionCache efficiently stores property and attribute information, reducing allocation and GC pressure. Additionally, the PR centralizes and refines the logic for expanding enumerable parameters, ensuring consistent and correct serialization. These internal changes enhance the library's efficiency without altering its public API surface, providing a more optimized experience for users. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This is an excellent pull request that addresses a significant performance issue by caching reflection results. The introduction of ReflectionCache is well-implemented using a ConcurrentDictionary for thread safety. I appreciate the refactoring to centralize parameter expansion logic in ParameterUtils, which improves code reuse and maintainability across ParameterCollection and ResumableUpload. The changes are supported by a comprehensive set of new tests that cover caching, parameter expansion, and edge cases. Overall, this is a high-quality contribution. I have one minor suggestion to simplify a LINQ expression for improved readability.
|
Thanks for this. It might still be a few days before we can get to looking into this issue and possibly reviewing the PR as we have other priorities at the moment. Also, you'd need to sign the CLA if you wish to contribute. |
2ad0142 to
87822eb
Compare
87822eb to
724064e
Compare
|
@amanda-tarafa I am still working on submitting a CLA for my employer: not a very straightforward process TBH. Updatethis has been resolved, I am now covered by an CLA. |
724064e to
398b9f1
Compare
Thanks, it might still be a few days until we can get to this. |
|
@amanda-tarafa Is there an ETA for reviewing this? The slow finalizer issue has been resolved in our environment as a fixed forked library. It is a cumbersome solution though when it comes to overriding a public higher level package dependency with a private lower level one. We would like get back to the public as soon as possible. |
|
Thanks, I'll try to look this week, but I can't make any promises. |
|
Hi @amanda-tarafa , friendly follow-up — it's been about a week since you mentioned you'd try to look at this. I understand the team has priorities, but I want to flag the severity: this bug causes 30-60 second application freezes in production due to uncached reflection flooding the finalizer thread with DestroyScout cleanup. It affects any application making repeated API calls, which is... most of them. We're currently running a private fork to work around it, which is not ideal for either side. Regarding PR size — the 692 added lines may look large, but ~370 of those are tests (which the affected code previously lacked). The actual fix is a ~62-line ReflectionCache class plus straightforward refactoring of the two call sites. Thanks for your time. |
|
Again, I'm sorry, I will try to look at this as soon as possible but it's not high priority for us. I'm less concerned about the PR size and more about understanding the problem and the proposed solution. It's definitely on my radar. |
|
The issue has a full root cause analysis with traces: #3112. That should cover the understanding part. Let me know if you have questions after reading it. |
|
|
||
| using System.Runtime.CompilerServices; | ||
|
|
||
| [assembly: InternalsVisibleTo("Google.Apis,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] |
There was a problem hiding this comment.
We wouldn't do this. We don't want to force version dependencies based on internal surface. Either we make some of this public or the cache is duplicated in both packages, which would still solve your issue.
There was a problem hiding this comment.
I would prefer making the classes public. While code duplication avoids public API commitment, the drawbacks outweigh that benefit:
- Double memory usage (~84 KB vs ~42 KB)
- Code duplication across assemblies
- Ongoing maintenance burden
There was a problem hiding this comment.
Previously-internal types (ReflectionCache, PropertyWithAttribute, InitParametersWithExpansion) are now public, which allowed removing the InternalsVisibleTo("Google.Apis", …) attribute.
| /// This cache is intentionally unbounded and keyed by request type. The set of request types decorated with | ||
| /// <see cref="RequestParameterAttribute"/> is expected to be finite and stable for the lifetime of the application. |
There was a problem hiding this comment.
This worries me, it's definitely finite, and eventually stable, but I'm not sure it' small enough in all cases that it wouldn't cause issues. For instance, imagine a CI that tests all the 400+ libraries, and makes a request for each of the methods on each of the libraries. Plus, this adds a bottleneck (you need the concurrent dictionary) that will affect 100% of requests, and even if your app makes a single type of request, on high loads latency may increase.
These are all corner cases, but yours is also a corner case (no one has encountered this issue in 10+ years) so I'm wary of swapping the implementation in ways that may create other problems.
A solution where calling code can select beforehand whether to use a cache or not is preferable. The default implementation would continue to be the one we currently have. I will think about how that looks like.
There was a problem hiding this comment.
Let me know what your approach should look like and I am going to follow. We probably need to resolve this before addressing that other concern by duplicating the cache or making it public.
There was a problem hiding this comment.
I'd suggest making the cache opt-in through a public static property that controls whether caching is enabled:
public static class ReflectionCacheSettings
{
/// <summary>
/// Gets or sets whether to enable reflection caching for request parameters.
/// Default is false. Set to true early in application startup to improve performance
/// when making many requests with the same request types.
/// </summary>
public static bool EnableReflectionCache { get; set; } = false;
}
internal static class ReflectionCache
{
...
internal static PropertyWithAttribute[] GetRequestParameterProperties(Type type)
{
if (ReflectionCacheSettings.EnableReflectionCache)
{
return RequestParameterPropertiesCache.GetOrAdd(type, ComputeProperties);
}
// Default behavior: no caching
return ComputeProperties(type);
}
private static PropertyWithAttribute[] ComputeProperties(Type type)
{
return type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Select(prop => new PropertyWithAttribute(prop, prop.GetCustomAttribute<RequestParameterAttribute>(inherit: false)))
.Where(pwa => pwa.Attribute != null)
.ToArray();
}
}
For the following reasons:
- Matches the requirement of the default behavior left unchanged, opt-in mechanism
- Transparent and simple for users: one line of code at startup to enable
- No public API surface changes: the ReflectionCacheSettings class would be new and public, but it doesn't change any existing APIs
There was a problem hiding this comment.
I've implemented the opt-in approach. The cache is now disabled by default and enabled with:
ApplicationContext.EnableReflectionCache = true;
rather than introducing a new ReflectionCacheSettings class, A property in the existing ApplicationContext is consistent with the library's established pattern for startup configuration, avoids new public API surface and is easier to obsolete later if caching becomes the default.
A couple of clarifications:
- On the CI scenario: The cache is per-process and keyed by
Type. Only properties decorated withRequestParameterAttributeare cached — not all properties on a type, and not all types in an assembly. Even a single process exercising all 400+ libraries would accumulate a few thousand entries in the worst case (trivial memory). When CI runs each library in a separate process, each process holds only the request types it tests — a much smaller subset — and the memory is reclaimed when the process exits. Without the cache, the runtime already creates transientPropertyInfoarrays and IL stubs per request that are temporarily stored in an internal weakly-referenced cache — which itself adds GC pressure and is the root cause of theDestroyScoutfinalizer delays under the linked issue. - On
ConcurrentDictionary: GetOrAddon a hit is aVolatile.Read— no lock taken, ~20–50 ns. The un-cached path (GetProperties+GetCustomAttributeper request) costs more than that. This is the standard pattern used by ASP.NET Core, EF Core, and System.Text.Json for reflection caching.
df73cc0 to
f02c330
Compare
…l API to public
Make reflection cache opt-in via ApplicationContext.EnableReflectionCache (default false), following the library's established startup-configuration pattern. Promote ReflectionCache, PropertyWithAttribute, and ParameterUtils.InitParametersWithExpansion to public; remove InternalsVisibleTo("Google.Apis", …) from AssemblyInfo.
f02c330 to
725ecdd
Compare
|
Just to say, this is on my radar still. I'll take a look this week, but also, we are considering an alternative where we generate this information so it's statically included on each request type. I believe that would be the best solution but also the most involved in terms of work and release complexity. Can I ask which libraries exactly are you using, that is, for which APIs. |
|
Per #3112: Google.Apis.Upload.ResumableUpload.SetAllPropertyValues and Google.Apis.Requests.Parameters.ParameterUtils.IterateParameters Higher level APIs: |
I was looking for this, thanks. |
Could we apply the narrowly scoped reflection cache patch and then think of larger refactoring? |
Fixes #3112
Problem Summary
Repeated reflection (property enumeration plus attribute lookup) was being performed on every request. In some workloads this can create significant allocation/JIT/GC pressure and contribute to long Gen 2 GC pauses.
Solution
ReflectionCache infrastructure (internal, in Google.Apis.Core)
PropertyInfoandRequestParameterAttributeper request type usingConcurrentDictionary<Type, ...>RequestParameterAttribute, which are expected to be a finite set for the lifetime of an applicationType-preserving parameter expansion
ExpandParametersWithTypes()expandsIEnumerable/Repeatable<T>values into repeated parameters while preservingRequestParameterType(Query/Path/etc.)Dual InitParameters methods
ParameterUtils.InitParameters(...)(public): preserved for backward compatibility; does not expandIEnumerableby default to avoid changing existing serialization semanticsParameterUtils.InitParametersWithExpansion(...)(internal): usesReflectionCacheand expandsIEnumerable/Repeatable<T>while preservingRequestParameterTypeInternalsVisibleToBackward compatibility
InitParametersunchanged)Testing
Added 16 tests covering:
IEnumerable/Repeatable<T>expansionUtilities.ConvertToString(null))All new types are internal; no public API surface changes.