为什么C#字符串插值比普通字符串连接慢?

4

我正在优化我们的调试打印功能(类)。

这个类大致上很简单,有一个全局的“启用”布尔值和一个PrintDebug例程。

我正在研究“禁用”模式下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}");
    }
}

在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 |

我认为使用连接方法会更慢,因为每个 + 操作实际上都会创建一个新的字符串(即分配内存),但似乎插值法在更高的时间内导致了更高的内存分配。你能解释一下吗?

1
字符串插值还会创建新的字符串,甚至在每个“部分”插值中都调用了string.Format...因此最终会有更多的调用。 - Jonathan Alfaro
这个回答解决了你的问题吗?字符串插值 vs String.Format - GSerg
@JonathanAlfaro 这是错误的,字符串插值只在当前的.NET版本中创建一个字符串(请参阅我的答案),而在旧的.NET版本中它总是调用一个单独的string.Format()函数。 - Petrusion
@Petrusion 我的陈述对于.NET 6.0之前的所有版本都是100%正确的。此外,使用插值处理程序并不能保证在处理小字符串时比字符串连接更快。 - Jonathan Alfaro
1
@JonathanAlfaro "string.Format会创建一个新的字符串,并由该方法返回" - 这一点显然超出了常识,这也是该方法的全部意义所在。为什么你总是说我只谈论 .Net 6,而我在这个评论链中多次谈到了之前的版本?如果我理解正确,你是说 $"sometext_{five}_{six}" 被翻译成多个 string.Format() 调用,这对于任何 .Net 版本都是错误的。在 .Net 6 之前,$"sometext_{five}_{six}" 被翻译成 string.Format("sometext_{0}_{1}", five, six),这是一个单独的 string.Format() - Petrusion
显示剩余2条评论
2个回答

14
< p > < em > TLDR:插值字符串总体上是最好的,它们只会在您使用旧的.NET和缓存数字字符串时在基准测试中分配更多内存

这里有很多要讨论的内容。

首先,很多人认为使用+进行字符串拼接将始终为每个+创建一个新字符串。在循环中可能是这种情况,但如果您连续使用大量+,编译器实际上将用一个string.Concat调用替换这些运算符,使复杂度为O(n),而不是O(n ^ 2)。您的DebugPrintConcat实际上编译成了这样:

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

需要翻译的内容:

需要注意的是,在您的特定情况下,您并未针对整数字符串分配进行基准测试,因为 .Net 会为小数字缓存字符串实例,所以那些在 fivesix 上调用的 .ToString() 实际上不会分配任何内存。如果您使用更大的数字或格式(例如 .ToString("10:0000")),内存分配将会有很大不同。

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

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

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

  • string.Concat()(接受对象而非字符串的重载版本)是最差的,因为它将始终装箱结构体并创建中间字符串(来源:使用ILSpy反编译)。
  • +string.Concat()(接受字符串而非对象的重载版本)比前者略好一些,因为虽然它们使用了中间字符串,但它们不会装箱结构体。
  • string.Format() 通常比前面的方法更好,因为如前所述,它确实需要装箱结构体,但如果结构体实现了ISpanFormattable(直到不久之前这个接口是内部的),则不会创建中间字符串。此外,与之前的方法相比,string.Format() 不太可能需要分配object[]
  • 插值字符串是最好的,因为随着 .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}...";
    }
}

结果按分配的字节数排序:

方法 平均值 误差 标准偏差 排名 Gen 0 分配内存
StringConcatObjects 293.9 ns 1.66 ns 1.47 ns 4 0.0386 488 B
StringPlus 266.8 ns 2.04 ns 1.91 ns 2 0.0267 336 B
StringConcatStrings 278.7 ns 2.14 ns 1.78 ns 3 0.0267 336 B
StringFormat 275.7 ns 1.46 ns 1.36 ns 3 0.0153 192 B
InterpolatedString 249.0 ns 1.44 ns 1.35 ns 1 0.0095 120 B
如果我编辑基准类以使用超过三个格式参数,则由于数组分配,InterpolatedStringstring.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}";
    }
}

基准测试结果,按分配的字节数重新排序:

方法 平均值 误差 标准偏差 排名 Gen 0 分配的内存
StringConcatObjects 389.3 ns 2.65 ns 2.34 ns 4 0.0477 600 B
StringPlus 350.7 ns 1.88 ns 1.67 ns 2 0.0329 416 B
StringConcatStrings 374.4 ns 6.90 ns 6.46 ns 3 0.0329 416 B
StringFormat 390.4 ns 2.01 ns 1.88 ns 4 0.0234 296 B
InterpolatedString 332.6 ns 2.82 ns 2.35 ns 1 0.0114 144 B

编辑:人们可能仍然认为在插入字符串处理程序参数上调用.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());
    }
}

方法 平均值 误差 标准差 排名 第一代垃圾回收次数 分配内存
StringFormatGood 389.0 纳秒 2.27 纳秒 2.12 纳秒 1 0.0234 296 字节
StringFormatBad 442.0 纳秒 4.62 纳秒 4.09 纳秒 2 0.0305 384 字节
结果的解释是将结构体装箱并让 string.Format() 直接将其字符串表示形式写入其 char 缓冲区比显式创建中间字符串并强制 string.Format() 从中复制更便宜。
如果您想了解有关插值字符串处理程序的工作原理以及如何使自己的类型实现 ISpanFormattable,这是一个不错的阅读材料:link

2
这是一个一流的答案。 - ctwardy

-1

我相信这里的问题只是对 int 进行了装箱。 我尝试消除装箱并获得了与连接相同的性能。

方法 平均值 误差 标准偏差 Gen 0 分配内存
DebugPrintConcat 41.49 纳秒 0.198 纳秒 0.185 纳秒 0.0046 48 字节
DebugPrintInterpolated 103.07 纳秒 0.257 纳秒 0.227 纳秒 0.0092 96 字节
DebugPrintInterpolatedStrings 41.36 纳秒 0.211 纳秒 0.198 纳秒 0.0046 48 字节

DebugPrintInterpolatedStrings 代码:我刚刚添加了显式的 ToString

    [Benchmark]
    public void DebugPrintInterpolatedStrings()
    {
        profiler.PrineDebug($"sometext_{five.ToString()}_{six.ToString()}");
    }

我们还可以注意到分配量减少了(正是由于额外装箱对象的缺失)。
顺便说一下,@GSerg 在评论中已经提到了具有相同解释的帖子。

1
在字符串插值中执行类似 five.ToString() 这样的操作是一个不好的主意。目前它对你来说工作得很好,只是因为 .Net 会缓存小整数的字符串返回。在大多数情况下,最好还是让装箱操作发生,因为在内部 .Net 不会创建新的字符串,而是使用 ISpanFormattable 中的 TryFormat() 直接将内容附加到正在创建的字符串上,而不需要中间的字符串对象。 - Petrusion
@Petrusion,请问您能详细说明一下关于缓存的注释吗?我重新进行了基准测试,将“five”和“six”设置为随机值(因此我可能会破坏任何缓存),并且我再次得出结论:显式使用ToString的版本比不使用更好,并且几乎与Concat相同。 - Serg
1
你正在使用旧版的 .net,我认为。请查看我的答案以获取更多细节。注意:我刚刚重新检查了一下,使用当前的 .net,插值字符串比不加 .ToString() 更快且分配的内存更少。 - Petrusion
1
附注:即使没有 .NET 6 的插值字符串性能增强,five.ToString() 仍然是一个不好的想法。我将在我的答案中解释原因。 - Petrusion
2
我刚刚做了你描述的相同基准测试。显然,你只是在关注速度差异,而你需要关注分配,这比一些13ms的速度差异更重要,特别是因为这些差异可能会随着.NET团队优化字符串处理程序引用结构而发生变化。我的论点的整个重点是分配更少的堆内存,而不是微小的速度差异。此外,请参见我的答案的最后一个基准测试,速度差异可能有利于不执行.ToString(),同时还可以节省内存分配。 - Petrusion
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接