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

.NET Multi-threading vs Multi-processing: Ужасная производительность Parallel.ForEach

Я закодировал очень простую программу "Word Count", которая читает файл и подсчитывает каждое вхождение слова в файл. Вот часть кода:

class Alaki
{
    private static List<string> input = new List<string>();

    private static void exec(int threadcount)
    {
        ParallelOptions options = new ParallelOptions();
        options.MaxDegreeOfParallelism = threadcount;
        Parallel.ForEach(Partitioner.Create(0, input.Count),options, (range) =>
        {
            var dic = new Dictionary<string, List<int>>();
            for (int i = range.Item1; i < range.Item2; i++)
            {
                //make some delay!
                //for (int x = 0; x < 400000; x++) ;                    

                var tokens = input[i].Split();
                foreach (var token in tokens)
                {
                    if (!dic.ContainsKey(token))
                        dic[token] = new List<int>();
                    dic[token].Add(1);
                }
            }
        });

    }

    public static void Main(String[] args)
    {            
        StreamReader reader=new StreamReader((@"c:\txt-set\agg.txt"));
        while(true)
        {
            var line=reader.ReadLine();
            if(line==null)
                break;
            input.Add(line);
        }

        DateTime t0 = DateTime.Now;
        exec(Environment.ProcessorCount);
        Console.WriteLine("Parallel:  " + (DateTime.Now - t0));
        t0 = DateTime.Now;
        exec(1);
        Console.WriteLine("Serial:  " + (DateTime.Now - t0));
    }
}

Это просто и прямо. Я использую словарь для подсчета каждого слова. Стиль примерно основан на модели программирования MapReduce. Как вы можете видеть, каждая задача использует собственный частный словарь. Таким образом, нет общих переменных; просто куча задач, которые подсчитывают слова сами по себе. Вот результат, когда код запускается на четырехъядерном процессоре i7:

Параллельно: 00: 00: 01.6220927
Номер: 00: 00: 02.0471171

Ускорение составляет около 1,25, что означает трагедию! Но когда я добавляю некоторую задержку при обработке каждой строки, я могу достичь значений ускорения около 4.

В исходном параллельном исполнении без задержки загрузка процессора едва достигает 30%, и поэтому ускорение не является многообещающим. Но, когда мы добавляем некоторую задержку, загрузка процессора достигает 97%.

Во-первых, я думал, что причиной является связанный с IO характер программы (но я думаю, что вставка в словарь в какой-то степени требует интенсивного процессора), и это кажется логичным, потому что все потоки считывают данные с шины общей памяти, Однако удивительным моментом является то, что одновременно я запускаю 4 экземпляра последовательных программ (без задержек), загрузка процессора достигает примерно рейзов, а все четыре экземпляра заканчиваются примерно через 2,3 секунды!

Это означает, что, когда код запускается в конфигурации многопроцессорности, он достигает значения ускорения около 3,5, но когда он запускается в конфигурации с несколькими потоками, ускорение составляет около 1,25.

Что вы думаете? Что-то не так в моем коде? Потому что я думаю, что нет общих данных вообще, и я думаю, что код не будет испытывать никаких утверждений. Есть ли недостаток во время выполнения .NET?

Спасибо заранее.

4b9b3361

Ответ 1

Parallel.For не делит вход на n частей (где n - MaxDegreeOfParallelism); вместо этого он создает множество небольших партий и гарантирует, что не более n обрабатывается одновременно. (Это так, что если одна партия занимает очень много времени для обработки, Parallel.For все еще может работать над другими потоками. См. Parallelism в .NET. - Часть 5, Partioning of Work для более подробной информации.)

Благодаря этому дизайну ваш код создает и отбрасывает десятки объектов Dictionary, сотни объектов List и тысячи объектов String. Это оказывает огромное давление на сборщик мусора.

Запуск PerfMonitor на моем компьютере сообщает, что 43% общего времени выполнения тратится в GC. Если вы переписываете свой код для использования меньшего количества временных объектов, вы должны увидеть желаемое ускорение в 4 раза. Ниже приводятся некоторые отрывки из отчета PerfMonitor:

Более 10% общего времени процессора было потрачено на сборщик мусора. Большинство хорошо настроенных приложений находятся в диапазоне 0-10%. Обычно это вызванный шаблоном распределения, который позволяет объектам жить просто долго чтобы потребовать дорогостоящую коллекцию Gen 2.

Эта программа имела максимальную скорость выделения кучи ГК более 10 МБ/с. Это довольно высоко. Нередко это просто ошибка производительности.

Изменить:. В соответствии с вашим комментарием я попытаюсь объяснить тайминги, о которых вы сообщали. На моем компьютере с PerfMonitor я измерял от 43 до 52% времени, проведенного в GC. Для простоты предположим, что 50% времени процессора работают, а 50% - GC. Таким образом, если мы сделаем работу на 4 раза быстрее (с помощью многопоточности), но сохраним количество GC одинаковым (это произойдет из-за того, что количество обрабатываемых партий оказалось одинаковым в параллельной и последовательной конфигурациях), лучший улучшение, которое мы могли получить, составляет 62,5% от первоначального времени, или 1,6 ×.

Однако мы видим только ускорение 1,25 ×, потому что по умолчанию GC не многопоточен (в GC рабочей станции). Согласно Основы коллекции мусора, все управляемые потоки приостанавливаются во время коллекции Gen 0 или Gen 1. (Concurrent и background GC, в .NET 4 и .NET 4.5, могут собирать Gen 2 в фоновом потоке.) Ваша программа имеет только ускорение 1,25 × (и вы видите 30% использования ЦП в целом), потому что потоки тратят большую часть своих время приостановлено для GC (потому что шаблон распределения памяти этой тестовой программы очень низок).

Если вы включите сервер GC, он будет выполнять сборку мусора по нескольким потокам. Если я это сделаю, программа будет работать в 2 раза быстрее (при почти 100% использовании ЦП).

При одновременном запуске четырех экземпляров программы каждая из них имеет свою собственную управляемую кучу, а сборка мусора для четырех процессов может выполняться параллельно. Вот почему вы видите 100% использование ЦП (каждый процесс использует 100% одного процессора). Немного более длительное общее время (2.3s для всех vs 2.05s для одного) возможно связано с неточностями в измерении, соперничеством за диск, время, затраченное на загрузку файла, необходимость инициализировать threadpool, накладные расходы на переключение контекста или некоторые другие фактор окружающей среды.

Ответ 2

Попытка объяснить результаты:

  • быстрый запуск в профилировщике VS показывает, что он едва достигает 40% загрузки процессора.
  • String.Split является главной точкой доступа.
  • поэтому совместное что-то должно блокировать процессор.
  • что-то, скорее всего, распределяется память. Ваши узкие места
var dic = new Dictionary<string, List<int>>();
...
   dic[token].Add(1);

Я заменил это на

var dic = new Dictionary<string, int>();
...
... else dic[token] += 1;

и результат близок к ускорению 2x.

Но мой встречный вопрос был бы: это имеет значение? Ваш код очень искусственный и неполный. Параллельная версия заканчивается созданием нескольких словарей без их слияния. Это даже не близко к реальной ситуации. И, как вы можете видеть, небольшие детали имеют значение.

Ваш пример кода сложный, чтобы сделать широкие заявления о Parallel.ForEach().
Слишком просто решить/проанализировать реальную проблему.

Ответ 3

Просто для удовольствия, вот более короткая версия PLINQ:

File.ReadAllText("big.txt").Split().AsParallel().GroupBy(t => t)
                                                .ToDictionary(g => g.Key, g => g.Count());