Skip to content

Conversation

@Sergio0694
Copy link
Contributor

What kind of change does this PR introduce?

This PR includes a few speed optimizations and memory usage reduction. It does so by employing a better caching system for generated rest methods, and by creating specific static fields/types to store reusable type info for the various generated methods, avoiding repeated allocations.

Current/past behavior comparison
As mentioned above, there are a number of improvements included in this PR:

Use Array.Empty() when possible

The current version of refit ends up allocating a lot of empty arrays in the generated services. This results in repeated allocations that can be skipped entirely by using Array.Empty<T>() instead:

// BEFORE
Task IRestService.Foo()
{
    var arguments = new object[] {  };
    var func = requestBuilder.BuildRestResultFuncForMethod("Foo", new Type[] {  });
    return (Task)func(Client, arguments);
}
// AFTER
Task IRestService.Foo()
{
    var arguments = Array.Empty<object>();
    var func = requestBuilder.BuildRestResultFuncForMethod("Foo", Array.Empty<Type>());
    return (Task)func(Client, arguments);
}
Use static cached fields for reused argument lists

Right now refit ends up allocating a new array to represent the types of the parameters for each method being used. This array is passed to the IRequestBuilder method to build the rest function, and results in repeated allocations for what is essentially the same exact array every time. This PR solves this issue by just using a dedicated field for each generated method, storing a reusable instance of that array:

// BEFORE
Task IRestService.Foo(PathBoundObject request, string someProperty)
{
    var arguments = new object[] { request, someProperty };
    var func = requestBuilder.BuildRestResultFuncForMethod("Foo", new Type[] { typeof(PathBoundObject), typeof(string) });
    return (Task)func(Client, arguments);
}
// AFTER
private static readonly Type[] ArgumentTypes_18675fb6c1f94a68be274f15250f3c4c = new Type[] { typeof(PathBoundObject), typeof(string) };

Task IRestService.Foo(PathBoundObject request, string someProperty)
{
    var arguments = new object[] { request, someProperty };
    var func = requestBuilder.BuildRestResultFuncForMethod("Foo", ArgumentTypes_18675fb6c1f94a68be274f15250f3c4c);
    return (Task)func(Client, arguments);
}
Use static cached fields for reused type parameters lists

Same concept as before, but for type parameters. In this case we can't just use a field, as the concrete type parameters are not known at compile time. In order to still use the previous caching system, in this case refit will now generate a dedicated generic type that will act as a container for the static fields storing the reusable arrays. This lets us use this technique even for generic methods:

// BEFORE
Task<TValue> IRestService.Foo<TResponse, TParam, THeader>.Get<TValue, TInput>(TInput input)
{
    var arguments = new object[] { input };
    var func = requestBuilder.BuildRestResultFuncForMethod("Get",
        new Type[] { typeof(TInput) },
        new Type[] { typeof(TValue), typeof(TInput) });
    return (Task<TValue>)func(Client, arguments);
}
// AFTER
private static class TypeHelper_531f6c2269294e40b1eb070dda401682<TValue, TInput>
{
    public static readonly Type[] ArgumentTypes = new Type[] { typeof(TInput) };
    public static readonly Type[] TypeParameters = new Type[] { typeof(TValue), typeof(TInput) };
}

/// <inheritdoc />
Task<TValue> IUseOverloadedGenericMethods<TResponse, TParam, THeader>.Get<TValue, TInput>(TInput input)
{
    var arguments = new object[] { input };
    var func = requestBuilder.BuildRestResultFuncForMethod("Get",
        TypeHelper_531f6c2269294e40b1eb070dda401682<TValue, TInput>.ArgumentTypes,
        TypeHelper_531f6c2269294e40b1eb070dda401682<TValue, TInput>.TypeParameters);
    return (Task<TValue>)func(Client, arguments);
}
Better caching in default IRequestBuilder implementation

The current RequestBuilderImplementation class was doing some repeated work every time the IRequestBuilder.BuildRestResultFuncForMethod method was being called (specifically, quite a bit of reflection, and some unnecessary closures being allocated). This PR fixes that by using the HashCode type to quickly calculate a method-specific hash code used as key for the cached Func<HttpClient, object[], object> instance to retrieve, and then includes a fast path to quickly get the cached instance if present and return it immediately.

