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

Improve performance of UriHelper.GetDisplayUrl #55611

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

paulomorgado
Copy link
Contributor

@paulomorgado paulomorgado commented May 8, 2024

Replaced StringBuilder concatenation with the more efficient string.Create method for creating new strings by concatenating scheme, host, pathBase, path, and queryString. This method reduces overhead by avoiding multiple string concatenations and allocating the correct buffer size for the new string. The CopyTo method is used in a callback function to copy each string to the new string, slicing the buffer to remove the copied part. The SchemeDelimiter is always copied to the new string, regardless of its length. This change enhances the performance of the code.

Improve performance of UriHelper.GetDisplayUrl

  • You've read the Contributor Guide and Code of Conduct.
  • You've included unit or integration tests for your change, where applicable.
  • You've included inline docs for your change, where applicable.
  • There's an open issue for the PR that you are making. If you'd like to propose a new feature or change, please open an issue to discuss the change or find an existing issue.

Summary

UriHelper.GetDisplayUrl uses a non-pooled StringBuilder that is instantiated on every invocation. Although optimized in size, it is a heap allocation with an intermediary buffer.

public static string GetDisplayUrl(this HttpRequest request)
{
    var scheme = request.Scheme ?? string.Empty;
    var host = request.Host.Value ?? string.Empty;
    var pathBase = request.PathBase.Value ?? string.Empty;
    var path = request.Path.Value ?? string.Empty;
    var queryString = request.QueryString.Value ?? string.Empty;

    // PERF: Calculate string length to allocate correct buffer size for StringBuilder.
    var length = scheme.Length + SchemeDelimiter.Length + host.Length
        + pathBase.Length + path.Length + queryString.Length;

    return new StringBuilder(length)
        .Append(scheme)
        .Append(SchemeDelimiter)
        .Append(host)
        .Append(pathBase)
        .Append(path)
        .Append(queryString)
        .ToString();
}

Motivation and goals

This method is frequently used in hot paths like redirect and rewrite rules.

From the benchmarks below, we can see that, compared to the current implementation using a StringBuilder with enough capacity, string interpolation is around 3 times better in terms of duration and around 4 times in memory used.

String.Create is even more performant.

Benchmarks

BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3593/23H2/2023Update/SunValley3)
13th Gen Intel Core i9-13900K, 1 CPU, 32 logical and 24 physical cores
.NET SDK 9.0.100-preview.4.24267.66
  [Host]     : .NET 9.0.0 (9.0.24.26619), X64 RyuJIT AVX2
  DefaultJob : .NET 9.0.0 (9.0.24.26619), X64 RyuJIT AVX2

Method scheme host basePath path query Mean Ratio Gen0 Allocated Alloc Ratio
StringBuilder http cname.domain.tld **** / **** 67.161 ns 1.00 0.0288 544 B 1.00
String_Interpolation http cname.domain.tld / 24.606 ns 0.37 0.0038 72 B 0.13
String_Concat http cname.domain.tld / 23.160 ns 0.34 0.0038 72 B 0.13
String_Create http cname.domain.tld / 9.903 ns 0.15 0.0038 72 B 0.13
StringBuilder http cname.domain.tld **** / ?para(...)alue3 [42] 92.873 ns 1.00 0.0446 840 B 1.00
String_Interpolation http cname.domain.tld / ?para(...)alue3 [42] 26.817 ns 0.29 0.0085 160 B 0.19
String_Concat http cname.domain.tld / ?para(...)alue3 [42] 25.303 ns 0.27 0.0085 160 B 0.19
String_Create http cname.domain.tld / ?para(...)alue3 [42] 11.978 ns 0.13 0.0085 160 B 0.19
StringBuilder http cname.domain.tld **** /path/one/two/three **** 74.314 ns 1.00 0.0314 592 B 1.00
String_Interpolation http cname.domain.tld /path/one/two/three 23.582 ns 0.32 0.0059 112 B 0.19
String_Concat http cname.domain.tld /path/one/two/three 35.836 ns 0.48 0.0059 112 B 0.19
String_Create http cname.domain.tld /path/one/two/three 9.352 ns 0.13 0.0059 112 B 0.19
StringBuilder http cname.domain.tld **** /path/one/two/three ?para(...)alue3 [42] 93.593 ns 1.00 0.0467 880 B 1.00
String_Interpolation http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 28.930 ns 0.31 0.0102 192 B 0.22
String_Concat http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 41.730 ns 0.45 0.0102 192 B 0.22
String_Create http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 13.065 ns 0.14 0.0102 192 B 0.22
StringBuilder http cname.domain.tld /base-path / **** 71.984 ns 1.00 0.0305 576 B 1.00
String_Interpolation http cname.domain.tld /base-path / 23.342 ns 0.32 0.0051 96 B 0.17
String_Concat http cname.domain.tld /base-path / 20.272 ns 0.28 0.0051 96 B 0.17
String_Create http cname.domain.tld /base-path / 10.282 ns 0.14 0.0051 96 B 0.17
StringBuilder http cname.domain.tld /base-path / ?para(...)alue3 [42] 93.702 ns 1.00 0.0459 864 B 1.00
String_Interpolation http cname.domain.tld /base-path / ?para(...)alue3 [42] 28.924 ns 0.31 0.0093 176 B 0.20
String_Concat http cname.domain.tld /base-path / ?para(...)alue3 [42] 25.931 ns 0.28 0.0093 176 B 0.20
String_Create http cname.domain.tld /base-path / ?para(...)alue3 [42] 13.951 ns 0.15 0.0093 176 B 0.20
StringBuilder http cname.domain.tld /base-path /path/one/two/three **** 75.755 ns 1.00 0.0327 616 B 1.00
String_Interpolation http cname.domain.tld /base-path /path/one/two/three 24.781 ns 0.33 0.0068 128 B 0.21
String_Concat http cname.domain.tld /base-path /path/one/two/three 36.724 ns 0.48 0.0068 128 B 0.21
String_Create http cname.domain.tld /base-path /path/one/two/three 11.092 ns 0.15 0.0068 128 B 0.21
StringBuilder http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 89.961 ns 1.00 0.0479 904 B 1.00
String_Interpolation http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 31.002 ns 0.34 0.0114 216 B 0.24
String_Concat http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 41.576 ns 0.46 0.0114 216 B 0.24
String_Create http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 14.374 ns 0.16 0.0115 216 B 0.24

StringBuilder

This benchmark uses the same implementation as UriHelper.GetDisplayUrl.

String_Interpolation

This benchmark uses string interpolation to build the URL.

String_Concat

This benchmark uses the new in .NET 9.0 String.Concat(ReadOnlySpan<string?> values) to build the URL.

String_Create

This benchmark uses String.Create and spans to build the URL.

Code

[MemoryDiagnoser]
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class DisplayUrlBenchmark
{
    private static readonly string SchemeDelimiter = Uri.SchemeDelimiter;

    private static readonly string[] schemes = ["http"];
    private static readonly string[] hosts = ["cname.domain.tld"];
    private static readonly string[] basePaths = [null, "/base-path",];
    private static readonly string[] paths = ["/", "/path/one/two/three",];
    private static readonly string[] queries = [null, "?param1=value1&param2=value2&param3=value3",];

    public IEnumerable<object[]> Data()
    {
        foreach (var scheme in schemes)
        {
            foreach (var host in hosts)
            {
                foreach (var basePath in basePaths)
                {
                    foreach (var path in paths)
                    {
                        foreach (var query in queries)
                        {
                            yield return new object[] { scheme, new HostString(host), new PathString(basePath), new PathString(path), new QueryString(query), };
                        }
                    }
                }
            }
        }
    }

    [Benchmark(Baseline = true)]
    [ArgumentsSource(nameof(Data))]
    public string StringBuilder(string scheme, HostString host, PathString basePath, PathString path, QueryString query)
    {
        var schemeValue = scheme ?? string.Empty;
        var hostValue = host.Value ?? string.Empty;
        var basePathValue = basePath.Value ?? string.Empty;
        var pathValue = path.Value ?? string.Empty;
        var queryValue = query.Value ?? string.Empty;

        var length =
            +schemeValue.Length
            + SchemeDelimiter
            + hostValue.Length
            + basePathValue.Length
            + pathValue.Length
            + queryValue.Length;

        return new StringBuilder(length)
                .Append(schemeValue)
                .Append(SchemeDelimiter)
                .Append(hostValue)
                .Append(basePathValue)
                .Append(pathValue)
                .Append(queryValue)
                .ToString();
    }

    [Benchmark]
    [ArgumentsSource(nameof(Data))]
    public string String_Interpolation(string scheme, HostString host, PathString basePath, PathString path, QueryString query)
    {
        return $"{scheme}://{host.Value}{basePath.Value}{path.Value}{query.Value}";
    }

    [Benchmark]
    [ArgumentsSource(nameof(Data))]
    public string String_Concat(string scheme, HostString host, PathString basePath, PathString path, QueryString query)
    {
        return string.Concat((ReadOnlySpan<string>)[scheme, "://", host.Value, basePath.Value, path, query.Value]);
    }

    [Benchmark]
    [ArgumentsSource(nameof(Data))]
    public string String_Create(string scheme, HostString host, PathString basePath, PathString path, QueryString query)
    {
        var schemeValue = scheme ?? string.Empty;
        var hostValue = host.Value ?? string.Empty;
        var basePathValue = basePath.Value ?? string.Empty;
        var pathValue = path.Value ?? string.Empty;
        var queryValue = query.Value ?? string.Empty;

        var length =
            +schemeValue.Length
            + SchemeDelimiter.Length
            + hostValue.Length
            + basePathValue.Length
            + pathValue.Length
            + queryValue.Length;

        return string.Create(
            length,
            (schemeValue, hostValue, basePathValue, pathValue, queryValue),
            static (buffer, uriParts) =>
            {
                var (scheme, host, basePath, path, query) = uriParts;

                if (scheme.Length > 0)
                {
                    scheme.CopyTo(buffer);
                    buffer = buffer.Slice(scheme.Length);
                }

                SchemeDelimiter.CopyTo(buffer);
                buffer = buffer.Slice(SchemeDelimiter.Length);

                if (host.Length > 0)
                {
                    host.CopyTo(buffer);
                    buffer = buffer.Slice(host.Length);
                }

                if (basePath.Length > 0)
                {
                    basePath.CopyTo(buffer);
                    buffer = buffer.Slice(basePath.Length);
                }

                if (path.Length > 0)
                {
                    path.CopyTo(buffer);
                    buffer = buffer.Slice(path.Length);
                }

                if (query.Length > 0)
                {
                    query.CopyTo(buffer);
                }
            });
    }
}

{Detail}

