String.join vs StringBuilder: que es más rápido?
-
06-09-2019 - |
Pregunta
En una pregunta anterior href="https://stackoverflow.com/questions/585215/can-i-rewrite-this-more-elegantly-using-linq"> sobre el formato de un double[][]
a formato CSV, se sugirió que el uso de StringBuilder
sería más rápido que String.Join
. ¿Es esto cierto?
Solución
Respuesta corta:. Depende
Respuesta larga: si ya tiene una matriz de cadenas para concatenar juntos (con un delimitador), String.Join
es la manera más rápida de hacerlo
String.Join
puede mirar a través de todas las cuerdas para calcular la longitud exacta que necesita, y luego ir de nuevo y copiar todos los datos. Esto significa que habrá no copia adicional implicado. El solamente inconveniente es que tiene que pasar por las cuerdas dos veces, lo que significa que potencialmente soplando la memoria caché más veces de lo necesario.
Si no tener las cadenas como una matriz de antemano, es probablemente más rápido de usar StringBuilder
- pero habrá situaciones en las que no lo es. Si se utiliza un StringBuilder
significa hacer montones y montones de copias, a continuación, la construcción de una matriz y luego llamar String.Join
bien puede ser más rápido.
EDIT: Esto es en términos de una sola llamada a String.Join
contra un montón de llamadas a StringBuilder.Append
. En la pregunta original, que tenía dos niveles diferentes de llamadas String.Join
, por lo que cada una de las llamadas anidadas habríamos creado una cadena intermedia. En otras palabras, es aún más complejo y más difícil de adivinar. Me sorprendería de ver de cualquier manera "ganar" significativamente (en términos de complejidad) con datos típicos.
EDIT: Cuando estoy en casa, voy a escribir un punto de referencia que es tan doloroso como, posiblemente, para StringBuilder
. Básicamente, si usted tiene una matriz donde cada elemento es aproximadamente el doble del tamaño de la anterior, y se obtiene lo justo, usted debería ser capaz de forzar una copia para cada modo de adición (de elementos, no de delimitador, a pesar de que tiene que debe tenerse en cuenta también). En ese punto que es casi tan malo como la concatenación de cadenas simples - pero String.Join
no tendrá problemas
Otros consejos
Aquí está mi banco de pruebas, utilizando int[][]
por simplicidad; Primeros resultados:
Join: 9420ms (chk: 210710000
OneBuilder: 9021ms (chk: 210710000
(actualización para double
resultados:)
Join: 11635ms (chk: 210710000
OneBuilder: 11385ms (chk: 210710000
(actualización re 2048 * 64 * 150)
Join: 11620ms (chk: 206409600
OneBuilder: 11132ms (chk: 206409600
y activar con OptimizeForTesting:
Join: 11180ms (chk: 206409600
OneBuilder: 10784ms (chk: 206409600
Por lo tanto más rápido, pero no tan masivamente; aparejo (ejecutar en la consola, en modo de lanzamiento, etc.):
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
namespace ConsoleApplication2
{
class Program
{
static void Collect()
{
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
}
static void Main(string[] args)
{
const int ROWS = 500, COLS = 20, LOOPS = 2000;
int[][] data = new int[ROWS][];
Random rand = new Random(123456);
for (int row = 0; row < ROWS; row++)
{
int[] cells = new int[COLS];
for (int col = 0; col < COLS; col++)
{
cells[col] = rand.Next();
}
data[row] = cells;
}
Collect();
int chksum = 0;
Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < LOOPS; i++)
{
chksum += Join(data).Length;
}
watch.Stop();
Console.WriteLine("Join: {0}ms (chk: {1}", watch.ElapsedMilliseconds, chksum);
Collect();
chksum = 0;
watch = Stopwatch.StartNew();
for (int i = 0; i < LOOPS; i++)
{
chksum += OneBuilder(data).Length;
}
watch.Stop();
Console.WriteLine("OneBuilder: {0}ms (chk: {1}", watch.ElapsedMilliseconds, chksum);
Console.WriteLine("done");
Console.ReadLine();
}
public static string Join(int[][] array)
{
return String.Join(Environment.NewLine,
Array.ConvertAll(array,
row => String.Join(",",
Array.ConvertAll(row, x => x.ToString()))));
}
public static string OneBuilder(IEnumerable<int[]> source)
{
StringBuilder sb = new StringBuilder();
bool firstRow = true;
foreach (var row in source)
{
if (firstRow)
{
firstRow = false;
}
else
{
sb.AppendLine();
}
if (row.Length > 0)
{
sb.Append(row[0]);
for (int i = 1; i < row.Length; i++)
{
sb.Append(',').Append(row[i]);
}
}
}
return sb.ToString();
}
}
}
Yo no lo creo. Mirando a través del reflector, la implementación de String.Join
se ve muy optimizado. También tiene la ventaja añadida de saber el tamaño total de la cadena que se ha creado de antemano, por lo que no necesita ninguna reasignación.
He creado dos métodos de prueba para compararlos:
public static string TestStringJoin(double[][] array)
{
return String.Join(Environment.NewLine,
Array.ConvertAll(array,
row => String.Join(",",
Array.ConvertAll(row, x => x.ToString()))));
}
public static string TestStringBuilder(double[][] source)
{
// based on Marc Gravell's code
StringBuilder sb = new StringBuilder();
foreach (var row in source)
{
if (row.Length > 0)
{
sb.Append(row[0]);
for (int i = 1; i < row.Length; i++)
{
sb.Append(',').Append(row[i]);
}
}
}
return sb.ToString();
}
me encontré con cada método 50 veces, pasando en una matriz de tamaño [2048][64]
. Hice esto por dos matrices; uno lleno de ceros y otro lleno de valores aleatorios. Me dieron los siguientes resultados en mi máquina (P4 a 3,0 GHz, de un solo núcleo, sin HT, que se ejecutan de modo de lanzamiento CMD):
// with zeros:
TestStringJoin took 00:00:02.2755280
TestStringBuilder took 00:00:02.3536041
// with random values:
TestStringJoin took 00:00:05.6412147
TestStringBuilder took 00:00:05.8394650
El aumento del tamaño de la matriz a [2048][512]
, mientras que disminuye el número de iteraciones a 10 me consiguió los siguientes resultados:
// with zeros:
TestStringJoin took 00:00:03.7146628
TestStringBuilder took 00:00:03.8886978
// with random values:
TestStringJoin took 00:00:09.4991765
TestStringBuilder took 00:00:09.3033365
Los resultados son repetibles (casi; con pequeñas fluctuaciones causadas por diferentes valores aleatorios). Al parecer String.Join
es un poco más rápido la mayoría del tiempo (aunque por un margen muy pequeño).
Este es el código que he usado para las pruebas:
const int Iterations = 50;
const int Rows = 2048;
const int Cols = 64; // 512
static void Main()
{
OptimizeForTesting(); // set process priority to RealTime
// test 1: zeros
double[][] array = new double[Rows][];
for (int i = 0; i < array.Length; ++i)
array[i] = new double[Cols];
CompareMethods(array);
// test 2: random values
Random random = new Random();
double[] template = new double[Cols];
for (int i = 0; i < template.Length; ++i)
template[i] = random.NextDouble();
for (int i = 0; i < array.Length; ++i)
array[i] = template;
CompareMethods(array);
}
static void CompareMethods(double[][] array)
{
Stopwatch stopwatch = Stopwatch.StartNew();
for (int i = 0; i < Iterations; ++i)
TestStringJoin(array);
stopwatch.Stop();
Console.WriteLine("TestStringJoin took " + stopwatch.Elapsed);
stopwatch.Reset(); stopwatch.Start();
for (int i = 0; i < Iterations; ++i)
TestStringBuilder(array);
stopwatch.Stop();
Console.WriteLine("TestStringBuilder took " + stopwatch.Elapsed);
}
static void OptimizeForTesting()
{
Thread.CurrentThread.Priority = ThreadPriority.Highest;
Process currentProcess = Process.GetCurrentProcess();
currentProcess.PriorityClass = ProcessPriorityClass.RealTime;
if (Environment.ProcessorCount > 1) {
// use last core only
currentProcess.ProcessorAffinity
= new IntPtr(1 << (Environment.ProcessorCount - 1));
}
}
A menos que el 1% de diferencia se convierte en algo significativo en términos del tiempo de todo el programa necesita para funcionar, esto se parece a la micro-optimización. Me gustaría escribir el código que es la más legible / comprensible y no preocuparse por la diferencia de rendimiento 1%.
Atwood tenía una especie de mensaje relacionado con esto hace un mes:
Sí. Si lo hace más de un par de combinaciones, será mucho más rápido.
Cuando usted hace una string.join, el tiempo de ejecución tiene que:
- Asignar memoria para la cadena resultante
- copiar el contenido de la primera cuerda al principio de la cadena de salida
- copiar el contenido de la segunda cadena al final de la cadena de salida.
Si lo hace dos combinaciones, tiene que copiar los datos dos veces, y así sucesivamente.
StringBuilder asigna un tampón con espacio de sobra, lo que los datos se pueden añadir sin tener que copiar la cadena original. Como no hay espacio de sobra en el búfer, la cadena anexa se puede escribir directamente en la memoria intermedia. Entonces sólo tiene que copiar toda la cadena de una vez, al final.