String.Join vs. StringBuilder: Was ist schneller?

80

In einer früheren Frage zur Formatierung eines double[][]CSV-Formats wurde vorgeschlagen, dass die Verwendung StringBuilderschneller als ist String.Join. Ist das wahr?

Hosam Aly
quelle
Aus Gründen der Klarheit der Leser ging es darum, einen einzelnen StringBuilder gegen mehrere string.Join zu verwenden, die dann verbunden wurden (n + 1 Joins)
Marc Gravell
2
Der Leistungsunterschied beträgt schnell mehrere Größenordnungen. Wenn Sie mehr als eine Handvoll Joins ausführen , können Sie viel Leistung erzielen, indem Sie zu Stringbuilder wechseln
Jalf

Antworten:

115

Kurze Antwort: es kommt darauf an.

Lange Antwort: Wenn Sie bereits eine Reihe von Zeichenfolgen zum Verketten haben (mit einem Trennzeichen), String.Joinist dies der schnellste Weg.

String.JoinSie können alle Zeichenfolgen durchsuchen, um die genaue Länge zu ermitteln. Kopieren Sie dann erneut alle Daten. Dies bedeutet, dass kein zusätzliches Kopieren erforderlich ist. Der einzige Nachteil ist, dass die Zeichenfolgen zweimal durchlaufen werden müssen, was bedeutet, dass der Speichercache möglicherweise öfter als erforderlich gelöscht wird.

Wenn Sie die Zeichenfolgen vorher nicht als Array haben, ist die Verwendung wahrscheinlich schneller StringBuilder- aber es wird Situationen geben, in denen dies nicht der Fall ist. Wenn Sie ein StringBuilderMittel verwenden, um viele, viele Kopien zu String.Joinerstellen , kann das Erstellen eines Arrays und das anschließende Aufrufen möglicherweise schneller sein.

BEARBEITEN: Dies ist in Bezug auf einen einzelnen Anruf an String.Joingegenüber einer Reihe von Anrufen an StringBuilder.Append. In der ursprünglichen Frage hatten wir zwei verschiedene Aufrufebenen String.Join, sodass jeder der verschachtelten Aufrufe eine Zwischenzeichenfolge erstellt hätte. Mit anderen Worten, es ist noch komplexer und schwieriger zu erraten. Ich wäre überrascht zu sehen, dass beide Arten mit typischen Daten signifikant (in Bezug auf die Komplexität) "gewinnen".

EDIT: Wenn ich zu Hause bin, schreibe ich einen Benchmark auf, der so schmerzhaft wie möglich ist StringBuilder. Wenn Sie ein Array haben, in dem jedes Element etwa doppelt so groß ist wie das vorherige, und Sie es genau richtig machen, sollten Sie in der Lage sein, eine Kopie für jedes Anhängen zu erzwingen (von Elementen, nicht vom Trennzeichen, obwohl dies erforderlich ist auch berücksichtigt werden). Zu diesem Zeitpunkt ist es fast so schlimm wie eine einfache Verkettung von Zeichenfolgen - aber es String.Joinwird keine Probleme geben.

