Несколько параллельных задач в C# не улучшают время расчета - PullRequest
1 голос
/ 17 января 2020

У меня сложная математическая задача, которую нужно решить, и я решил провести несколько независимых вычислений параллельно, чтобы сократить время вычислений. Во многих программах CAE, таких как ANSYS или SolidWorks, для этой цели можно установить несколько ядер.

Я создал простой пример формы Windows, чтобы проиллюстрировать мою проблему. Здесь функция CalculateStuff() повышает A с Sample класса по мощности 1.2 max раз. Для 2 задач это max / 2 раз, а для 4 задач max / 4 раз.

Я рассчитал итоговое время работы как только для одной функции CalculateStuff(), так и для четырех дубликатов (CalculateStuff1(), ...2(), ...3(), ...4() - по одному для каждого задача) с тем же кодом. Я не уверен, имеет ли смысл использовать одну и ту же функцию для каждой задачи (в любом случае, Math.Pow - это одно и то же). Я также попытался включить или отключить ProgressBar.

В таблице представлено время работы (se c) для всех 12 случаев. Я ожидал, что это будет примерно в 2 и 4 раза быстрее для 2 и 4 задач, но в некоторых случаях 4 задачи даже хуже, чем 1. Мой компьютер имеет 2 процессора, по 10 ядер в каждом. Согласно окну отладки, загрузка процессора увеличивается с увеличением количества задач. Что не так с моим кодом здесь или я что-то неправильно понимаю? Почему несколько задач не улучшают время работы?

Table of results

        private readonly ulong max = 400000000ul;

        // Sample class
        private class Sample
        {
            public double A { get; set; } = 1.0;
        }

        // Clear WinForm elements
        private void Clear()
        {
            PBar1.Value = PBar2.Value = PBar3.Value = PBar4.Value = 0;
            TextBox.Text = "";
        }

        // Button that launches 1 task
        private async void BThr1_Click(object sender, EventArgs e)
        {
            Clear();
            DateTime start = DateTime.Now;

            Sample sample = new Sample();

            await Task.Delay(100);
            Task t = Task.Run(() => CalculateStuff(sample, PBar1, max));
            await t;

            TextBox.Text = (DateTime.Now - start).ToString(@"hh\:mm\:ss");

            t.Dispose();
        }

        // Button that launches 2 tasks
        private async void BThr2_Click(object sender, EventArgs e)
        {
            Clear();
            DateTime start = DateTime.Now;

            Sample sample1 = new Sample();
            Sample sample2 = new Sample();

            await Task.Delay(100);
            Task t1 = Task.Run(() => CalculateStuff(sample1, PBar1, max / 2));
            Task t2 = Task.Run(() => CalculateStuff(sample2, PBar2, max / 2));
            await t1; await t2;

            TextBox.Text = (DateTime.Now - start).ToString(@"hh\:mm\:ss");

            t1.Dispose(); t2.Dispose();
        }

        // Button that launches 4 tasks
        private async void BThr4_Click(object sender, EventArgs e)
        {
            Clear();
            DateTime start = DateTime.Now;

            Sample sample1 = new Sample();
            Sample sample2 = new Sample();
            Sample sample3 = new Sample();
            Sample sample4 = new Sample();

            await Task.Delay(100);
            Task t1 = Task.Run(() => CalculateStuff(sample1, PBar1, max / 4));
            Task t2 = Task.Run(() => CalculateStuff(sample2, PBar2, max / 4));
            Task t3 = Task.Run(() => CalculateStuff(sample3, PBar3, max / 4));
            Task t4 = Task.Run(() => CalculateStuff(sample4, PBar4, max / 4));
            await t1; await t2; await t3; await t4;

            TextBox.Text = (DateTime.Now - start).ToString(@"hh\:mm\:ss");

            t1.Dispose(); t2.Dispose(); t3.Dispose(); t4.Dispose();
        }

        // Calculate some math stuff
        private static void CalculateStuff(Sample s, ProgressBar pb, ulong max)
        {
            ulong c = max / (ulong)pb.Maximum;

            for (ulong i = 1; i <= max; i++)
            {
                s.A = Math.Pow(s.A, 1.2);

                if (i % c == 0)
                    pb.Invoke(new Action(() => pb.Value = (int)(i / c)));
            }
        }

Ответы [ 3 ]

5 голосов
/ 17 января 2020

Задачи не являются потоками. «Асинхронный» не означает «одновременный».

Что не так с моим кодом здесь или я что-то неправильно понимаю?

Вы не понимаете, какие задачи .

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

  • Нарезать картофель
  • Нарезать овощи
  • Нарезать мясо

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

Если бы они были задачами, это не значит, что эти работы будут выполняться одновременно. Вы - только один человек (= один поток), и вы можете делать только одну вещь за один раз.
Вы можете выполнять задачи в любом порядке, который вам нравится, вы можете даже остановить одну задачу, чтобы начать с другой, но вы до сих пор не может сделать больше, чем одну вещь одновременно. Независимо от порядка, в котором вы выполняете задачи, общее время, затрачиваемое на выполнение всех трех задач, остается неизменным, и это (по сути) не быстрее.

Если бы они были потоками, это все равно, что нанимать 3 шеф-поваров, что означает, что эти задания можно выполнять одновременно.


Асинхронность сокращает время простоя, когда оно ожидается .

Обратите внимание, что асинхронный код может привести к выигрышу во времени в тех случаях, когда ваш синхронный код в противном случае работал бы вхолостую, например, ожидание ответа сети. Это не учитывается в приведенном выше примере, именно поэтому я перечислил «cut [x]» задания, а не «wait to x», чтобы закипеть ».

Ваша работа (расчет) не асинхронный код. Он никогда не работает вхолостую (таким образом, что это ожидаемо), и поэтому он работает синхронно. Это означает, что вы не получаете никакой выгоды от выполнения этого асинхронно.

Сокращение вашего кода до более простого примера:

private static void CalculateStuff(Sample s, ProgressBar pb, ulong max)
{
    Thread.Sleep(5000);
}

Проще говоря, это задание занимает 5 секунд и не может ждать . Если вы запустите 3 из этих задач одновременно, они все равно будут обрабатываться одна за другой, что займет 15 секунд .

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

private static async void CalculateStuff(Sample s, ProgressBar pb, ulong max)
{
    await Task.Delay(5000);
}

Это задание занимает 5 секунд , но может ожидаться . Если вы запустите 3 из этих задач одновременно, ваш поток не будет тратить время на холостом ходу (т. Е. Ожидает задержки) и вместо этого начнет выполнение следующей задачи. Поскольку он может ожидать (то есть ничего не делать) для этих задач одновременно, это означает, что общее время обработки занимает 5 секунд, общее (плюс незначительные накладные расходы).


Согласно окну отладки, загрузка ЦП увеличивается с увеличением числа задач.

Управление задачами требует небольших накладных расходов, что означает, что общий объем работы (который можно измерить в ЦП) использование во времени) немного выше по сравнению с синхронным кодом. Этого и следовало ожидать.

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


  1. В моем компьютере 2 процессора по 10 ядер.

Ядра процессора, потоки и задачи - три совершенно разных зверя.

Задачи обрабатываются потоками, но они не обязательно имеют однозначное сопоставление. Возьмите пример команды из 4 разработчиков, которая должна решить 10 ошибок. Хотя это означает, что невозможно разрешить все 10 ошибок одновременно, эти разработчики (потоки) могут брать заявки (задачи) один за другим, принимая новый билет (задачу) всякий раз, когда они заканчивают свой предыдущий билет ( задача).

Ядра процессора похожи на рабочие станции. Нет смысла иметь меньше рабочих станций (процессорных ядер), чем у разработчиков (потоков), так как вы в конечном итоге будете работать с простыми разработчиками.

Кроме того, вы можете не захотеть, чтобы ваши разработчики могли претендовать на все рабочие станции. Возможно, персоналу отдела кадров и бухгалтерии (= другим процессам ОС) также необходимо иметь некоторые гарантированные рабочие станции, чтобы они могли выполнять свою работу.
Компания (= компьютер) не просто останавливается, потому что разработчики исправляют некоторые ошибки. Это то, что происходило на одноядерных машинах - если один процесс требует ЦП, больше ничего не может произойти. Если этот один процесс занимает много времени или зависает, все зависает.

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

Точная конфигурация вашего пула потоков - это не то, что я могу устранить из-за простых вопросов и ответов. Но описанное вами поведение согласуется с меньшим количеством процессоров (выделенных для вашей среды выполнения) и / или потоков по сравнению с тем, сколько у вас задач.

2 голосов
/ 17 января 2020

Существует множество возможных причин, по которым вы можете не увидеть ожидаемого прироста производительности, включая такие вещи, как то, к чему еще привыкли ядра вашей машины в данный момент. Запустив эту урезанную версию вашего кода, я вижу заметное улучшение при параллельной работе:

private IEnumerable<Sample> CalculateMany(int n)
{
    return Enumerable.Range(0, n)
        .AsParallel() // comment this to remove parallelism
        .Select(i => { var s = new Sample(); CalculateStuff(s, max / (ulong)n); return s; })
        .ToList();
}

// Calculate some math stuff
private static void CalculateStuff(Sample s, ulong max)
{
    for (ulong i = 1; i <= max; i++)
    {
        s.A = Math.Pow(s.A, 1.2);
    }
}

Здесь выполняется CalculateMany со значениями n как 1, 2 и 4: enter image description here

Вот что я получу, если не использовать параллелизм: enter image description here

Я вижу похожие результаты, используя Task.Run():

private IEnumerable<Sample> CalculateMany(int n)
{
    var tasks = 
    Enumerable.Range(0, n)
        .Select(i => Task.Run(() => { var s = new Sample(); CalculateStuff(s, max / (ulong)n); return s; }))
        .ToArray()        ;
    Task.WaitAll(tasks);
    return tasks
        .Select(t => t.Result)
        .ToList();
}
0 голосов
/ 17 января 2020

К сожалению, я не могу дать вам причину, кроме, вероятно, что-то с магией конечного автомата c, которая происходит, но это значительно увеличивает производительность:

private async void BThr4_Click(object sender, EventArgs e)
{
    Clear();
    DateTime start = DateTime.Now;

    await Task.Delay(100);
    Task<Sample> t1 = Task<Sample>.Run(() => CalculateStuff(PBar1, max / 4));
    Task<Sample> t2 = Task<Sample>.Run(() => CalculateStuff(PBar2, max / 4));
    Task<Sample> t3 = Task<Sample>.Run(() => CalculateStuff(PBar3, max / 4));
    Task<Sample> t4 = Task<Sample>.Run(() => CalculateStuff(PBar4, max / 4));

    Sample sample1 = await t1;
    Sample sample2 = await t2;
    Sample sample3 = await t3;
    Sample sample4 = await t4;

    TextBox.Text = (DateTime.Now - start).ToString(@"hh\:mm\:ss");

    t1.Dispose(); t2.Dispose(); t3.Dispose(); t4.Dispose();
}

// Calculate some math stuff
private static Sample CalculateStuff(ProgressBar pb, ulong max)
{
    Sample s = new Sample();
    ulong c = max / (ulong)pb.Maximum;

    for (ulong i = 1; i <= max; i++)
    {
        s.A = Math.Pow(s.A, 1.2);

        if (i % c == 0)
            pb.Invoke(new Action(() => pb.Value = (int)(i / c)));
    }

    return s;
}

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

...