Подтвердить что ты не робот

Проблема производительности String.Join в С#

Я изучал вопрос, который мне был представлен: как написать функцию, которая берет строку в качестве ввода и возвращает строку с пробелами между символами. Функция должна быть написана для оптимизации производительности, когда она называется тысячами раз в секунду.

  • Я знаю, что .net имеет функцию под названием String.Join, к которой я могу передать символ пробела в качестве разделителя вместе с исходной строкой.

  • Запрет использования String.Join, я могу использовать класс StringBuilder для добавления пробелов после каждого символа.

  • Другой способ выполнить эту задачу - объявить массив символов с 2 * n-1 символами (вы должны добавить n-1 символов для пробелов). Массив символов может быть заполнен в цикле, а затем передан в строку constructor.

Я написал некоторый .net-код, который запускает каждый из этих алгоритмов один миллион раз каждый с параметром "Hello, World" и измеряет, сколько времени требуется для его выполнения. Метод (3) намного, намного быстрее, чем (1) или (2).

Я знаю, что (3) должен быть очень быстрым, потому что он избегает создания каких-либо дополнительных ссылок на строки для сбора мусора, но мне кажется, что встроенная функция .net, такая как String.Join, должна обеспечивать хорошую производительность. Почему использование String.Join намного медленнее, чем выполнение работы вручную?

public static class TestClass
{
    // 491 milliseconds for 1 million iterations
    public static string Space1(string s) 
    {            
        return string.Join(" ", s.AsEnumerable());
    }

    //190 milliseconds for 1 million iterations
    public static string Space2(string s) 
    {
        if (s.Length < 2)
            return s;
        StringBuilder sb = new StringBuilder();
        sb.Append(s[0]);
        for (int i = 1; i < s.Length; i++)
        {
            sb.Append(' ');
            sb.Append(s[i]);
        }            
        return sb.ToString();
    }

    // 50 milliseconds for 1 million iterations
    public static string Space3(string s) 
    {
        if (s.Length < 2)
            return s;
        char[] array = new char[s.Length * 2 - 1];
        array[0] = s[0];
        for (int i = 1; i < s.Length; i++)
        {
            array[2*i-1] = ' ';
            array[2*i] = s[i];
        }
        return new string(array);
    }

Обновление: Я изменил свой проект на режим "Отпуск" и обновил мои прошедшие времена в вопросе соответственно.

4b9b3361

Ответ 1

Почему использование String.Join намного медленнее, чем выполнение работы вручную?

Причина String.Join в этом случае медленнее, так это то, что вы можете написать алгоритм, который ранее знал точный характер вашего IEnumerable<T>.

String.Join<T>(string, IEnumerable<T>) (с перегрузкой, с которой вы работаете), с другой стороны, предназначен для работы с любым произвольным перечислимым типом, что означает, выделять соответствующий размер. В этом случае это гибкость в торговле для чистой производительности и скорости.

Многие из методов framework обрабатывают определенные случаи, когда вещи могут ускоряться, проверяя условия, но это обычно делается только тогда, когда этот "специальный случай" будет распространен.

В этом случае вы эффективно создаете граничный случай, когда ручная программа будет работать быстрее, но это не обычный вариант использования String.Join. В этом случае, поскольку вы точно знаете, что требуется, у вас есть возможность избежать всех накладных расходов, необходимых для гибкого проектирования, предварительно распределив массив точно такого же размера и создав результаты вручную.

Вы обнаружите, что в общем случае часто можно написать метод, который не выполнит некоторые из фреймворческих подпрограмм для конкретных входных данных. Это обычное явление, поскольку подпрограммы фреймворка должны работать с любым набором данных, а это значит, что вы не можете оптимизировать конкретный сценарий ввода.

Ответ 2

Пример String.Join работает с IEnumerable<char>. Перечисление IEnumerable<T> с помощью foreach часто медленнее, чем выполнение цикла for (это зависит от типа коллекции и других обстоятельств, как отметил Дэйв Блэк в комментарии). Даже если Join использует StringBuilder, внутренний буфер StringBuilder должен быть увеличен несколько раз, так как количество элементов для добавления неизвестно заранее.

Ответ 3

Поскольку вы не используете сборку Release (которая должна быть проверена по умолчанию) и/или вы отлаживаете визуальную студию, JITER будет предотвращать большую оптимизацию. Из-за этого вы просто не получаете хорошую картину того, как долго каждая операция действительно выполняется. После добавления оптимизаций вы можете получить реальную картину того, что происходит.

Также важно, чтобы вы не отлаживали визуальную студию. Перейдите в папку bin/release и дважды щелкните исполняемый файл полностью за пределами визуальной студии.

Ответ 4

В первом методе вы используете перегрузку String.Join, которая работает с Enumerable, которая требует, чтобы метод проходил символы строки, используя перечислитель. Внутри это использует StringBuilder, поскольку точное количество символов неизвестно.

Считаете ли вы использование перегрузки String.Join, которая берет строку (или строковый массив)? Эта реализация позволяет использовать буфер фиксированной длины (аналогичный вашему третьему методу) наряду с некоторыми внутренними небезопасными строковыми операциями для скорости. Звонок изменился бы на - String.Join(" ", s); Без фактического выполнения ножки для измерения я ожидал бы, что это будет на уровне или быстрее, чем ваш третий подход.

Ответ 5

Плохая производительность не идет от String.Join, а от того, как вы обрабатываете каждый символ. В этом случае, поскольку персонажи должны обрабатываться индивидуально, ваш первый метод будет создавать гораздо более промежуточные строки, а второй метод будет иметь два вызова метода .Append для каждого символа. Ваш третий метод не включает в себя множество промежуточных строк или методов вызовов и что причина, по которой ваш третий метод является самым быстрым.

Ответ 6

Когда вы прошли IEnumerable до String.Join, он не знает, сколько памяти нужно выделить. Я выделяю кусок памяти, меняет его размер, если он недостаточен, и повторяет процесс, пока он не получит достаточно памяти для размещения всех строк.

Версия массива выполняется быстрее, потому что мы знаем объем выделенной памяти.

Также не рекомендуется, чтобы при запуске 1-й версии GC мог произойти.

Ответ 7

Очень интересно читать. Я хотел бы упомянуть, что

string.Join(" ", s.ToCharArray())

улучшает производительность чуть выше

string.Join(" ", s.AsEnumerable())

Вот тестовая страница, которую я использовал для сравнения:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics;
using System.Text;
using System.Web.UI;
public partial class Test : Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        var rankings = new Dictionary<string, double>();
        var test = new string('1', 1000000);
        Response.Write("Testing space separating a string with length: " + test.Length + ". Numbers are total milliseconds.<br/><br/>");
        var timer = Stopwatch.StartNew();
        Space1(test);
        timer.Stop();
        rankings.Add("string.join", timer.Elapsed.TotalMilliseconds);
        timer = Stopwatch.StartNew();
        Space2(test);
        timer.Stop();
        rankings.Add("stringbuilder", timer.Elapsed.TotalMilliseconds);
        timer = Stopwatch.StartNew();
        Space3(test);
        timer.Stop();
        rankings.Add("char array", timer.Elapsed.TotalMilliseconds);
        var list = rankings.ToList();
        list.Sort((x, y) => x.Value.CompareTo(y.Value));
        foreach (var rank in list)
        {
            Response.Write(rank + "<br/>");
        }
    }
    private static string Space1(string s)
    {
        //return string.Join(" ", s.AsEnumerable());
        return string.Join(" ", s.ToCharArray());
    }
    private static string Space2(string s)
    {
        if (s.Length < 2)
        {
            return s;
        }
        var sb = new StringBuilder();
        sb.Append(s[0]);
        for (var i = 1; i < s.Length; i++)
        {
            sb.Append(' ');
            sb.Append(s[i]);
        }
        return sb.ToString();
    }
    private static string Space3(string s)
    {
        if (s.Length < 2)
        {
            return s;
        }
        var array = new char[s.Length * 2 - 1];
        array[0] = s[0];
        for (var i = 1; i < s.Length; i++)
        {
            array[2 * i - 1] = ' ';
            array[2 * i] = s[i];
        }
        return new string(array);
    }
}

Пример вывода (почти всегда одного порядка):

Тестирование пространства, разделяющего строку длиной: 1000000. Числа - это общие миллисекунды.

[char array, 14.7902]

[stringbuilder, 25.6643]

[string.join, 61.7402]