Jon Skeet
quelle
6
Selbst wenn ich die Zeichenfolgen vorher nicht habe, scheint es schneller zu sein, String.Join zu verwenden. Bitte überprüfen Sie meine Antwort ...
Hosam Aly
2
Es hängt davon ab, wie das Array hergestellt wird, wie groß es ist usw. Ich gebe gerne ein ziemlich definitives "In diesem Fall wird String.Join mindestens genauso schnell sein" - das würde ich nicht gerne tun umkehren.
Jon Skeet
4
(Schauen Sie sich insbesondere Marc's Antwort an, in der StringBuilder String.Join schlägt. Das Leben ist kompliziert.)
Jon Skeet
2
@BornToCode: Meinst du damit, eine StringBuildermit einer Originalzeichenfolge zu konstruieren und dann Appendeinmal aufzurufen ? Ja, ich würde erwarten string.Join, dort zu gewinnen.
Jon Skeet
13
[Thread-Nekromantie]: Aktuelle (.NET 4.5) Implementierung von string.JoinVerwendungen StringBuilder.
n0rd
31

Hier ist mein Prüfstand, der der int[][]Einfachheit halber verwendet wird. Ergebnisse zuerst:

Join: 9420ms (chk: 210710000
OneBuilder: 9021ms (chk: 210710000

(Update für doubleErgebnisse :)

Join: 11635ms (chk: 210710000
OneBuilder: 11385ms (chk: 210710000

(Update zu 2048 * 64 * 150)

Join: 11620ms (chk: 206409600
OneBuilder: 11132ms (chk: 206409600

und mit aktiviertem OptimizeForTesting:

Join: 11180ms (chk: 206409600
OneBuilder: 10784ms (chk: 206409600

So schneller, aber nicht massiv; Rig (an der Konsole, im Release-Modus usw. ausführen):

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();
        }
    }
}
Marc Gravell
quelle
Danke Marc. Was bekommen Sie für größere Arrays? Ich verwende zum Beispiel [2048] [64] (ungefähr 1 MB). Unterscheiden sich Ihre Ergebnisse auch irgendwie, wenn Sie die von mir verwendete OptimizeForTesting()Methode verwenden?
Hosam Aly
Vielen Dank Marc. Ich stelle jedoch fest, dass dies nicht das erste Mal ist, dass wir unterschiedliche Ergebnisse für Mikro-Benchmarks erhalten. Haben Sie eine Idee, warum dies sein könnte?
Hosam Aly
2
Karma? Kosmische Strahlung? Wer weiß ... es zeigt jedoch die Gefahren der Mikrooptimierung ;-p
Marc Gravell
Verwenden Sie zum Beispiel einen AMD-Prozessor? ET64? Vielleicht habe ich zu wenig Cache-Speicher (512 KB)? Oder ist das .NET Framework unter Windows Vista möglicherweise optimierter als das für XP SP3? Was denken Sie? Ich bin wirklich interessiert daran, warum dies geschieht ...
Hosam Aly
XP SP3, x86, Intel Core2 Duo T7250 bei 2 GHz
Marc Gravell
20

Das glaube ich nicht. Durch Reflector String.Joinsieht die Implementierung von sehr optimiert aus. Es hat auch den zusätzlichen Vorteil, dass die Gesamtgröße der zu erstellenden Zeichenfolge im Voraus bekannt ist, sodass keine Neuzuweisung erforderlich ist.

Ich habe zwei Testmethoden erstellt, um sie zu vergleichen:

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();
}

Ich habe jede Methode 50 Mal ausgeführt und dabei ein Array von Größen übergeben [2048][64]. Ich habe das für zwei Arrays gemacht; eine mit Nullen und eine mit zufälligen Werten. Ich habe die folgenden Ergebnisse auf meinem Computer erhalten (P4 3,0 GHz, Single-Core, kein HT, mit Release-Modus von 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

Durch Erhöhen der Größe des Arrays auf [2048][512]und Verringern der Anzahl der Iterationen auf 10 wurden die folgenden Ergebnisse erzielt:

// 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

Die Ergebnisse sind wiederholbar (fast; mit kleinen Schwankungen, die durch unterschiedliche Zufallswerte verursacht werden). Anscheinend String.Joinist es die meiste Zeit etwas schneller (wenn auch mit sehr geringem Abstand).

Dies ist der Code, den ich zum Testen verwendet habe:

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));
    }
}
Hosam Aly
quelle
13

Sofern sich der Unterschied von 1% nicht in Bezug auf die Zeit, die das gesamte Programm benötigt, um etwas Wesentliches zu ändern, sieht dies nach einer Mikrooptimierung aus. Ich würde den Code schreiben, der am besten lesbar / verständlich ist, und mich nicht um den Leistungsunterschied von 1% sorgen.

Tvanfosson
quelle
1
Ich glaube, der String.Join ist verständlicher, aber der Beitrag war eher eine lustige Herausforderung. :) Es ist auch nützlich (IMHO) zu lernen, dass die Verwendung einiger integrierter Methoden besser sein kann als die manuelle Ausführung, selbst wenn die Intuition etwas anderes vorschlägt. ...
Hosam Aly
... Normalerweise hätten viele Leute vorgeschlagen, den StringBuilder zu verwenden. Selbst wenn sich String.Join als 1% langsamer herausstellen würde, hätten viele Leute nicht darüber nachgedacht, nur weil sie denken, dass StringBuilder schneller ist.
Hosam Aly
Ich habe kein Problem mit der Untersuchung, aber jetzt, wo Sie eine Antwort haben, bin ich mir nicht sicher, ob die Leistung das übergeordnete Anliegen ist. Da ich mir einen Grund vorstellen kann, eine Zeichenfolge in CSV zu erstellen, außer sie in einen Stream zu schreiben, würde ich die Zwischenzeichenfolge wahrscheinlich überhaupt nicht erstellen.
Tvanfosson
-3

Ja. Wenn Sie mehr als ein paar Joins ausführen, ist dies viel schneller.

Wenn Sie eine string.join ausführen, muss die Laufzeit:

  1. Ordnen Sie Speicher für die resultierende Zeichenfolge zu
  2. Kopieren Sie den Inhalt der ersten Zeichenfolge an den Anfang der Ausgabezeichenfolge
  3. Kopieren Sie den Inhalt der zweiten Zeichenfolge an das Ende der Ausgabezeichenfolge.

Wenn Sie zwei Verknüpfungen durchführen, müssen die Daten zweimal kopiert werden, und so weiter.

StringBuilder weist einen Puffer mit freiem Speicherplatz zu, sodass Daten angehängt werden können, ohne dass die ursprüngliche Zeichenfolge kopiert werden muss. Da im Puffer noch Platz vorhanden ist, kann die angehängte Zeichenfolge direkt in den Puffer geschrieben werden. Dann muss es am Ende nur noch einmal die gesamte Zeichenfolge kopieren.

jalf
quelle
1
Aber String.Join weiß im Voraus, wie viel zuzuweisen ist, während StringBuilder dies nicht tut. Weitere Informationen finden Sie in meiner Antwort.
Hosam Aly
@erikkallen: Sie können den Code für String.Join in Reflector sehen. red-gate.com/products/reflector/index.htm
Hosam Aly