String.Join против StringBuilder: что быстрее?

В предыдущем вопросе о форматировании double[][] в формат CSV предполагалось, что использование StringBuilder будет быстрее, чем String.Join . Это правда?

Ответов (5)

Решение

Короткий ответ: это зависит от обстоятельств.

Long answer: if you already have an array of strings to concatenate together (with a delimiter), String.Join is the fastest way of doing it.

String.Join can look through all of the strings to work out the exact length it needs, then go again and copy all the data. This means there will be no extra copying involved. The only downside is that it has to go through the strings twice, which means potentially blowing the memory cache more times than necessary.

Если вы не имеете строки в виде массива заранее, это , вероятно , быстрее использовать StringBuilder - но будут ситуации , когда это не так . Если вы используете StringBuilder средства, выполняющие много-много копий, то создание массива и последующий вызов String.Join могут быть быстрее.

РЕДАКТИРОВАТЬ: это с точки зрения одного вызова String.Join против нескольких вызовов StringBuilder.Append . В исходном вопросе у нас было два разных уровня String.Join вызовов, поэтому каждый из вложенных вызовов создавал бы промежуточную строку. Другими словами, это еще сложнее, и о чем труднее догадаться. Я был бы удивлен, увидев, что любой из этих способов значительно (с точки зрения сложности) "выиграет" с типичными данными.

РЕДАКТИРОВАТЬ: Когда я буду дома, я напишу тест, который будет настолько болезненным, насколько это возможно StringBuilder . В основном, если у вас есть массив, в котором каждый элемент примерно в два раза больше предыдущего, и вы все правильно понимаете, вы должны иметь возможность принудительно копировать для каждого добавления (элементов, а не разделителя, хотя это должно тоже следует учитывать). На этом этапе это почти так же плохо, как простая конкатенация строк, но не String.Join будет проблем.

Я так не думаю. Просматривая Reflector, реализация String.Join выглядит очень оптимизированной. Он также имеет дополнительное преимущество: заранее известно общий размер создаваемой строки, поэтому перераспределение не требуется.

Я создал два метода тестирования, чтобы сравнить их:

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

Я запускал каждый метод 50 раз, передавая массив размера [2048][64] . Я сделал это для двух массивов; один заполнен нулями, а другой заполнен случайными значениями. Я получил следующие результаты на своей машине (P4 3,0 ГГц, одноядерный, без HT, работает режим Release из 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

Увеличение размера массива до 10 [2048][512] при уменьшении количества итераций до 10 дало мне следующие результаты:

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

Результаты воспроизводимы (почти; с небольшими колебаниями, вызванными разными случайными значениями). По-видимому, String.Join большую часть времени работает немного быстрее (хотя и с очень небольшим отрывом).

Это код, который я использовал для тестирования:

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

да. Если вы сделаете больше пары соединений, это будет намного быстрее.

Когда вы выполняете string.join, среда выполнения должна:

  1. Выделите память для результирующей строки
  2. скопируйте содержимое первой строки в начало выходной строки
  3. скопируйте содержимое второй строки в конец выходной строки.

Если вы выполняете два соединения, он должен скопировать данные дважды и так далее.

StringBuilder выделяет один буфер с свободным пространством, поэтому данные можно добавлять без необходимости копировать исходную строку. Поскольку в буфере осталось свободное место, добавленную строку можно записать в буфер напрямую. Затем ему просто нужно скопировать всю строку один раз, в конце.

Вот мой испытательный стенд, использующий int[][] для простоты; сначала результаты:

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

(обновите double результаты :)

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

(обновление 2048 * 64 * 150)

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

и с включенным OptimizeForTesting:

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

Так быстрее, но не так массово; rig (запускать на консоли, в режиме выпуска и т. д.):

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

Если разница в 1% не превратится в нечто существенное с точки зрения времени, необходимого для выполнения всей программы, это выглядит как микрооптимизация. Я бы написал код, который был бы наиболее читаемым / понятным, и не беспокоился бы о разнице в производительности в 1%.