是否可以仅使用一次分配来连接字符串列表?

时间:2015-08-26 02:51:55

标签: c# .net concatenation stringbuilder

在进行一些分析之后,我们发现我们的应用程序连接字符串的当前方式会导致大量的内存流失和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>

编辑1

我想总结到目前为止讨论的各种方法,以及为什么它们仍然是次优的。我还想更具体地设置具体情况的参数,因为我已经收到了许多试图支持中心问题的问题。

...

首先,我在其中工作的代码的结构。有三层:

  • 第一层是一组产生我内容的方法。这些方法返回小字符串对象,我称之为“&#39;组件”。串&#39 ;.这些字符串对象最终将连接成一个字符串。我没有能力修改这些方法;我必须面对现实,他们返回字符串对象并继续前进。
  • 第二层是我的代码,它调用这些内容生成器并组装输出,并且是这个问题的主题。我必须调用内容生成器方法,收集它们返回的字符串,并最终将返回的字符串连接成一个字符串(现实情况稍微复杂一些;返回的字符串根据它们为输出路由的方式进行分区,以及所以我有几组大型字符串集合。
  • 第三层是一组接受单个大字符串进行进一步处理的方法。更改该代码的界面是我无法控制的。

谈论一些数字:典型的批处理运行将从内容生成器收集~500000个字符串,代表大约200-500 MB的内存。我需要最有效的方法将这些500k字符串连接成一个字符串。

...

现在我想研究到目前为止讨论的方法。为了数字,假设我们正在运行64位,假设我们正在收集500000个字符串对象,并假设字符串对象的聚合大小总计200兆字节的字符数据。此外,假设在以下分析中,原始字符串对象的存储器不计入任何方法的总计。我做出这个假设是因为它对于任何和所有方法都是通用的,因为它假设我们不能改变内容生成器的接口 - 它们返回500k相对较小的完全形成的字符串对象,然后我必须接受并以某种方式连接。如上所述,我无法更改此界面。

方法#1

内容制作者----&gt; StringBuilder ----&gt; string

从概念上讲,这将调用内容生成器,并直接将它们返回的字符串写入StringBuilder,然后调用StringBuilder.ToString()以获取连接的字符串。

通过分析StringBuilder的实施情况,我们可以看到,其成本可归结为400 MB的分配和副本:

  • 在我们收集内容制作人的输出的阶段,我们将200 MB的数据写入StringBuilder。我们将执行一个200 MB的分配来预先分配StringBuilder,然后在我们复制并丢弃从内容制作者返回的字符串时再分配200 MB的副本
  • 在我们收集了内容制作者的所有输出并完成StringBuilder之后,我们需要调用StringBuilder.ToString()。这只执行一次分配(string.FastAllocateString()),然后将字符串数据从其内部缓冲区复制到字符串对象的内部存储器中。

总费用:约400 MB的分配和副本

方法#2

内容制作者---&gt;预先分配char[] ---&gt; string

这个策略相当简单。假设我们大致知道我们将从生产者那里收集多少字符数据,我们可以预先分配一个200 MB大的char[]。然后,当我们调用内容生成器时,我们将它们返回的字符串复制到char[]中。这占了200 MB的分配和副本。将其转换为字符串对象的最后一步是将其传递给new string(char[])构造函数。但是,由于字符串是不可变的而数组不是,因此构造函数将复制整个数组,从而分配和复制另外200 MB的字符数据。

总费用:约400 MB的分配和副本

方法#3:

内容制作者---&gt; List<string> ----&gt; string[] ----&gt; string.Concat(string[])

  • 预分配List<string>大约500k个元素 - 大约4 MB的List基础数组(每个指针500k * 8个字节= 4 MB内存)。
  • 调用所有内容制作者来收集他们的字符串。大约4 MB的副本,因为我们将返回的字符串的指针复制到List的底层数组中。
  • 致电List<string>.ToArray()获取string[]。大约4 MB的分配和副本(再次,我们真的只是复制指针)。
  • 致电string.Concat(string[])
    • Concat会在完成任何实际工作之前复制提供给它的数组。再次大约4 MB的分配和副本。
    • Concat将分配一个目的地&#39;使用内部string.FastAllocateString()特殊方法的字符串对象。大约200 MB的分配。
    • 然后,Concat会将字符串从其提供的数组的内部副本直接复制到目标中。大约200 MB的副本。

总费用:约212 MB的分配和副本

这些方法都不是理想的,但方法#3非常接近。我们假设需要分配和复制的内存的绝对最小值是200 MB(对于目标字符串),这里我们非常接近 - 212 MB。

如果有string.Concat重载,1)接受IList<string>并且2)在使用它之前没有复制该IList,那么问题就解决了。 .Net没有提供这样的方法,因此是这个问题的主题。

编辑2

解决方案的进展

我已经对一些被黑客入侵的IL做了一些测试,发现直接调用string.FastAllocateString(n)(通常不可调用...)与调用new string('\0', n)一样快,两者都是似乎分配的内存与预期的完全一样。

从那里,似乎可以使用unsafefixed语句获取指向新分配字符串的指针。

因此,一个粗略的解决方案开始出现:

    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*类型的方法除外。

4 个答案:

答案 0 :(得分:2)

如果在尝试执行操作之前可以确定并置的长度,则char数组可以在某些用例中击败字符串构建器。操作数组中的字符可以防止多次分配。

请参阅:http://blogs.msdn.com/b/cisg/archive/2008/09/09/performance-analysis-reveals-char-array-is-better-than-stringbuilder.aspx

<强>更新

请从.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;
    } 

来源:http://www.dotnetframework.org/default.aspx/4@0/4@0/DEVDIV_TFS/Dev10/Releases/RTMRel/ndp/clr/src/BCL/System/String@cs/1305376/String@cs

更新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() )
}

内存消耗

  • &#34;不安全&#34;实现只执行一次分配和零临时分配。 List<string>直接连接成一个新分配的string对象。
  • &#34; safe&#34;实现需要两个列表副本 - 一个是当我调用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 [],然后填入)。我相信你有能力。