【发布时间】:2015-08-26 02:51:54
【问题描述】:
在进行了一些分析之后,我们发现我们的应用连接字符串的当前方式会导致大量的内存流失和 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
我想总结一下目前讨论的各种方法,以及为什么它们仍然不是最理想的。我还想更具体地设置情况参数,因为我收到了很多试图绕过中心问题的问题。
...
首先,我正在使用的代码结构。共有三层:
- 第一层是一组生成我的内容的方法。这些方法返回小的字符串对象,我将其称为“组件”字符串。这些字符串对象最终将连接成一个字符串。我没有能力修改这些方法;我必须面对他们返回字符串对象并继续前进的现实。
- 第二层是我的代码,它调用这些内容生产者并组装输出,是这个问题的主题。我必须调用内容生产者方法,收集它们返回的字符串,并最终将返回的字符串连接成一个字符串(现实有点复杂;返回的字符串根据它们被路由输出的方式进行分区,所以我有几组大的字符串集合)。
- 第三层是一组接受单个大字符串进行进一步处理的方法。更改该代码的界面超出了我的控制范围。
谈谈一些数字:典型的批处理运行将从内容制作者那里收集大约 500000 个字符串,代表大约 200-500 MB 的内存。我需要最有效的方法将这 500k 字符串连接成一个字符串。
...
现在我想检查一下目前讨论的方法。为了数字,假设我们运行 64 位,假设我们正在收集 500000 个字符串对象,并假设字符串对象的总大小总计 200 兆字节的字符数据。此外,在下面的分析中,假设原始字符串对象的内存不计入任何方法的总数。我做出这个假设是因为它对任何和所有方法都必然是通用的,因为它假设我们不能改变内容生产者的接口——它们返回 500k 相对较小的完全形成的字符串对象,然后我必须接受这些对象并以某种方式连接。如上所述,我无法更改此界面。
方法#1
内容制作者 ----> StringBuilder ----> string
从概念上讲,这将是调用内容生产者,并将它们返回的字符串直接写入StringBuilder,然后再调用StringBuilder.ToString() 以获取连接的字符串。
通过分析StringBuilder 的实现,我们可以看到这样做的成本归结为 400 MB 的分配和副本:
- 在我们从内容制作者收集输出的阶段,我们将 200 MB 的数据写入
StringBuilder。我们将执行一次 200 MB 的分配来预分配StringBuilder,然后在复制和丢弃从内容制作者返回的字符串时再分配 200 MB 的副本 - 在我们收集了内容制作者的所有输出并形成了完整的
StringBuilder之后,我们需要调用StringBuilder.ToString()。这仅执行一次分配 (string.FastAllocateString()),然后将字符串数据从其内部缓冲区复制到字符串对象的内部存储器。
总成本:大约 400 MB 的分配和副本
方法#2
内容制作者 ---> 预分配 char[] ---> string
这个策略相当简单。假设我们大致知道我们将从生产者那里收集多少字符数据,我们可以预先分配一个 200 MB 大的char[]。然后,当我们调用内容制作者时,我们将他们返回的字符串复制到我们的char[] 中。这占 200 MB 的分配和副本。将其转换为字符串对象的最后一步是将其传递给new string(char[]) 构造函数。但是,由于字符串是不可变的,而数组不是,构造函数将复制整个数组,使其分配和复制另外 200 MB 的字符数据。
总成本:大约 400 MB 的分配和副本
方法#3:
内容制作者 ---> List<string> ----> string[] ----> string.Concat(string[])
- 将
List<string>预分配为大约 500k 个元素 - 为 List 的底层数组分配大约 4 MB(500k * 每个指针 8 字节 == 4 MB 内存)。 - 致电所有内容制作者以收集他们的字符串。大约 4 MB 的副本,因为我们将指向返回字符串的指针复制到 List 的底层数组中。
- 致电
List<string>.ToArray()以获取string[]。大约 4 MB 的分配和副本(同样,我们实际上只是在复制指针)。 - 致电
string.Concat(string[]):- Concat 将在执行任何实际工作之前复制提供给它的数组。再次,大约 4 MB 的分配和副本。
- 然后,Concat 将使用内部
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) 一样快,并且两者都似乎 em> 分配与预期一样多的内存。
从那里,似乎可以使用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;
}
下一个最大的障碍是实现或找到一种有效的块复制方法,例如 Buffer.BlockCopy,除了接受char* 类型的方法。
【问题讨论】:
-
理想情况下,如果您需要不同格式的数据,您不会使用
List<string>。为什么要这样存储?数据去哪儿了(只是到内存必须是字符串的形式)? -
@JonathanWood - “为什么要这样存储?”因为我们有一个大型文本处理应用程序,它最终将非常大的字符串提供给外部(本机)库。请专注于提出的问题。
-
可以使用
unsafe代码对字符串进行变异,因此您可以创建一个适当长度的字符串,然后将源字符串的字符复制到其中。但是您必须非常小心并确定自己在做什么。 -
@displayName:我倾向于不同意。我有 C 和汇编程序的背景,并在 C# 中进行了大量的字符串处理。这是我认为我擅长的问题。但是我的设置有问题。并且将跳出框框思考以更好地处理约束,这意味着理解这些约束。他不想要我的帮助,所以我不给。但这就是我需要帮助他的原因。
-
@JonathanWood:我支持你跳出框框思考。我的第一个答案是相同的证据。完全尊重您的不同意见。
标签: c# .net concatenation stringbuilder