What might this PR break?
There should be no functional differences, and all the tests are passing.

Please check if the PR fulfills these requirements

  • Tests for the changes have been added (for bug fixes / features)
  • Docs have been added / updated (for bug fixes / features)

Other information:
cc. @clairernovotny @Dreamescaper @martincostello

@Dreamescaper
Copy link
Contributor

Can't you use same approach with static field with Id for ResultFunc altogether? (Probably as Lazy)

@Sergio0694
Copy link
Contributor Author

Sergio0694 commented Apr 8, 2020

@Dreamescaper That was my original idea and I tried that multiple times, but I couldn't get it to work, some tests just kept failing. I thought these changes would be a nice improvement on their own anyway, even if just as a stopgap in case we manage to get that working 🤔

I'll give it another go just in case though 😄

To be honest, I've never been a big fan of the current infrastructure in the first place, from a performance standpoint. I mean, don't get me wrong, I can see how that makes things easier to manage and I'm not criticizing anyone's work, but if this whole lib were to be rebuilt from scratch, ideally I'd just want to see each method having its strongly typed implementation directly from there, without any reflection or dynamic delegate creation being necessary at all. I guess that'd be fairly more complicated to do, not to mention it'd be a breaking change at this point.

@Dreamescaper
Copy link
Contributor

Generally I like those improvements.
Have two concerns

  1. Not sure you can rely solely on HashCode, as collisions are technically possible.
  2. More related specifically to this repository. Currently RefitStubs are committed to repository, even though they are generated on build. With this change it will lead to RefitStubs changed in each PR. Thinking whether it could be avoided somehow.

@clairernovotny
Copy link
Member

We will be wanting to support the C# 9 generators proposal and will need a pretty big rewrite to support that. I suspect that a lot of what we do at runtime could be generated code.

We'll need help with all of that, I myself am not an expert in Roslyn enough to write a generator.

@clairernovotny
Copy link
Member

Since this is looking at perf, any chance of getting a BenchmarkDotnet project wired up so we can measure before and after? It would be helpful to track improvements and regressions, along with identifying places that can be improved.

@Sergio0694
Copy link
Contributor Author

@Dreamescaper About the HashCode, you're technically correct, though I'm not sure of another equivalent solution with no performance hit there. I mean, chances of a collision when using the HashCode class in this scenario should be extremely small, but still... 🤔

As for the random ids creating diff changes in the stubs at every rebuild, yup I've noticed that too. I'll see if I can fix that by generating a deterministic id from the stubs project that's just based on the other properties of each method, instead of just using a new Guid every time.

@clairernovotny I've heard about the "code generators" coming in C# 9 but I'm not familiar with them, and I can't find the related issue right now, would you have a link? Also, that sounds great!

As for BenchmarkDotNet, sure, do you have suggestions of what to test in particular? Or should I just reuse some code from random test methods? Not sure what the best approach would be.

@Dreamescaper
Copy link
Contributor

Dreamescaper commented Apr 8, 2020

I would suggest to use struct similar to CloseGenericMethodKey, but with methodName, parameterTypes, genericArgumentTypes.
And use it as your dictionary Key.