Fixes #28906

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions label May 8, 2024
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label May 8, 2024
.Append(path)
.Append(queryString)
.ToString();
return string.Create(
Copy link
Member

@MihaZupan MihaZupan May 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this meaningfully better than just calling the span Concat overload?

return string.Concat((ReadOnlySpan<string?>)[
    request.Scheme,
    SchemeDelimiter,
    request.Host.Value,
    request.PathBase.Value,
    request.Path.Value,
    request.QueryString.Value
]);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because string.Concat with ReadOnlySpan<char> only goes up to 5 parameters and 6 are needed, as you figured out, an array allocation is needed (the API you "used" doesn't exist).

I haven't benchmarked this one, but I don't expect it to be better than string.Create.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does exist in .Net 9, might be fairly recent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still can't see how that could be better than string.Create. have you benchmarked it?

Copy link
Member

@MihaZupan MihaZupan May 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better by way of being 10x shorter and thus more readable/maintainable.

Unless rolling out the custom Concat is meaningfully faster, I don't think it's worth the extra logic.
Judging by the implementation, I would expect the two to perform very similarly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's around 3 times faster and uses around 4 times less memory and is a pattern already used in the code base.

I'm not saying we shouldn't make any changes here. Thank you for looking into this and improving things.
Going through string.Create definitely is faster than StringBuilder, that isn't being disputed.

I'm saying that using an existing helper string.Concat(ReadOnlySpan<string>) should be very similar perf-wise to the significantly more logic needed to go through string.Create.

Copy link
Member

@MihaZupan MihaZupan May 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E.g. for a sample input from your benchmark

Method Mean Allocated
StringBuilder 262.35 ns 904 B
String_Interpolation 111.77 ns 216 B
Concat 88.82 ns 216 B
String_Create 75.50 ns 216 B

Then the question becomes whether the extra LOC are worth it for 10 ns on GetDisplayUrl.
I'll leave that up to the maintainers of this repo.

Side note: I wouldn't be surprised if Interpolation ends up compiling to the same thing as Concat in future compiler versions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems odd that Concat is slower, it should basically be doing the same thing as string.Create here.

Is it possible that the CopyStringContent calls are adding overhead that could be reduced?

Copy link
Member

@MihaZupan MihaZupan May 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That helper is most likely getting inlined. My guess would be it's a combination of

  • extra branches to guard against null values (both when calculating length and before copying)
  • extra branch to check for length overflow (which theoretically the manual string.Create should also be doing)
  • extra branches for length checks before copying (defensive in case the backing values changed)
  • extra branch at the end to check if the length is correct
  • branches from having the loops at all instead of being effectively manually unrolled
  • One extra Memmove call for "://" which would otherwise be turned into a couple movs when used directly in a CopyTo like in the string.Create impl

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BrennanConroy,

Seems odd that Concat is slower, it should basically be doing the same thing as string.Create here.

Is it possible that the CopyStringContent calls are adding overhead that could be reduced?

public static string Concat(/params/ ReadOnlySpan<string?> values) is not yet in .NET 9.0.0-preview.3.24172.9. As soon as it is in a preview,

The current implementation of string.Concat that does not require the allocation of a string[] only accepts up to 4 strings, and this requires 5.

I'll update my benchmarks to use it.

@MihaZupan,

That helper is most likely getting inlined. My guess would be it's a combination of

  • extra branches to guard against null values (both when calculating length and before copying)
  • extra branch to check for length overflow (which theoretically the manual string.Create should also be doing)
  • extra branches for length checks before copying (defensive in case the backing values changed)
  • extra branch at the end to check if the length is correct
  • branches from having the loops at all instead of being effectively manually unrolled
  • One extra Memmove call for "://" which would otherwise be turned into a couple movs when used directly in a CopyTo like in the string.Create impl

When hot path performance is involved, I tend to not rely on guessing. I rely on personal and community experience and concrete measurements.

@amcasey
Copy link
Member

amcasey commented May 15, 2024

I'm going to be sad if this is the most efficient way to concatenate six strings.

@paulomorgado
Copy link
Contributor Author

I'm going to be sad if this is the most efficient way to concatenate six strings.

Up to 9.0.0-preview.3.24175.3, it is.

@paulomorgado
Copy link
Contributor Author

Upated to use the new in .NET 9.0 String.Concat(ReadOnlySpan<string?> values) method.

Always slower than String.Create and slower than string interpolation in most of the cases.

@dotnet-policy-service dotnet-policy-service bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label May 28, 2024
@amcasey
Copy link
Member

amcasey commented May 28, 2024

Remaining questions, @MihaZupan @BrennanConroy?

@paulomorgado
Copy link
Contributor Author

Remaining questions, @MihaZupan @BrennanConroy?

/cc @davidfowl @stephentoub

@stephentoub
Copy link
Member

stephentoub commented Jun 2, 2024

Always slower than String.Create and slower than string interpolation in most of the cases.

I don't see a difference anywhere close to what you're seeing. For me, the string.Create case is just a few ns faster than the string.Concat (which makes sense, as it's effectively manually unrolling a loop the latter has), but it's also ~50 lines of code compared to the ~1 line of code for string.Concat. And it's going to contribute to a larger NativeAOT footprint, due to the use of the ValueTuple`5, which may not be otherwise used in the app and which brings with it a non-trivial footprint. (I also wouldn't be surprised if the string.Create benchmark were being skewed a bit by dynamic PGO, which can devirtualize this delegate call but would be much less likely in a real app where different delegates were being used with it.)

This path is already allocating, so it can't be an ultra hot path. I'd just do the super simple and basically as efficient thing; just use Concat.

@paulomorgado
Copy link
Contributor Author

Hi @stephentoub,

Always slower than String.Create and slower than string interpolation in most of the cases.

I don't see a difference anywhere close to what you're seeing. For me, the string.Create case is just a few ns faster than the string.Concat (which makes sense, as it's effectively manually unrolling a loop the latter has), but it's also ~50 lines of code compared to the ~1 line of code for string.Concat. And it's going to contribute to a larger NativeAOT footprint, due to the use of the ValueTuple`5, which may not be otherwise used in the app and which brings with it a non-trivial footprint. (I also wouldn't be surprised if the string.Create benchmark were being skewed a bit by dynamic PGO, which can devirtualize this delegate call but would be much less likely in a real app where different delegates were being used with it.)

This path is already allocating, so it can't be an ultra hot path. I'd just do the super simple and basically as efficient thing; just use Concat.

I was comparing the current implementation using StringBuilder.
This PR is based on #28906 from Dec 29, 2020 and I was only made aware of the existence of String.Concat(ReadOnlySpan<string?> values) in .NET 9.0 by @MihaZupan's comment.

In the meantime the original idea of using string.Create was picked opentelemetry-dotnet picked up the original idea on open-telemetry/opentelemetry-dotnet#2947 and ImageSharp.Web on SixLabors/ImageSharp.Web#265.

In the interest of measuring and comparing, I've added:

[Benchmark]
[ArgumentsSource(nameof(Data))]
public string String_Concat2(string scheme, HostString host, PathString basePath, PathString path, QueryString query)
{
    if (!query.HasValue)
    {
        return string.Concat(scheme, "://", host.Value, basePath.Value, path.Value);
    }
    else if (!basePath.HasValue)
    {
        return string.Concat(scheme, "://", host.Value, path.Value, query.Value);
    }
    else if (!path.HasValue)
    {
        return string.Concat(scheme, "://", host.Value, basePath.Value, query.Value);
    }
    else
    {
        return string.Concat((ReadOnlySpan<string>)[scheme, "://", host.Value, basePath.Value, path, query.Value]);
    }
}

But it doesn't perform better:

Method scheme host basePath path query Mean Gen0 Allocated
String_Concat http cname.domain.tld / 22.490 ns 0.0038 72 B
String_Concat2 http cname.domain.tld / 19.873 ns 0.0072 136 B
String_Concat http cname.domain.tld / ?para(...)alue3 [42] 24.928 ns 0.0085 160 B
String_Concat2 http cname.domain.tld / ?para(...)alue3 [42] 22.459 ns 0.0119 224 B
String_Concat http cname.domain.tld /path/one/two/three 34.783 ns 0.0059 112 B
String_Concat2 http cname.domain.tld /path/one/two/three 21.974 ns 0.0093 176 B
String_Concat http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 44.334 ns 0.0102 192 B
String_Concat2 http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 24.625 ns 0.0136 256 B
String_Concat http cname.domain.tld /base-path / 20.708 ns 0.0051 96 B
String_Concat2 http cname.domain.tld /base-path / 24.585 ns 0.0085 160 B
String_Concat http cname.domain.tld /base-path / ?para(...)alue3 [42] 23.504 ns 0.0093 176 B
String_Concat2 http cname.domain.tld /base-path / ?para(...)alue3 [42] 28.154 ns 0.0093 176 B
String_Concat http cname.domain.tld /base-path /path/one/two/three 36.179 ns 0.0068 128 B
String_Concat2 http cname.domain.tld /base-path /path/one/two/three 23.420 ns 0.0102 192 B
String_Concat http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 41.275 ns 0.0114 216 B
String_Concat2 http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 44.670 ns 0.0114 216 B

I haven't doen't any work with AOT, so that completely overlooked that.

But, I tried creating a struct for the arguments:

[Benchmark]
[ArgumentsSource(nameof(Data))]
public string String_Create2(string scheme, HostString host, PathString basePath, PathString path, QueryString query)
{
    var schemeValue = scheme ?? string.Empty;
    var hostValue = host.Value ?? string.Empty;
    var basePathValue = basePath.Value ?? string.Empty;
    var pathValue = path.Value ?? string.Empty;
    var queryValue = query.Value ?? string.Empty;

    var length =
        +schemeValue.Length
        + SchemeDelimiter.Length
        + hostValue.Length
        + basePathValue.Length
        + pathValue.Length
        + queryValue.Length;

    return string.Create(
        length,
        new UriParts( scheme, hostValue, basePathValue, pathValue, queryValue),
        static (buffer, uriParts) =>
        {
            if (uriParts.Scheme.Length > 0)
            {
                uriParts.Scheme.CopyTo(buffer);
                buffer = buffer.Slice(uriParts.Scheme.Length);
            }

            SchemeDelimiter.CopyTo(buffer);
            buffer = buffer.Slice(SchemeDelimiter.Length);

            if (uriParts.Host.Length > 0)
            {
                uriParts.Host.CopyTo(buffer);
                buffer = buffer.Slice(uriParts.Host.Length);
            }

            if (uriParts.BasePath.Length > 0)
            {
                uriParts.BasePath.CopyTo(buffer);
                buffer = buffer.Slice(uriParts.BasePath.Length);
            }

            if (uriParts.Path.Length > 0)
            {
                uriParts.Path.CopyTo(buffer);
                buffer = buffer.Slice(uriParts.Path.Length);
            }

            if (uriParts.Query.Length > 0)
            {
                uriParts.Query.CopyTo(buffer);
            }
        });
}

private readonly struct UriParts
{
    public readonly string Scheme;
    public readonly string Host;
    public readonly string BasePath;
    public readonly string Path;
    public readonly string Query;

    public UriParts(string scheme, string host, string basePath, string path, string query)
    {
        this.Scheme = scheme;
        this.Host = host;
        this.BasePath = basePath;
        this.Path = path;
        this.Query = query;
    }
}

And it's very close that using a tuple (DOTNET_TieredPGO set to 0):

Method scheme host basePath path query Mean Gen0 Allocated
String_Concat http cname.domain.tld / 22.50 ns 0.0038 72 B
String_Create http cname.domain.tld / 10.13 ns 0.0038 72 B
String_Create2 http cname.domain.tld / 12.72 ns 0.0038 72 B
String_Concat http cname.domain.tld / ?para(...)alue3 [42] 27.57 ns 0.0085 160 B
String_Create http cname.domain.tld / ?para(...)alue3 [42] 16.74 ns 0.0085 160 B
String_Create2 http cname.domain.tld / ?para(...)alue3 [42] 14.38 ns 0.0085 160 B
String_Concat http cname.domain.tld /path/one/two/three 45.80 ns 0.0059 112 B
String_Create http cname.domain.tld /path/one/two/three 12.34 ns 0.0059 112 B
String_Create2 http cname.domain.tld /path/one/two/three 11.40 ns 0.0059 112 B
String_Concat http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 49.75 ns 0.0102 192 B
String_Create http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 16.61 ns 0.0102 192 B
String_Create2 http cname.domain.tld /path/one/two/three ?para(...)alue3 [42] 15.46 ns 0.0102 192 B
String_Concat http cname.domain.tld /base-path / 23.96 ns 0.0051 96 B
String_Create http cname.domain.tld /base-path / 13.31 ns 0.0051 96 B
String_Create2 http cname.domain.tld /base-path / 11.28 ns 0.0051 96 B
String_Concat http cname.domain.tld /base-path / ?para(...)alue3 [42] 27.16 ns 0.0093 176 B
String_Create http cname.domain.tld /base-path / ?para(...)alue3 [42] 17.88 ns 0.0093 176 B
String_Create2 http cname.domain.tld /base-path / ?para(...)alue3 [42] 14.86 ns 0.0093 176 B
String_Concat http cname.domain.tld /base-path /path/one/two/three 45.87 ns 0.0068 128 B
String_Create http cname.domain.tld /base-path /path/one/two/three 14.12 ns 0.0068 128 B
String_Create2 http cname.domain.tld /base-path /path/one/two/three 12.51 ns 0.0068 128 B
String_Concat http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 50.87 ns 0.0114 216 B
String_Create http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 19.09 ns 0.0115 216 B
String_Create2 http cname.domain.tld /base-path /path/one/two/three ?para(...)alue3 [42] 16.46 ns 0.0115 216 B

Whether this marginally improvement over string.Concat is worst all that code, would require data I don't have on the usage of this API.

The best option would be to add an overload to string.Concat with 5 string parameters, but that would be a breaking change. One that might not hurt anyone, but still a breaking change.

@paulomorgado paulomorgado mentioned this pull request Jun 7, 2024
4 tasks
@paulomorgado paulomorgado force-pushed the UriHelper.GetDisplayUrl branch 3 times, most recently from 13f0072 to 562d2be Compare June 17, 2024 13:20
Replaced `StringBuilder` concatenation with the more efficient `string.Create` method for creating new strings by concatenating `scheme`, `host`, `pathBase`, `path`, and `queryString`. This method reduces overhead by avoiding multiple string concatenations and allocating the correct buffer size for the new string. The `CopyTo` method is used in a callback function to copy each string to the new string, slicing the buffer to remove the copied part. The `SchemeDelimiter` is always copied to the new string, regardless of its length. This change enhances the performance of the code.
@paulomorgado paulomorgado force-pushed the UriHelper.GetDisplayUrl branch 2 times, most recently from 449d2c1 to 643eeee Compare July 9, 2024 20:06
@paulomorgado
Copy link
Contributor Author

Given @stephentoub's comment and string.Concat(ReadOnlySpan<string>) being in preview6, I've changed the implementation.

.Append(path)
.Append(queryString)
.ToString();
return string.Concat((ReadOnlySpan<string?>)[request.Scheme, SchemeDelimiter, request.Host.Value, request.PathBase.Value, request.Path.Value, request.QueryString.Value]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this casting really necessary to pick the correct overload? I'd hoped params ReadOnlySpan<string?> would be picked over params string?[] so you could call it like this:

string.Concat(request.Scheme, SchemeDelimiter, request.Host.Value, request.PathBase.Value, request.Path.Value, request.QueryString.Value)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was, last time I checked. Have you got different results?

Copy link
Member

@khellang khellang Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling it with a collection expression (with or without the casting) seems to result in the exact same lowered code 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was introduced in preview6.

I think the plan for the compiler is to always choose the most performant option. But it's not there yet. Maybe in the next preview.

@paulomorgado
Copy link
Contributor Author

Hi @adityamandaleeka,

Anything blocking this PR?

@adityamandaleeka
Copy link
Member

@paulomorgado No blockers, just needs a review approval. FYI the team is pretty focused on wrapping up 9.0 but we'll give this another look soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions community-contribution Indicates that the PR has been added by a community member pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun Perf
Projects
None yet
Development

Successfully merging this pull request may close these issues.

UriHelper.GetDisplayUrl: opportunity for performance improvement
7 participants