[英]Why C# string interpolation slower than regular string concat?
我正在优化我们的调试打印设施(类)。 该类大致简单,具有全局“启用”布尔值和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}");
}
}
在 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 |
我认为 Concat 方法会更慢,因为每个+
操作实际上都会创建一个新字符串(+分配),但似乎插值会导致更高的分配时间更长。
你可以解释吗?
TLDR:插值字符串总体上是最好的,它们只会在您的基准测试中分配更多内存,因为您使用的是旧的 .Net 和缓存的数字字符串
这里有很多话要说。
首先,很多人认为使用+
的字符串连接总是会为每个+
创建一个新字符串。 这可能是循环中的情况,但是如果您一个接一个地使用大量+
,编译器实际上会用对一个string.Concat
的调用替换这些运算符,从而使复杂度为 O(n),而不是 O(n^2 )。 您的DebugPrintConcat
实际上会编译为:
public void DebugPrintConcat()
{
profiler.PrineDebug(string.Concat("sometext_", five.ToString(), "_", six.ToString()));
}
应该注意的是,在您的特定情况下,您没有对整数的字符串分配进行基准测试,因为 .Net缓存了小数字的字符串实例,因此.ToString()
上的five
和six
最终什么也不分配。 如果您使用更大的数字或格式(如.ToString("10:0000")
),内存分配会大不相同。
连接字符串的三种方式是+
(即string.Concat()
)、 string.Format()
和插值字符串。 插值字符串曾经与string.Format()
完全相同,因为$"..."
只是string.Format()
的语法糖,但自从 .Net 6 重新设计后,情况不再如此内插字符串处理程序
我认为我必须解决的另一个神话是人们认为在结构上使用string.Format()
总是会导致首先对结构进行装箱,然后通过在装箱的结构上调用.ToString()
来创建中间字符串。 这是错误的,多年来,所有原始类型都实现ISpanFormattable
,它允许string.Format()
跳过创建中间字符串并将对象的字符串表示直接写入内部缓冲区。 ISpanFormattalbe
已随着 .Net 6 的发布而公开,因此您也可以为自己的类型实现它(更多信息在本答案末尾)
关于每种方法的内存特性,从最差到最好排序:
string.Concat()
(接受对象的重载,而不是字符串)是最糟糕的,因为它总是将结构体装箱并创建中间字符串(来源:使用ILSpy进行反编译)+
和string.Concat()
(接受字符串而不是对象的重载)比前面的稍微好一点,因为虽然它们确实使用中间字符串,但它们不会对结构进行装箱string.Format()
通常比以前的要好,因为如前所述,它确实需要对结构进行装箱,但如果结构实现ISpanFormattable
则不需要制作中间字符串(直到不久前它是 .Net 内部的,但性能优势是尽管如此)。 此外,与以前的方法相比,string.Format() 更有可能不需要分配object[]
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}...";
}
}
结果按分配的字节排序:
方法 | 意思是 | 错误 | 标准差 | 秩 | 0代 | 已分配 |
---|---|---|---|---|---|---|
字符串连接对象 | 293.9 纳秒 | 1.66 纳秒 | 1.47 纳秒 | 4 | 0.0386 | 488乙 |
字符串加 | 266.8 纳秒 | 2.04 纳秒 | 1.91 纳秒 | 2 | 0.0267 | 336乙 |
StringConcatStrings | 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}";
}
}
基准测试结果,再次按分配的字节排序:
方法 | 意思是 | 错误 | 标准差 | 秩 | 0代 | 已分配 |
---|---|---|---|---|---|---|
字符串连接对象 | 389.3 纳秒 | 2.65 纳秒 | 2.34 纳秒 | 4 | 0.0477 | 600乙 |
字符串加 | 350.7 纳秒 | 1.88 纳秒 | 1.67 纳秒 | 2 | 0.0329 | 416乙 |
StringConcatStrings | 374.4 纳秒 | 6.90 纳秒 | 6.46 纳秒 | 3 | 0.0329 | 416乙 |
字符串格式 | 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());
}
}
方法 | 意思是 | 错误 | 标准差 | 秩 | 0代 | 已分配 |
---|---|---|---|---|---|---|
字符串格式良好 | 389.0 纳秒 | 2.27 纳秒 | 2.12 纳秒 | 1 | 0.0234 | 296乙 |
字符串格式错误 | 442.0 纳秒 | 4.62 纳秒 | 4.09 纳秒 | 2 | 0.0305 | 384乙 |
对结果的解释是,将结构装箱并让string.Format()
将字符串表示直接写入它的 char 缓冲区,而不是显式创建中间字符串并强制string.Format()
从中复制更便宜。
如果您想了解更多关于插值字符串处理程序如何工作以及如何使您自己的类型实现ISpanFormattable
的信息,这是一个很好的阅读: 链接
当您必须连接少于 4-8 个(虽然不准确)的字符串时,字符串连接会更快更轻,但是随着必须连接的字符串数量的增加,最好使用 StringBuilderStringBuilder 。
在内部,当您使用“+”运算符连接字符串时
string test = "foo" + a + "bar";
每次连接都会调用concat()方法。
string t1 = "foo";
string t2 = a;
string t3 = concat(t1, t2);
string t4 = "bar";
string final = concat(t3, t4);
在字符串插值的后一种情况下,这只不过是String.Format()的语法糖。
所以,当你使用字符串插值时,
string text = $"Foo{a}Bar";
它将被转换为,
string text = string.Format("Foo{0}Bar", new object[] { a });
您可以在MSDN中找到这些方法的性能影响,但是,性能因素在小规模字符串构建中几乎可以忽略不计,但对于严重的字符串构建,使用 StringBuilder 而不是插值和原始连接要好得多。
在选择一种方法时,性能并不是唯一的因素,因为插值在内部调用 string.Format() ,它允许您格式化字符串(填充、小数精度、日期格式等),从而为您提供更大的灵活性。
连接、格式化和字符串构建都有自己的用例,由您决定哪一个最适合您的需要。
我相信这里的问题只是int
的装箱。 我试图消除拳击并获得与串联相同的性能
方法 | 意思是 | 错误 | 标准差 | 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()}");
}
我们还可以注意到减少的分配(正是因为没有额外的装箱对象)。
PS。 顺便说一句,@GSerg 已经在评论中提到了具有相同解释的帖子。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.