(That won't be needed, however, at all, if idea with static field for ResultFunc works)

@Sergio0694
Copy link
Contributor Author

@Dreamescaper Fair enough, I'll try that!
In the meantime I've followed your advice and made the ids deterministic in f8aad64 😄

@clairernovotny
Copy link
Member

clairernovotny commented Apr 8, 2020

Here's a link to the current generators design: https://github.com/dotnet/roslyn/blob/master/docs/features/source-generators.md

As for which tests we should benchmark, I think we should have several...

Ones that use Open Generics, ones that use concrete types, ones that return ApiRepsonse, ones that return strongly typed content, etc.

There can be a few tests that are just about creation of the proxy -- RestService.For, and others that are about invoking it using the MockHttpHandler, so we can exercise the end-to-end.

Basically, let's look at normal usage, where we expect normal usage.

GitHub
The Roslyn .NET compiler provides C# and Visual Basic languages with rich code analysis APIs. - dotnet/roslyn

@Sergio0694
Copy link
Contributor Author

@Dreamescaper I removed the collision risk in 42f4d2b, is this what you had in mind? While I was at it I also realized we could skip the iteration on the arrays as well, since we're using static arrays now.
Let me know what you think! 😊

@Sergio0694 Sergio0694 mentioned this pull request Apr 8, 2020
2 tasks
@clairernovotny clairernovotny changed the base branch from master to main November 13, 2020 13:49
@clairernovotny
Copy link
Member

Closing this due to age and conflicts. I'd love to see any additional work done to improve perf and memory usage though!

@Sergio0694
Copy link
Contributor Author

Sorry, have been a bit busy lately and kinda forgot about this PR 😅

In hindsight, the improvements in this PR were nice to have, but really just a band-aid. While I do love refit and I think it's awesome, I feel like its architecture is really starting to show its age at this point, and in particular due to the fact that it has a very high reliance on runtime reflection and code generation. I've just checked the generated code again for a simple API:

partial class GitHubAPIsIGitHubService : global::GitHub.APIs.IGitHubService
{
    public global::System.Net.Http.HttpClient Client { get; }
    readonly global::Refit.IRequestBuilder requestBuilder;

    public GitHubAPIsIGitHubService(global::System.Net.Http.HttpClient client, global::Refit.IRequestBuilder requestBuilder)
    {
        Client = client;
        this.requestBuilder = requestBuilder;
    }
    
    public global::System.Threading.Tasks.Task<global::GitHub.Models.User> GetUserAsync(string @username) 
    {
        var ______arguments = new object[] { @username };
        var ______func = requestBuilder.BuildRestResultFuncForMethod("GetUserAsync", new global::System.Type[] { typeof(string) } );
        return (global::System.Threading.Tasks.Task<global::GitHub.Models.User>)______func(this.Client, ______arguments);
    }
}

This is not efficient at all, and likely a complete no-go in both AOT scenarios as well as when using trimming.
What I'm trying to say is, we're using source generators now, but not really leveraging them in the correct way, as the code being generated is still following "old" patterns (carried over from when we were using a build task I imagine), and not really using a reflection-free and AOT-friendly approach as a core principle. Don't get me wrong, I do like refit a lot, and it's still my go-to library whenever I want to write a simple REST API provider. What I'm trying to say is just that I don't think a re-do of this PR, or anything similar, would really help much at the end of the day. In the long term I think we should rather start thinking of a complete overhaul of the generated code, to make the library a good choice for all developers even in those other scenarios 🙂

Maybe it would help if I opened an issue to use as a starting point for future planning?
And I guess also to see whether there's actual interest in something like this 😄

@anaisbetts
Copy link
Member

anaisbetts commented Jan 20, 2022

This is not efficient at all

Literally anything we do in Refit is nothing compared to the time it takes to actually make a network call and complete it. This is the equivalent of trying to speed up your International flight by brushing your teeth faster in the morning before you leave

likely a complete no-go in both AOT scenarios as well as when using trimming.

Have we verified this? Afaik the whole reason we use source generation (this was pre Source Generators) was to be compatible with AOT. It would be Unfortunate if the move to official Source Generators broke this scenario

@Sergio0694
Copy link
Contributor Author

Sergio0694 commented Jan 20, 2022

The fact the generated code is not efficient was just a (small) part of the issue I've mentioned. The main issue is that the generated code is a no-go for AOT and for developers wanting to use trimming. This has nothing to do with the time spent there being negligible compared to the time it takes to make network calls. This is not about making the code faster (which would still be nice to have), but just something that could effectively block people.

"Have we verified this?"

I've tested this with .NET Native: stripping reflection metadata completely explodes at runtime, because the linker cannot statically analyze the whole dependency graph of the code refit generates, for instance. I'd expect pretty much the same to also happen on AOT and on CoreCLR with trimming enable. This has been a major focus lately, see eg. System.Text.Json.

"the whole reason we use source generation (this was pre Source Generators) was to be compatible with AOT"

Source generators only help AOT if the code they produce can then be statically analyzed. If you just generate code that still relies on reflection at runtime, then the fact you're generating that code using source generating is completely irrelevant.

"It would be Unfortunate if the move to official Source Generators broke this scenario"

No, refit has always been broken in this sense. I'm just saying this wasn't improved, but it'd be nice to do so 🙂

@github-actions
Copy link

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Apr 13, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants