Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid allocations when chaining contexts #2613

Merged
merged 2 commits into from Mar 24, 2024

Conversation

jnyrup
Copy link
Member

@jnyrup jnyrup commented Mar 24, 2024

ba6028b:
#2607 added chaining of nested contexts.

It always creates a new Lazy<string> but we do allow a null Context

var identifier = Context?.Value;

So if both context are null, we can then then avoid creating a new context.

Also if one of the contexts are null, we can just return the other one.

Now we only need to create a new context in the last case where we have two non-null contexts.
As the lambda passed to the new Lazy<string> takes a closure over the other contexts, one needs to take some care to avoid always allocating the closure object.

See how Bar is the only variant that is non-allocating when either contexts are null.

Method a b Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
Dev ? ? 20.6564 ns 0.2046 ns 0.1814 ns 1.00 0.00 0.0268 168 B 1.00
Foo ? ? 3.0756 ns 0.0601 ns 0.0562 ns 0.15 0.00 0.0051 32 B 0.19
Bar ? ? 0.5815 ns 0.0201 ns 0.0178 ns 0.03 0.00 - - 0.00
Baz ? ? 5.5472 ns 0.0305 ns 0.0255 ns 0.27 0.00 0.0051 32 B 0.19
Dev ? inner 20.6674 ns 0.0672 ns 0.0561 ns 1.00 0.00 0.0268 168 B 1.00
Foo ? inner 3.7963 ns 0.0971 ns 0.0909 ns 0.18 0.00 0.0051 32 B 0.19
Bar ? inner 1.5008 ns 0.0229 ns 0.0191 ns 0.07 0.00 - - 0.00
Baz ? inner 6.3278 ns 0.0979 ns 0.0916 ns 0.31 0.00 0.0051 32 B 0.19
Dev outer ? 21.0130 ns 0.1855 ns 0.1645 ns 1.00 0.00 0.0268 168 B 1.00
Foo outer ? 3.8222 ns 0.0425 ns 0.0397 ns 0.18 0.00 0.0051 32 B 0.19
Bar outer ? 1.4970 ns 0.0402 ns 0.0356 ns 0.07 0.00 - - 0.00
Baz outer ? 6.1180 ns 0.0834 ns 0.0740 ns 0.29 0.00 0.0051 32 B 0.19
Dev outer inner 22.6892 ns 0.2077 ns 0.1841 ns 1.00 0.00 0.0268 168 B 1.00
Foo outer inner 19.2641 ns 0.1288 ns 0.1142 ns 0.85 0.00 0.0268 168 B 1.00
Bar outer inner 19.5794 ns 0.4315 ns 0.4036 ns 0.86 0.02 0.0268 168 B 1.00
Baz outer inner 19.7202 ns 0.2628 ns 0.2194 ns 0.87 0.01 0.0268 168 B 1.00
code
BenchmarkRunner
    .Run<SwitchBenchmark>(
        ManualConfig
        .CreateMinimumViable()
        .AddValidator(DeferredExecutionValidator.DontFailOnError)
        .AddDiagnoser(MemoryDiagnoser.Default)
    );

public class SwitchBenchmark
{
    public IEnumerable<object?[]> Values =>
    [
        [null, null],
        [new Lazy<string>("outer"), null],
        [null, new Lazy<string>("inner")],
        [new Lazy<string>("outer"), new Lazy<string>("inner")]
    ];

    [Benchmark(Baseline = true)]
    [ArgumentsSource(nameof(Values))]
    public Lazy<string>? Dev(Lazy<string>? a, Lazy<string>? b) => Impl.Dev(a, b);

    [Benchmark]
    [ArgumentsSource(nameof(Values))]
    public Lazy<string>? Foo(Lazy<string>? a, Lazy<string>? b) => Impl.Foo(a, b);

    [Benchmark]
    [ArgumentsSource(nameof(Values))]
    public Lazy<string>? Bar(Lazy<string>? a, Lazy<string>? b) => Impl.Bar(a, b);

    [Benchmark]
    [ArgumentsSource(nameof(Values))]
    public Lazy<string>? Baz(Lazy<string>? a, Lazy<string>? b) => Impl.Baz(a, b);
}

static class Impl
{
    public static Lazy<string> Dev(Lazy<string>? outer, Lazy<string>? inner)
    {
        return new Lazy<string>(() => JoinContext(outer, inner));

        static string JoinContext(params Lazy<string>?[] contexts) =>
                string.Join("/", contexts.Where(ctx => ctx is not null).Select(x => x.Value));
    }

    public static Lazy<string>? Foo(Lazy<string>? outer, Lazy<string>? inner) => (outer, inner) switch
    {
        (null, null) => null,
        ({ } a, null) => a,
        (null, { } b) => b,
        ({ } a, { } b) => new(() => a.Value + "/" + b.Value)
    };

    public static Lazy<string>? Bar(Lazy<string>? outer, Lazy<string>? inner)
    {
        return (outer, inner) switch
        {
            (null, null) => null,
            ({ } a, null) => a,
            (null, { } b) => b,
            ({ } a, { } b) => Combine(a, b)
        };

        static Lazy<string> Combine(Lazy<string> outer, Lazy<string> inner) =>
            new(() => outer.Value + "/" + inner.Value);
    }

    public static Lazy<string>? Baz(Lazy<string>? outer, Lazy<string>? inner)
    {
        if (outer is null)
        {
            if (inner is not null)
            {
                return inner;
            }

            return null;
        }

        if (inner is null)
        {
            return outer;
        }

        return new(() => outer.Value + "/" + inner.Value);
    }
}

694ca94:
In #1939 (comment) I avoided allocating a new lambda closure object per iteration using, by creating a Func outside the loop, which the compiler sees at non-capturing and then allocates at most in a static field.
We can improve this by utilizing local functions, which is also marked as static to enforce it doesn't take any closure.

Inspect the lowered code in SharpLab to see the differences between using a lambda directly inside the loop, creating a Func outside the loop and now using a static local function.

Benchmarking:

Method Step Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
Func ? 0.6533 ns 0.0122 ns 0.0108 ns 1.00 0.00 - - NA
LocalFunction ? 0.0396 ns 0.0101 ns 0.0095 ns 0.06 0.01 - - NA
Func System.Object 9.8370 ns 0.1420 ns 0.1259 ns 1.00 0.00 0.0140 88 B 1.00
LocalFunction System.Object 9.3965 ns 0.0707 ns 0.0662 ns 0.96 0.02 0.0140 88 B 1.00
code
BenchmarkRunner.Run<Allocations>();

[MemoryDiagnoser]
public class Allocations
{
    [ParamsSource(nameof(ValuesForStep))]
    public object? Step { get; set; }

    public IEnumerable<object?> ValuesForStep => [new(), null];

    [Benchmark(Baseline = true)]
    public object? Func() => TryToProveNodesAreEquivalent(Step);

    [Benchmark]
    public object? LocalFunction() => TryToProveNodesAreEquivalent_New(Step);

    public object? TryToProveNodesAreEquivalent(object? step)
    {
        Func<object, GetTraceMessage> getMessage = static step => _ => $"Equivalency was proven by {step.GetType().Name}";

        if (step is not null)
        {
            return getMessage(step);
        }

        return null;
    }

    public object? TryToProveNodesAreEquivalent_New(object? step)
    {
        if (step is not null)
        {
            return GetMessage(step);

            static GetTraceMessage GetMessage(object step) =>
                _ => $"Equivalency was proven by {step.GetType().Name}";
        }

        return null;
    }

    public delegate string GetTraceMessage(object node);
}

IMPORTANT

  • If the PR touches the public API, the changes have been approved in a separate issue with the "api-approved" label.
  • The code complies with the Coding Guidelines for C#.
  • The changes are covered by unit tests which follow the Arrange-Act-Assert syntax and the naming conventions such as is used in these tests.
  • If the PR adds a feature or fixes a bug, please update the release notes with a functional description that explains what the change means to consumers of this library, which are published on the website.
  • If the PR changes the public API the changes needs to be included by running AcceptApiChanges.ps1 or AcceptApiChanges.sh.
  • If the PR affects the documentation, please include your changes in this pull request so the documentation will appear on the website.
    • Please also run ./build.sh --target spellcheck or .\build.ps1 --target spellcheck before pushing and check the good outcome

Copy link

github-actions bot commented Mar 24, 2024

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@coveralls
Copy link

coveralls commented Mar 24, 2024

Pull Request Test Coverage Report for Build 8409588228

Details

  • 11 of 11 (100.0%) changed or added relevant lines in 2 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.002%) to 97.557%

Totals Coverage Status
Change from base Build 8361920663: 0.002%
Covered Lines: 12003
Relevant Lines: 12187

💛 - Coveralls

@dennisdoomen dennisdoomen merged commit 30ea6a2 into fluentassertions:develop Mar 24, 2024
7 checks passed
@jnyrup jnyrup deleted the JoinContexts branch March 24, 2024 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants