Как правильно выполнять тяжелые вычисления в Parallel с помощью C#?

Цель

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

Проблема

Я упростил объяснение и тестирование проблемы, создав следующий сценарий:

1) for each value of 2, 3, 5, and 7:
2) find all multiples (up to a certain value) and add them to the same List
3) remove all duplicates from said list

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

Что я пробовал

Я сделал приложение winforms с C # Core с 5 кнопками, пробуя различные варианты параллелизма, которые я нашел здесь, в Stackoverflow, и в других местах в Интернете: введите здесь описание изображения

Вот код (кажется, что его много, но это всего лишь 5 вариантов одной и той же идеи), все они дают подсчет, чтобы проверить, дали ли они тот же результат + сколько времени это заняло:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Security.Permissions;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace Parallelism
{
    public partial class Form1 : Form
    {
        private readonly int Repeat = 10000000; 

        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();
            List<int> output = new List<int>();
            foreach (int x in new int[] { 2, 3, 5, 7 })
            {
                for (int i = 0; i < Repeat; i++)
                {
                    output.Add(x * i);
                }
            }
            output = output.Distinct().ToList();
            watch.Stop();
            (sender as Button).Text += $", c:{output.Count} - {watch.ElapsedMilliseconds}ms";
        }

        private void button2_Click(object sender, EventArgs e)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();
            ConcurrentBag<int> output = new ConcurrentBag<int>();
            Task task = Task.WhenAll(
              Task.Run(() => button2_Calculation(2, output)),
              Task.Run(() => button2_Calculation(3, output)),
              Task.Run(() => button2_Calculation(5, output)),
              Task.Run(() => button2_Calculation(7, output))
            );
            task.Wait();
            HashSet<int> output2 = new HashSet<int>(output);
            watch.Stop();
            (sender as Button).Text += $", c:{output2.Count} - {watch.ElapsedMilliseconds}ms";
        }
        private void button2_Calculation(int x, ConcurrentBag<int> output)
        {
            for (int i = 0; i < Repeat; i++)
            {
                output.Add(x * i);
            }
        }

        private void button3_Click(object sender, EventArgs e)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();
            List<int> output = new List<int>();
            foreach (int x in (new int[] { 2, 3, 5, 7 }).AsParallel())
            {
                for (int i = 0; i < Repeat; i++)
                {
                    output.Add(x * i);
                }
            }
            output = output.Distinct().ToList();
            watch.Stop();
            (sender as Button).Text += $", c:{output.Count} - {watch.ElapsedMilliseconds}ms";
        }

        private void button4_Click(object sender, EventArgs e)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();
            ConcurrentBag<int> output = new ConcurrentBag<int>();
            Dictionary<int, Task> runningTasks = new Dictionary<int, Task>();
            foreach (int x in new int[] { 2, 3, 5, 7 })
            {
                int value = x;
                runningTasks.Add(x, Task.Factory.StartNew(() => button2_Calculation(value, output)));
            }
            foreach (Task t in runningTasks.Select(c => c.Value))
                t.Wait();
            HashSet<int> output2 = new HashSet<int>(output);
            watch.Stop();
            (sender as Button).Text += $", c:{output2.Count} - {watch.ElapsedMilliseconds}ms";
        }

        private void button5_Click(object sender, EventArgs e)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();
            ConcurrentBag<int> output = new ConcurrentBag<int>();
            Parallel.ForEach(new int[] { 2, 3, 5, 7 }, x => button5_Calculation(x, output));
            HashSet<int> output2 = new HashSet<int>(output);
            watch.Stop();
            (sender as Button).Text += $", c:{output2.Count} - {watch.ElapsedMilliseconds}ms";
        }
        private void button5_Calculation(int x, ConcurrentBag<int> output)
        {
            for (int i = 0; i < Repeat; i++)
                output.Add(x * i);
        }
    }
}

Результаты на данный момент

До сих пор все вышеперечисленные методы приводили к одинаковой продолжительности от 1 до 1,5 с. На самом деле, иногда обычное последовательное выполнение кажется намного быстрее. Как это возможно? Я ожидаю, что с 8 ядрами (16 виртуальных ядер) разделение задач приведет к более высокой общей скорости?

Любая помощь очень ценится!

Будущее

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

РЕДАКТИРОВАТЬ:

Ответ @Pac0: вот моя реализация ваших предложений. Кажется, это не имеет большого значения: введите здесь описание изображения

private void button6_Click(object sender, EventArgs e)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();
            ConcurrentBag<HashSet<int>> bag = new ConcurrentBag<HashSet<int>>();
            var output = Parallel.ForEach(new int[] { 2, 3, 5, 7 }, x =>
            {
                HashSet<int> temp = new HashSet<int>();
                for (int i = 0; i < Repeat; i++)
                    temp.Add(x * i);
                bag.Add(temp);
            });
            HashSet<int> output2 = new HashSet<int>();
            foreach (var hash in bag)
                output2.UnionWith(hash);
            watch.Stop();
            (sender as Button).Text += $", c:{output2.Count} - {watch.ElapsedMilliseconds}ms";
        }

person Lemth    schedule 20.08.2020    source источник
comment
Можете ли вы попробовать те же методы для Repeat = 100000000 и посмотреть результаты?   -  person Sowmyadhar Gourishetty    schedule 20.08.2020
comment
Я предполагаю, что, поскольку вы всегда используете один и тот же подход с concurrentbag для сохранения результатов на лету, вы вызываете много блокировок и, следовательно, теряете преимущество использования многих потоков. Но я, возможно, ошибаюсь, так как не использую concurrentbag для большого хранилища. Я бы предпочел подход Map/Reduce: выполнить вычисления для 2, 3, 5 и 7 независимо, а затем объединить все результаты.   -  person Pac0    schedule 20.08.2020
comment
поэтому в основном создайте HashSet для параллельного вычисления, затем, как только все будет сделано, выполните объединение хэш-наборов (это позаботится о дубликатах).   -  person Pac0    schedule 20.08.2020
comment
Tasks/async обычно предназначен только для кода, связанного с вводом-выводом, а у вас нет.   -  person Neil    schedule 20.08.2020
comment
@SowmyadharGourishetty, вот результаты для 100.000.000x: i.imgur.com/luahcSv.png как вы можете видеть, есть некоторая разница, и метод 2 кажется всего на 20% быстрее, хотя есть 4 отдельных потока.   -  person Lemth    schedule 20.08.2020
comment
@Neil, он также хорошо подходит для тяжелой работы, связанной с процессором, когда у вас есть основной поток (например, графический интерфейс), который вы не хотите замораживать.   -  person Pac0    schedule 20.08.2020
comment
@Pac0 Pac0 Я отредактировал свой основной вопрос, чтобы показать реализацию вашего предложения.   -  person Lemth    schedule 20.08.2020
comment
Похоже, вы исходите из предположения, что горлышко бутылки — это петля for: for (int i = 0; i < Repeat; i++). Как вы подтвердили, что проблема именно там, а не в коде, удаляющем дубликаты?   -  person Joshua Robinson    schedule 20.08.2020
comment
@JoshuaRobinson, ты прав. В этом примере самым большим узким местом является удаление дубликатов. Однако это отличается от фактической реализации моего кода. Но я приму предложение сделать удаление дубликатов более эффективным.   -  person Lemth    schedule 20.08.2020


Ответы (2)


Как упоминалось в комментарии, использование вами одной коллекции вызывает значительную блокировку. В вычислительном отношении решение, основанное на задаче, примерно на 50% быстрее (см. ниже, где мы не управляем комбинированным выводом). Он управляет коллекцией, которая вызывает некоторую привязку. В зависимости от того, как он обрабатывается, он может быть в 3 раза медленнее, чем последовательное выполнение.

Борьба с параллелизмом всегда балансирует нагрузку на узкое место.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ConsoleApp5
{
    class Program
    {
        static int Repeat = 100000000;
        static int[] worklist = new int[] { 2, 3, 5, 7 };

        static void Main(string[] args)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();

            Console.WriteLine("Hello World! Launching Threads");
            Task launcher = Task.Run(()=>LaunchThreads());
            launcher.Wait();
            Console.WriteLine("Hello World! Threads Complete");

            watch.Stop();
            Console.WriteLine($"Threads took: {watch.ElapsedMilliseconds}");

            watch = System.Diagnostics.Stopwatch.StartNew();
            Console.WriteLine("Serial Execution Starting");
            foreach (int i in worklist)
            {
                DoWork(i);
            }
            watch.Stop();
            Console.WriteLine($"Serial Execution took: {watch.ElapsedMilliseconds}");
        }
        static async void LaunchThreads()
        {
            //Dictionary<int, List<int>> mywork = new Dictionary<int, List<int>>();
            HashSet<int> output = new HashSet<int>();

            var worktasks = new List<Task<List<int>>>();

            foreach (int i in worklist)
            {
                worktasks.Add(Task.Run(() => DoWork(i)));
            }

            await Task.WhenAll(worktasks);
        }
        static List<int> DoWork(int x)
        {
            Console.WriteLine($"Thread Worker: {x}");
            List<int> output = new List<int>();
            for (int i = 0; i < Repeat; i++)
            {
                output.Add(x * i);
            }

            Console.WriteLine($"Thread Worker: {x} - Exiting");
            return output;
        }
    }
}
person yugami    schedule 20.08.2020
comment
Я должен добавить, что пытался использовать обратную связь на основе IProgress, чтобы напрямую добавлять элементы в хеш-набор, но это было худшим из всех. Итерация результатов и UnionWith на хеш-наборе казались лучшими, но все еще медленными. - person yugami; 20.08.2020

Я хочу опубликовать это как авсер, потому что кто-то по имени Югами опубликовал что-то, что отличалось от того, что я пробовал, и это был полезный и хороший ответ, но он был удален.

Поэтому я публикую свои попытки воссоздать их код на своем тестовом стенде:

private async void button9_Click(object sender, EventArgs e)
        {
            var watch = System.Diagnostics.Stopwatch.StartNew();
            HashSet<int> output = new HashSet<int>();
            var worktasks = new List<Task<List<int>>>();
            foreach (int i in new int[] { 2, 3, 5, 7 })
                worktasks.Add(Task.Run(() => button9_Calculation(i)));

            await Task.WhenAll(worktasks);
            foreach (Task<List<int>> tsk in worktasks)
                foreach (int i in tsk.Result)
                    output.Add(i);
            watch.Stop();
            (sender as Button).Text += $", c:{output.Count} - {watch.ElapsedMilliseconds}ms";
        }
        private List<int> button9_Calculation(int x)
        {
            List<int> output = new List<int>();
            for (int i = 0; i < Repeat; i++)
                output.Add(x * i);

            return output;
        }

Вот результаты серийного и двух лучших решений со 100 000 000 попыток. Здесь я, наконец, вижу некоторое улучшение выполнения шага 2 параллельно, но теперь самым большим узким местом является удаление дубликатов/фильтрация всего этого до одного HashSet... введите здесь описание изображения

Итак, я думаю, что это решает первоначальный вопрос, который я должен был улучшить на шаге 2. Теперь я продолжу свои поиски, чтобы улучшить шаг 3; удаление дубликатов.

person Lemth    schedule 20.08.2020
comment
Извините, мое решение не было полным, а заметил его только постфактум. Я очистил его, но все еще есть проблемы в зависимости от того, как вы хотите работать с данными. - person yugami; 20.08.2020
comment
Просто примечание: в методе button9_Calculation вы знаете, что output будет содержать Repeat элементов, поэтому вы можете создать его с помощью List<int> output = new List<int>(Repeat). Он должен работать немного лучше, устраняя необходимость изменять размер списка во время выполнения метода. - person Joshua Robinson; 20.08.2020