为什么 C# 字符串插值比常规字符串连接慢?

Nir*_*rMH 4 c# string performance string-interpolation

我正在优化我们的调试打印设施(类)。该类大致简单,具有全局“启用”布尔值和PrineDebug例程。

我正在研究该方法在“禁用”模式下的性能PrintDebug,尝试创建一个在不需要调试打印的情况下对运行时影响较小的框架。

在探索过程中,我发现了以下结果,这让我感到惊讶,我想知道我在这里错过了什么?

public class Profiler
{
     private bool isDebug = false;

     public void PrineDebug(string message)
     {
         if (isDebug)
         {
             Console.WriteLine(message);
         }
     }
}

[MemoryDiagnoser]
public class ProfilerBench
{
    private Profiler profiler = new Profiler();
    private int five = 5;
    private int six = 6;

    [Benchmark]
    public void DebugPrintConcat()
    {
        profiler.PrineDebug("sometext_" + five + "_" + six);
    }

    [Benchmark]
    public void DebugPrintInterpolated()
    {
        profiler.PrineDebug($"sometext_{five}_{six}");
    }
}
Run Code Online (Sandbox Code Playgroud)

在 BenchmarkDotNet 下运行此基准测试..结果如下:

|                 Method |     Mean |   Error |  StdDev |  Gen 0 | Allocated |
|----------------------- |---------:|--------:|--------:|-------:|----------:|
|       DebugPrintConcat | 149.0 ns | 3.02 ns | 6.03 ns | 0.0136 |      72 B |
| DebugPrintInterpolated | 219.4 ns | 4.13 ns | 6.18 ns | 0.0181 |      96 B |
Run Code Online (Sandbox Code Playgroud)

我认为 Concat 方法会更慢,因为每个+操作实际上都会创建一个新字符串(+分配),但插值似乎会导致更高的分配和更长的时间。

你可以解释吗?

Pet*_*ion 17

TLDR:插值字符串总体上是最好的,它们只会在基准测试中分配更多内存,因为您使用的是旧的 .Net 和缓存的数字字符串

这里有很多话要说。

首先,很多人认为使用字符串连接+总是会为每个+. 循环中可能会出现这种情况,但如果您一个接一个地使用大量+操作符,编译器实际上会用对 one 的调用替换这些运算符string.Concat,从而使复杂度为 O(n),而不是 O(n^2)。您DebugPrintConcat实际上编译为:

public void DebugPrintConcat()
{
    profiler.PrineDebug(string.Concat("sometext_", five.ToString(), "_", six.ToString()));
}
Run Code Online (Sandbox Code Playgroud)

应该注意的是,在您的具体情况下,您不会对整数的字符串分配进行基准测试,因为 .Net 会缓存小数字的字符串实例,因此这些实例.ToString()最终five不会six分配任何内容。如果您使用更大的数字或格式(如 ),内存分配将会有很大不同.ToString("10:0000")

连接字符串的三种方式是+(即string.Concat()string.Format() 插值字符串。插值字符串曾经与 完全相同string.Format(),只是$"..."的语法糖,但自从 .Net 6 通过插值字符串处理程序string.Format()进行重新设计以来,情况就不再是这样了

我认为我必须解决的另一个神话是,人们认为使用string.Format()结构总是会导致首先装箱结构,然后通过调用.ToString()装箱结构来创建中间字符串。这是错误的,多年来,所有原始类型都已实现ISpanFormattable,允许string.Format()跳过创建中间字符串并将对象的字符串表示直接写入内部缓冲区ISpanFormattalbe已随 .Net 6 的发布而公开,因此您也可以为您自己的类型实现它(更多信息在本答案的末尾)

关于每种方法的内存特性,从最差到最好排序:

  • string.Concat()(接受对象而不是字符串的重载)是最糟糕的,因为它总是会装箱结构创建中间字符串(来源:使用ILSpy反编译)
  • +并且string.Concat()(接受字符串而不是对象的重载)比前面的稍好一些,因为虽然它们确实使用中间字符串,但它们不装箱结构
  • string.Format()通常比以前更好,因为如前所述,它确实需要对结构进行装箱,但如果结构实现,则不需要生成中间字符串ISpanFormattable(直到不久前它还是 .Net 内部的,但性能优势仍然存在)。object[]此外,与以前的方法相比,string.Format() 更有可能不需要分配 an
  • 插值字符串是最好的,因为随着 .Net 6 的发布,它们不会装箱结构,也不会为实现的类型创建中间字符串ISpanFormattable。通常,您获得的唯一分配只是返回的字符串,没有其他内容。

为了支持上述主张,我在下面添加了一个基准测试类和基准测试结果,确保避免原始帖子中+仅因为字符串被缓存为小整数而表现最佳的情况:

[MemoryDiagnoser]
[RankColumn]
public class ProfilerBench
{
    private float pi = MathF.PI;
    private double e = Math.E;
    private int largeInt = 116521345;

    [Benchmark(Baseline = true)]
    public string StringPlus()
    {
        return "sometext_" + pi + "_" + e + "_" + largeInt + "...";
    }

    [Benchmark]
    public string StringConcatStrings()
    {
        // the string[] overload
        // the exact same as StringPlus()
        return string.Concat("sometext_", pi.ToString(), "_", e.ToString(), "_", largeInt.ToString(), "...");
    }

    [Benchmark]
    public string StringConcatObjects()
    {
        // the params object[] overload
        return string.Concat("sometext_", pi, "_", e, "_", largeInt, "...");
    }

    [Benchmark]
    public string StringFormat()
    {
        // the (format, object, object, object) overload
        // note that the methods above had to allocate an array unlike string.Format()
        return string.Format("sometext_{0}_{1}_{2}...", pi, e, largeInt);
    }

    [Benchmark]
    public string InterpolatedString()
    {
        return $"sometext_{pi}_{e}_{largeInt}...";
    }
}
Run Code Online (Sandbox Code Playgroud)

结果按分配的字节排序:

方法 意思是 错误 标准差 第0代 已分配
字符串连接对象 293.9纳秒 1.66纳秒 1.47纳秒 4 0.0386 488 乙
字符串加号 266.8纳秒 2.04纳秒 1.91纳秒 2 0.0267 336 乙
StringConcat字符串 278.7纳秒 2.14纳秒 1.78纳秒 3 0.0267 336 乙
字符串格式 275.7纳秒 1.46纳秒 1.36纳秒 3 0.0153 192乙
插值字符串 249.0纳秒 1.44纳秒 1.35纳秒 1 0.0095 120乙

如果我编辑基准类以使用三个以上的格式参数,那么由于数组分配,InterpolatedString和之间的差异会更大:string.Format()

[MemoryDiagnoser]
[RankColumn]
public class ProfilerBench
{
    private float pi = MathF.PI;
    private double e = Math.E;
    private int largeInt = 116521345;
    private float anotherNumber = 0.123456789f;

    [Benchmark]
    public string StringPlus()
    {
        return "sometext_" + pi + "_" + e + "_" + largeInt + "..." + anotherNumber;
    }

    [Benchmark]
    public string StringConcatStrings()
    {
        // the string[] overload
        // the exact same as StringPlus()
        return string.Concat("sometext_", pi.ToString(), "_", e.ToString(), "_", largeInt.ToString(), "...", anotherNumber.ToString());
    }

    [Benchmark]
    public string StringConcatObjects()
    {
        // the params object[] overload
        return string.Concat("sometext_", pi, "_", e, "_", largeInt, "...", anotherNumber);
    }

    [Benchmark]
    public string StringFormat()
    {
        // the (format, object[]) overload
        return string.Format("sometext_{0}_{1}_{2}...{3}", pi, e, largeInt, anotherNumber);
    }

    [Benchmark]
    public string InterpolatedString()
    {
        return $"sometext_{pi}_{e}_{largeInt}...{anotherNumber}";
    }
}
Run Code Online (Sandbox Code Playgroud)

基准测试结果,再次按分配的字节排序:

方法 意思是 错误 标准差 第0代 已分配
字符串连接对象 389.3纳秒 2.65纳秒 2.34纳秒 4 0.0477 600乙
字符串加号 350.7纳秒 1.88纳秒 1.67纳秒 2 0.0329 416B
StringConcat字符串 374.4纳秒 6.90纳秒 6.46纳秒 3 0.0329 416B
字符串格式 390.4纳秒 2.01纳秒 1.88纳秒 4 0.0234 296 乙
插值字符串 332.6纳秒 2.82纳秒 2.35纳秒 1 0.0114 144 乙

编辑:人们可能仍然认为调用.ToString()内插字符串处理程序参数是一个好主意。事实并非如此,如果您这样做,性能将会受到影响,Visual Studio 甚至会警告您不要这样做。这不仅仅适用于 .net6,下面你可以看到,即使使用string.Format(),它的内插字符串曾经是语法糖,调用仍然很糟糕.ToString()

[MemoryDiagnoser]
[RankColumn]
public class ProfilerBench
{
    private float pi = MathF.PI;
    private double e = Math.E;
    private int largeInt = 116521345;
    private float anotherNumber = 0.123456789f;

    [Benchmark]
    public string StringFormatGood()
    {
        // the (format, object[]) overload with boxing structs
        return string.Format("sometext_{0}_{1}_{2}...{3}", pi, e, largeInt, anotherNumber);
    }

    [Benchmark]
    public string StringFormatBad()
    {
        // the (format, object[]) overload with pre-converting the structs to strings
        return string.Format("sometext_{0}_{1}_{2}...{3}", 
            pi.ToString(), 
            e.ToString(), 
            largeInt.ToString(), 
            anotherNumber.ToString());
    }
}
Run Code Online (Sandbox Code Playgroud)
方法 意思是 错误 标准差 第0代 已分配
字符串格式好 389.0纳秒 2.27纳秒 2.12纳秒 1 0.0234 296 乙
字符串格式错误 442.0纳秒 4.62纳秒 4.09纳秒 2 0.0305 384 乙

对结果的解释是,将结构装箱并将string.Format()字符串表示直接写入其字符缓冲区,而不是显式创建中间字符串并强制string.Format()从中复制,成本更低。

如果您想了解有关插值字符串处理程序如何工作以及如何实现您自己的类型的更多信息ISpanFormattable,这是一个很好的阅读:链接

  • 这是一个一流的答案。 (2认同)