在进行一些分析之后,我们发现我们的应用程序连接字符串的当前方式会导致大量的内存流失和CPU时间。
我们正在构建一个List<string>
个字符串来连接大约50万个元素的数量级,引用几百兆字节的字符串。我们正在尝试优化我们应用的这一小部分,因为它似乎占用了不成比例的CPU和内存使用量。
我们做了很多文字处理:)
理论上,我们应该能够在单个分配和N个副本中执行连接 - 我们可以知道我们的字符串中有多少总字符可用,所以它应该像汇总组件字符串的长度一样简单并分配足够的底层内存来保存结果。
假设我们从预先填充的List<string>
开始,是否可以使用单个分配连接该列表中的所有字符串?
目前,我们正在使用StringBuilder
类,但是它存储了自己的所有字符的中间缓冲区 - 所以我们有一个不断增长的块数组,每个块存储一个字符的副本我们给它。远非理想。块数组的分配并不可怕,但最糟糕的是它分配了中间字符数组,这意味着N分配和副本。
我们现在能做的最好的事情就是调用List<string>.ToArray()
- 执行500k元素数组的一个副本 - 然后将生成的string[]
传递给string.Concat(params string[])
。 string.Concat()
然后执行两个分配,一个用于将输入数组复制到内部数组,另一个用于分配目标字符串的内存。
来自referencesource.microsoft.com:
public static String Concat(params String[] values) {
if (values == null)
throw new ArgumentNullException("values");
Contract.Ensures(Contract.Result<String>() != null);
// Spec#: Consider a postcondition saying the length of this string == the sum of each string in array
Contract.EndContractBlock();
int totalLength=0;
// -----------> Allocation #1 <---------
String[] internalValues = new String[values.Length];
for (int i=0; i<values.Length; i++) {
string value = values[i];
internalValues[i] = ((value==null)?(String.Empty):(value));
totalLength += internalValues[i].Length;
// check for overflow
if (totalLength < 0) {
throw new OutOfMemoryException();
}
}
return ConcatArray(internalValues, totalLength);
}
private static String ConcatArray(String[] values, int totalLength) {
// -----------------> Allocation #2 <---------------------
String result = FastAllocateString(totalLength);
int currPos=0;
for (int i=0; i<values.Length; i++) {
Contract.Assert((currPos <= totalLength - values[i].Length),
"[String.ConcatArray](currPos <= totalLength - values[i].Length)");
FillStringChecked(result, currPos, values[i]);
currPos+=values[i].Length;
}
return result;
}
因此,在最好的情况下,我们有三个分配,两个用于引用组件字符串的数组,另一个用于目标连接字符串。
我们能改进吗?是否可以使用单个分配和单个循环的字符副本连接List<string>
?
我想总结到目前为止讨论的各种方法,以及为什么它们仍然是次优的。我还想更具体地设置具体情况的参数,因为我已经收到了许多试图支持中心问题的问题。
...
首先,我在其中工作的代码的结构。有三层:
谈论一些数字:典型的批处理运行将从内容生成器收集~500000个字符串,代表大约200-500 MB的内存。我需要最有效的方法将这些500k字符串连接成一个字符串。
...
现在我想研究到目前为止讨论的方法。为了数字,假设我们正在运行64位,假设我们正在收集500000个字符串对象,并假设字符串对象的聚合大小总计200兆字节的字符数据。此外,假设在以下分析中,原始字符串对象的存储器不计入任何方法的总计。我做出这个假设是因为它对于任何和所有方法都是通用的,因为它假设我们不能改变内容生成器的接口 - 它们返回500k相对较小的完全形成的字符串对象,然后我必须接受并以某种方式连接。如上所述,我无法更改此界面。
内容制作者----&gt; StringBuilder
----&gt; string
从概念上讲,这将调用内容生成器,并直接将它们返回的字符串写入StringBuilder
,然后调用StringBuilder.ToString()
以获取连接的字符串。
通过分析StringBuilder
的实施情况,我们可以看到,其成本可归结为400 MB的分配和副本:
StringBuilder
。我们将执行一个200 MB的分配来预先分配StringBuilder
,然后在我们复制并丢弃从内容制作者返回的字符串时再分配200 MB的副本StringBuilder
之后,我们需要调用StringBuilder.ToString()
。这只执行一次分配(string.FastAllocateString()
),然后将字符串数据从其内部缓冲区复制到字符串对象的内部存储器中。总费用:约400 MB的分配和副本
内容制作者---&gt;预先分配char[]
---&gt; string
这个策略相当简单。假设我们大致知道我们将从生产者那里收集多少字符数据,我们可以预先分配一个200 MB大的char[]
。然后,当我们调用内容生成器时,我们将它们返回的字符串复制到char[]
中。这占了200 MB的分配和副本。将其转换为字符串对象的最后一步是将其传递给new string(char[])
构造函数。但是,由于字符串是不可变的而数组不是,因此构造函数将复制整个数组,从而分配和复制另外200 MB的字符数据。
总费用:约400 MB的分配和副本
内容制作者---&gt; List<string>
----&gt; string[]
----&gt; string.Concat(string[])
List<string>
大约500k个元素 - 大约4 MB的List基础数组(每个指针500k * 8个字节= 4 MB内存)。List<string>.ToArray()
获取string[]
。大约4 MB的分配和副本(再次,我们真的只是复制指针)。string.Concat(string[])
:
string.FastAllocateString()
特殊方法的字符串对象。大约200 MB的分配。总费用:约212 MB的分配和副本
这些方法都不是理想的,但方法#3非常接近。我们假设需要分配和复制的内存的绝对最小值是200 MB(对于目标字符串),这里我们非常接近 - 212 MB。
如果有string.Concat
重载,1)接受IList<string>
并且2)在使用它之前没有复制该IList,那么问题就解决了。 .Net没有提供这样的方法,因此是这个问题的主题。
解决方案的进展。
我已经对一些被黑客入侵的IL做了一些测试,发现直接调用string.FastAllocateString(n)
(通常不可调用...)与调用new string('\0', n)
一样快,两者都是似乎分配的内存与预期的完全一样。
从那里,似乎可以使用unsafe
和fixed
语句获取指向新分配字符串的指针。
因此,一个粗略的解决方案开始出现:
private static string Concat( List<string> list )
{
int concatLength = 0;
for( int i = 0; i < list.Count; i++ )
{
concatLength += list[i].Length;
}
string newString = new string( '\0', concatLength );
unsafe
{
fixed( char* ptr = newString )
{
...
}
}
return newString;
}
下一个最大的障碍是实现或找到一个有效的块复制方法,ala Buffer.BlockCopy,但接受char*
类型的方法除外。
答案 0 :(得分:2)
如果在尝试执行操作之前可以确定并置的长度,则char数组可以在某些用例中击败字符串构建器。操作数组中的字符可以防止多次分配。
<强>更新强>
请从.NET查看String.Join
的这个内部实现 - 它使用带有指针的不安全代码来避免多次分配。除非我遗漏了某些内容,否则您可以使用您的列表重新编写此内容以完成您想要的内容:
[System.Security.SecuritySafeCritical] // auto-generated
public unsafe static String Join(String separator, String[] value, int startIndex, int count) {
//Range check the array
if (value == null)
throw new ArgumentNullException("value");
if (startIndex < 0)
throw new ArgumentOutOfRangeException("startIndex", Environment.GetResourceString("ArgumentOutOfRange_StartIndex"));
if (count < 0)
throw new ArgumentOutOfRangeException("count", Environment.GetResourceString("ArgumentOutOfRange_NegativeCount"));
if (startIndex > value.Length - count)
throw new ArgumentOutOfRangeException("startIndex", Environment.GetResourceString("ArgumentOutOfRange_IndexCountBuffer"));
Contract.EndContractBlock();
//Treat null as empty string.
if (separator == null) {
separator = String.Empty;
}
//If count is 0, that skews a whole bunch of the calculations below, so just special case that.
if (count == 0) {
return String.Empty;
}
int jointLength = 0;
//Figure out the total length of the strings in value
int endIndex = startIndex + count - 1;
for (int stringToJoinIndex = startIndex; stringToJoinIndex <= endIndex; stringToJoinIndex++) {
if (value[stringToJoinIndex] != null) {
jointLength += value[stringToJoinIndex].Length;
}
}
//Add enough room for the separator.
jointLength += (count - 1) * separator.Length;
// Note that we may not catch all overflows with this check (since we could have wrapped around the 4gb range any number of times
// and landed back in the positive range.) The input array might be modifed from other threads,
// so we have to do an overflow check before each append below anyway. Those overflows will get caught down there.
if ((jointLength < 0) || ((jointLength + 1) < 0) ) {
throw new OutOfMemoryException();
}
//If this is an empty string, just return.
if (jointLength == 0) {
return String.Empty;
}
string jointString = FastAllocateString( jointLength );
fixed (char * pointerToJointString = &jointString.m_firstChar) {
UnSafeCharBuffer charBuffer = new UnSafeCharBuffer( pointerToJointString, jointLength);
// Append the first string first and then append each following string prefixed by the separator.
charBuffer.AppendString( value[startIndex] );
for (int stringToJoinIndex = startIndex + 1; stringToJoinIndex <= endIndex; stringToJoinIndex++) {
charBuffer.AppendString( separator );
charBuffer.AppendString( value[stringToJoinIndex] );
}
Contract.Assert(*(pointerToJointString + charBuffer.Length) == '\0', "String must be null-terminated!");
}
return jointString;
}
更新2
快速分配的好点。根据一篇旧的SO帖子,你可以使用反射包装FastAllocate(假设你当然要缓存fastAllocate方法引用,所以你每次只调用Invoke
。也许这个调用的权衡比你的更好。现在就做。
var fastAllocate = typeof (string).GetMethods(BindingFlags.NonPublic | BindingFlags.Static)
.First(x => x.Name == "FastAllocateString");
var newString = (string)fastAllocate.Invoke(null, new object[] {20});
Console.WriteLine(newString.Length); // 20
也许另一种方法是使用不安全的代码将您的分配复制到char *数组中,然后将其传递给字符串构造函数。带有char *的字符串构造函数是传递给底层C ++实现的extern
。我还没有找到可靠的代码来确认,但也许这对您来说可能更快。非prod就绪代码(不检查潜在的溢出,固定到垃圾收集中的锁定字符串等)将从以下开始:
public unsafe string MyConcat(List<string> values)
{
int index = 0;
int totalLength = values.Sum(m => m.Length);
char* concat = stackalloc char[totalLength + 1]; // Add additional char for null term
foreach (var value in values)
{
foreach (var c in value)
{
concat[index] = c;
index++;
}
}
concat[index] = '\0';
return new string(concat);
}
现在我完全没有想法:)也许有人可以通过编组找出一个方法来避免不安全的代码。由于引入不安全的代码需要将不安全的标志添加到编译中,因此请考虑将此片段添加为单独的dll,以便在您沿着该路径走下去时最大限度地降低应用程序的安全风险。
答案 1 :(得分:1)
我已经实现了一种方法,将List连接成一个只执行一次分配的字符串。
以下代码在.Net 4.6下编译 - Block.MemoryCopy
没有被添加到.Net直到4.6。
&#34;不安全&#34;实现:
public static unsafe class FastConcat
{
public static string Concat( IList<string> list )
{
string destinationString;
int destLengthChars = 0;
for( int i = 0; i < list.Count; i++ )
{
destLengthChars += list[i].Length;
}
destinationString = new string( '\0', destLengthChars );
unsafe
{
fixed( char* origDestPtr = destinationString )
{
char* destPtr = origDestPtr; // a pointer we can modify.
string source;
for( int i = 0; i < list.Count; i++ )
{
source = list[i];
fixed( char* sourcePtr = source )
{
Buffer.MemoryCopy(
sourcePtr,
destPtr,
long.MaxValue,
source.Length * sizeof( char )
);
}
destPtr += source.Length;
}
}
}
return destinationString;
}
}
竞争实施如下&#34; safe&#34;实现:
public static string Concat( IList<string> list )
{
return string.Concat( list.ToArray() )
}
内存消耗
List<string>
直接连接成一个新分配的string
对象。ToArray()
将它传递给string.Concat时,另一个是当string.Concat执行它自己的数组内部副本时。连接500k元素列表时,&#34; safe&#34; string.Concat方法在64位进程中分配了8 MB的额外内存,我通过在内存监视器中运行测试驱动程序来确认。这是我们对安全实现执行的阵列副本的期望。
CPU性能
对于小型工作集,不安全的实现似乎赢了大约25%。
测试驱动程序通过编译64位进行测试,通过NGEN将程序安装到本机映像缓存中,并在卸载的工作站上从调试器外部运行。
来自我的测试驱动程序,带有一个小工作集(500k字符串,每个2-10个字符长):
Unsafe Time: 17.266 ms
Unsafe Time: 18.419 ms
Unsafe Time: 16.876 ms
Safe Time: 21.265 ms
Safe Time: 21.890 ms
Safe Time: 24.492 ms
不安全的平均值:17.520毫秒。安全平均值:22.549毫秒。安全比不安全时间长约25%。这可能是由于安全实现需要做的额外工作,分配临时数组。
...
来自我的大型工作集的测试驱动程序(500k字符串,每个长度为500-800个字符):
Unsafe Time: 498.122 ms
Unsafe Time: 513.725 ms
Unsafe Time: 515.016 ms
Safe Time: 487.456 ms
Safe Time: 499.508 ms
Safe Time: 512.390 ms
正如您所看到的,大字符串的性能差异大致为零,可能是因为时间由原始副本占主导地位。
<强>结论强>
如果您不关心阵列副本,那么安全实现很容易实现,并且大致与不安全的实现一样快。如果您希望在内存使用方面绝对完美,请使用不安全的实现。
我附上了我用于测试工具的代码:
class PerfTestHarness
{
private List<string> corpus;
public PerfTestHarness( List<string> corpus )
{
this.corpus = corpus;
// Warm up the JIT
// Note that `result` is discarded. We reference it via 'result[0]' as an
// unused paramater to my prints to be absolutely sure it doesn't get
// optimized out. Cheap hack, but it works.
string result;
result = FastConcat.Concat( this.corpus );
Console.WriteLine( "Fast warmup done", result[0] );
result = string.Concat( this.corpus.ToArray() );
Console.WriteLine( "Safe warmup done", result[0] );
GC.Collect();
GC.WaitForPendingFinalizers();
}
public void PerfTestSafe()
{
Stopwatch watch = new Stopwatch();
string result;
GC.Collect();
GC.WaitForPendingFinalizers();
watch.Start();
result = string.Concat( this.corpus.ToArray() );
watch.Stop();
Console.WriteLine( "Safe Time: {0:0.000} ms", watch.Elapsed.TotalMilliseconds, result[0] );
Console.WriteLine( "Memory usage: {0:0.000} MB", Environment.WorkingSet / 1000000.0 );
Console.WriteLine();
}
public void PerfTestUnsafe()
{
Stopwatch watch = new Stopwatch();
string result;
GC.Collect();
GC.WaitForPendingFinalizers();
watch.Start();
result = FastConcat.Concat( this.corpus );
watch.Stop();
Console.WriteLine( "Unsafe Time: {0:0.000} ms", watch.Elapsed.TotalMilliseconds, result[0] );
Console.WriteLine( "Memory usage: {0:0.000} MB", Environment.WorkingSet / 1000000.0 );
Console.WriteLine();
}
}
答案 2 :(得分:0)
我的前两个答案现已包含在问题中。这是我的高度依赖,但很有用 -
如果在所有这些字符串的MB中你得到了很多相同的字符串,那么更聪明的方法是使用两个字典,一个是Dictionary<int, int>
来存储position
和&#34 ;标识&#34;该位置的字符串,而另一个是Dictionary<int, int>
来存储&#34; Id&#34;和原始字符串[]中的实际字符串索引。
巧合的是,我想要做的事情已经在C#中实现了。有点像......
如果确实有很多相同的字符串,那么String Interning是否有用是否是罕见的情况?如果许多匹配的字符串来自内容制作者,您可以保证大量的 200 MB 目标。
什么是String.Intern?
当你在C#中使用字符串时,CLR会做一些聪明的事情 字符串实习。它是存储任何字符串的一个副本的一种方式。如果你 最终有一百个或更糟的一百万个字符串 价值,占用存储相同内存的所有内存都是浪费 一遍又一遍地串起来。字符串实习是一种解决方法。 CLR维护一个名为实习池的表,其中包含一个 对每个文字字符串的单一,唯一引用 在程序运行时以编程方式声明或创建。和 .NET Framework为您提供了两种有用的交互方法 实习池:String.Intern()和String.IsInterned()。
String.Intern()的工作方式非常简单。你传了一个 单个字符串作为参数。如果该字符串已经在实习生中 pool,它返回对该字符串的引用。如果它还没有 实习池,它添加它并返回您传递的相同引用 进入它。
链接中解释了使用String Interning的方法。为了完整答案,我可以在这里添加代码,但前提是您觉得这些解决方案很有用。
答案 3 :(得分:0)
StringBuilder旨在有效地连接字符串。它没有其他用途。
使用设置初始容量的构造函数:
int totalLength = CalcTotalLength();
// sufficient capacity
StringBuilder sb = new StringBuilder(totalLength);
但是你说甚至StringBuilder都会分配中间内存,你想做得更好......
这些是不寻常的要求,因此您需要编写一个适合您情况的函数(创建适当大小的char [],然后填入)。我相信你有能力。