1

Есть код для поиска слов в списке, начинающихся с введённых символов с использованием foreach и Parallel.ForEach. При этом при использовании обычного foreach поиск выполняется быстрее. В чём может быть причина? Может ли это быть связано с накладыванием дополнительных расходов ресурсов и времени на организацию параллельных потоков?

foreach (var word in words)
{
     if (word.StartsWith(searchString))
        {
            matchingWords.Add(word);
        }
 }

И Parallel.ForEach

   Parallel.ForEach(words, word =>
   {
       if (word.StartsWith("b"))
         {
           lock (matchingWords)
           {
              matchingWords.Add(word);
           }
         }
   });
  • How to: Speed Up Small Loop Bodies. Использование разделения (partition) выгодно в случае маленького объёма работы на каждой итерации цикла. – Alexander Petrov May 09 '23 at 19:01
  • не знаю, может поэтому неверный вывод у автора... в версии с Parallel.ForEach делается сверка с "b", а не с searchString. в моих измерениях параллельный алгоритм показал трехкратное увеличение скорости (.NET 4.8) и минимальное увеличение скорости (.NET 7) на моем 4-ядерном процессоре. значит накладные расходы есть. – Алексей Обухов May 09 '23 at 22:00

2 Answers2

3

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

Плюс к вышесказанному тормозов добавляет lock, так как подмораживает каждую из итераций, ради того чтобы не повредить результирующую коллекуцию. Как вариант, можно использовать потокобезопасную коллекцию, типа ConcurrentBag<T>.

Если вам хочется вывести вычисления в отдельный поток. Сделайте просто так.

Task<List<string>> task = Task.Run(() =>
{
    List<string> result = new();
    foreach (var word in words)
    {
        if (word.StartsWith(searchString))
        {
            result.Add(word);
        }
    }
    return result;
});
// здесь можно нарисовать в интерфейсе, что поиск начался
List<string> filteredWords = await task;
// здесь можно отобразить результат

И lock в таком случае не потребуется.

Кстати, если так важна производительность, то foreach по массиву работает быстрее, чем по списку.

aepot
  • 49,560
  • ваш пример полностью идентичен простой версии (т.е. выполняется в один поток). опубликовал измерения в своем ответе – Алексей Обухов May 09 '23 at 18:20
  • @АлексейОбухов несовсем, вычисления выведены в отдельный поток, а это значит, например для UI приложения, что UI не подмерзнет на время выполнения. Такие быстрые CPU-bound вычисления лучше всего параллелятся через SIMD, но об этом вопроса не было. Вопрос был "почему медленнее?", я ответил. Код - это лишь дополнение к вариантам решения потенциальной проблемы замораживания приложения во время длительной работы этого кода. – aepot May 09 '23 at 18:23
  • тем не менее изначальное предположение, что многопоточная версия медленнее однопоточной неверна. даже с учетом возможный накладных расходов на lock. можно и без него написать. – Алексей Обухов May 09 '23 at 18:25
  • @АлексейОбухов ваш ответ правильный, но попытка сравнить производительность моего и вашего или исходного вариантов - бессмысленна. Вы же это понимаете? Само собой, что синхронный цикл никогда не будет быстрее, чем он сам. – aepot May 09 '23 at 18:31
  • @АлексейОбухов здесь можно устроить SIMD, разбить коллекцию на чанки, еще можно много всего сделать, чтобы Parallel.ForEach глотал пыль по производительности. Но еще раз, вопрос был не об этом. – aepot May 09 '23 at 18:34
0

Update: поступили замечания по тесту. Мои комментарии:

  1. Сборка в релизе не дает никакой прибавки к скорости
  2. Убирание lock (он тут не нужен) дает минимальную прибавку к скорости
  3. .NET 7 на тесте работает на порядок медленнее, чем .NET 4.8

Написал свой тест, который показал, что ваше предположение о медленной работе в несколько потоков неверно. Результаты работы программы опубликованной ниже:

simple: 00:00:04.2730974, parallel: 00:00:01.6736944, aepot: 00:00:04.3022718

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace ConsoleApp2 { internal class Program { static void Main(string[] args) { var swSimple = new Stopwatch(); var swParallel = new Stopwatch(); var swAepot = new Stopwatch(); var random = new Random(); string searchString = new string('0', 100); var words = new List<string>(); for (int i = 0; i < 5e3; i++) { char randomNum = "0123456789"[random.Next(10)]; string sampleString = new string(randomNum, random.Next(100 * i)); words.Add(sampleString);

            swSimple.Start();
            var list1 = RunSimple(words, searchString);
            swSimple.Stop();

            swParallel.Start();
            var list2 = RunParallel(words, searchString);
            swParallel.Stop();

            swAepot.Start();
            var list3 = RunAepot(words, searchString);
            swAepot.Stop();

            if (list1.Count != list2.Count || list3.Count != list1.Count)
            {
                Console.WriteLine($&quot;Функции не идентичны: words={words.Count}, searchString={searchString.Length}, RunSimple: {list1.Count}, RunParallel: {list2.Count}, RunAepot: {list3.Count}&quot;);
            }
        }

        Console.WriteLine($&quot;simple: {swSimple.Elapsed}, parallel: {swParallel.Elapsed}, aepot: {swAepot.Elapsed}&quot;);

    }

    static List&lt;string&gt; RunSimple(List&lt;string&gt; words, string searchString)
    {
        var matchingWords = new List&lt;string&gt;();
        foreach (var word in words)
        {
            if (word.StartsWith(searchString))
            {
                matchingWords.Add(word);
            }
        }
        return matchingWords;
    }

    static List&lt;string&gt; RunParallel(List&lt;string&gt; words, string searchString)
    {
        var matchingWords = new ConcurrentBag&lt;string&gt;();
        Parallel.ForEach(words, word =&gt;
        {
            if (word.StartsWith(searchString))
            {
                lock (matchingWords)
                {
                    matchingWords.Add(word);
                }
            }
        });
        return matchingWords.ToList();
    }

    static List&lt;string&gt; RunAepot(List&lt;string&gt; words, string searchString)
    {
        Task&lt;List&lt;string&gt;&gt; task = Task.Run(() =&gt;
        {
            List&lt;string&gt; result = new List&lt;string&gt;();
            foreach (var word in words)
            {
                if (word.StartsWith(searchString))
                {
                    result.Add(word);
                }
            }
            return result;
        });
        // здесь можно нарисовать в интерфейсе, что поиск начался
        //List&lt;string&gt; filteredWords = await task;
        // здесь можно отобразить результат
        task.Wait();
        return task.Result;
    }
}

}

  • task.Wait(); или task.Result; полностью ломает смысл кода в моем ответе и является грубой ошибкой при использовании асинхронных методов, таких как Task.Run. – aepot May 09 '23 at 18:24
  • ну тут распараллеливание по поиску в массиве, а не распараллеливание по всем поискам – Алексей Обухов May 09 '23 at 18:26
  • Я и не предлагал никакого распараллеливания, для того чтобы это предлагать, надо было знать проблему. Я лишь ответил на вопрос "почему медленнее". Хотя про lock тоже следовало добавить, сейчас допишу. – aepot May 09 '23 at 18:27
  • Замеры в релизе кстати сделайте, а не в дебаге. Здесь не все так гладко. И желательно несколько итераций измерений на коллекциях разного размера. – aepot May 09 '23 at 18:36
  • 1
    ConcurrentBag потокобезопасный, не нужно лочить его метод Add. – Alexander Petrov May 09 '23 at 18:58
  • @AlexanderPetrov lock очень быстрый, см. дополнение к ответу – Алексей Обухов May 09 '23 at 21:47
  • @aepot дополнил ответ, релизная сборка, как ни странно, на скорость не влияет. тест и так делает прогоны в большом кол-ве по строкам и массивам разного размера, не вижу надобности в его переделке. тест скорее для демонстрации, что Parallel.ForEach реально ускоряет работу (что логично). понятно, что любой тест будет показывать то, что удобно тестировщику. – Алексей Обухов May 09 '23 at 21:52
  • .NET 7 на тесте работает на порядок медленнее, чем .NET 4.8 это невозможно по той простой причине, что должно быть в точности наоборот. Сборка в релизе не дает никакой прибавки к скорости - Вы не смогли собрать релизную сборку (галочка "оптимизировать код"). – aepot May 09 '23 at 23:07
  • Ну и работа через lock с потокобезопасной коллекцией - пустая трата времени. То есть все 3 тезиса мимо. – aepot May 09 '23 at 23:10
  • На .NET7 действительно какая-то чертовщина творится с этим кодом: выполняется несколько минут, а на FW4.8 несколько секунд. Даже интересно стало. Нужно профилировать. @aepot – Alexander Petrov May 09 '23 at 23:59
  • @aepot галочка "Optimize code" стоит. как я понимаю, оптимизатор в c# работает и действительно в некоторых случаях дает кратную прибавку к скорости, но часто просто бесполезен. вот тут есть какие-то обсуждения – Алексей Обухов May 10 '23 at 08:19
  • @АлексейОбухов вы ссылаетесь на обсуждение 12-летней давности, оно неактуально. – aepot May 10 '23 at 08:53
  • @aepot ну почему же не актуально? .NET 4 вышел 2010 году, как раз 12 лет прошло. хотя конечно обсуждения на каком-то форуме не могут быть приняты за истину. лично я для себя отметил конструкцию Parallel.ForEach как перспективную. вполне возможно что вы правы, она не всегда ускоряет работу (в статье от Alexander Petrov говорится, что маленькое тело внутри Parallel.ForEach делать невыгодно, хотя мой тест это не подтверждает) – Алексей Обухов May 10 '23 at 09:36
  • @АлексейОбухов у Parallel.* есть один огромный недостаток - он блокирует вызывающий поток, поэтому как-то так приходится выкручиваться. Для меня это API выглядит устаревшим и неюзабельным. – aepot May 10 '23 at 09:55
  • @АлексейОбухов, попробовал запустить Ваш код у себя на .NET 4.8. И получил совершенно другие результаты:

    simple: 00:00:07.6115842, parallel: 00:00:20.4587524, aepot: 00:00:08.1802551

    – heavenbeyond91 May 10 '23 at 16:18
  • @heavenbeyond91 предполагаю, что тут зависимость от процессора (100% нагрузка на него). указанный мной результат получен на i5-8562u, на процессоре i7-3770k результаты чуть похуже (примерно такие же, по количеству потоков и ядер они одинаковые). также тест при работе требует около 1ГБ памяти. – Алексей Обухов May 10 '23 at 17:17