Почему 1000 потоков быстрее, чем несколько?

У меня есть простая программа, которая ищет линейно в массиве 2D точек. Я делаю 1000 поисков в массиве 1 000 000 точек.

Любопытно, что, если я создаю 1000 потоков, программа работает так же быстро, как когда я занимаю столько же, сколько у меня есть ядра ЦП, или когда я использую Parallel.For. Это противоречит всему, что я знаю о создании тем. Создание и удаление потоков стоит дорого, но, очевидно, не в этом случае.

Может кто-нибудь объяснить почему?

Примечание: это методологический пример; алгоритм поиска намеренно не предназначен для оптимального. Основное внимание уделяется многопоточности.

Примечание 2: я тестировал на 4-ядерном i7 и 3-ядерном AMD, результаты следуют той же схеме!

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;

/// <summary>
/// We search for closest points.
/// For every point in array searchData, we search into inputData for the closest point, 
/// and store it at the same position into array resultData;
/// </summary>
class Program
{
    class Point
    {
        public double X { get; set; }
        public double Y { get; set; }

        public double GetDistanceFrom (Point p)
        {
            double dx, dy;
            dx = p.X - X;
            dy = p.Y - Y;
            return Math.Sqrt(dx * dx + dy * dy);
        }
    }

    const int inputDataSize = 1_000_000;
    static Point[] inputData = new Point[inputDataSize];

    const int searchDataSize = 1000;
    static Point[] searchData = new Point[searchDataSize];
    static Point[] resultData = new Point[searchDataSize];

    static void GenerateRandomData (Point[] array)
    {
        Random rand = new Random();
        for (int i = 0; i < array.Length; i++)
        {
            array[i] = new Point()
            {
                X = rand.NextDouble() * 100_000,
                Y = rand.NextDouble() * 100_000
            };
        }
    }

    private static void SearchOne(int i)
    {
        var searchPoint = searchData[i];
        foreach (var p in inputData)
        {
            if (resultData[i] == null)
            {
                resultData[i] = p;
            }
            else
            {
                double oldDistance = searchPoint.GetDistanceFrom(resultData[i]);
                double newDistance = searchPoint.GetDistanceFrom(p);
                if (newDistance < oldDistance)
                {
                    resultData[i] = p;
                }
            }
        }
    }

    static void AllThreadSearch()
    {
        List<Thread> threads = new List<Thread>();
        for (int i = 0; i < searchDataSize; i++)
        {
            var thread = new Thread(
                obj =>
                {
                    int index = (int)obj;
                    SearchOne(index);
                });
            thread.Start(i);
            threads.Add(thread);
        }
        foreach (var t in threads) t.Join();
    }

    static void FewThreadSearch()
    {
        int threadCount = Environment.ProcessorCount;
        int workSize = searchDataSize / threadCount;
        List<Thread> threads = new List<Thread>();
        for (int i = 0; i < threadCount; i++)
        {
            var thread = new Thread(
                obj =>
                {
                    int[] range = (int[])obj;
                    int from = range[0];
                    int to = range[1];
                    for (int index = from; index < to; index++)
                    {
                        SearchOne(index);
                    }
                }
                );
            int rangeFrom = workSize * i;
            int rangeTo = workSize * (i + 1);
            thread.Start(new int[]{ rangeFrom, rangeTo });
            threads.Add(thread);
        }
        foreach (var t in threads) t.Join();
    }

    static void ParallelThreadSearch()
    {
        System.Threading.Tasks.Parallel.For (0, searchDataSize, 
                index =>
                {
                    SearchOne(index);
                });
    }

    static void Main(string[] args)
    {
        Console.Write("Generatic data...  ");
        GenerateRandomData(inputData);
        GenerateRandomData(searchData);
        Console.WriteLine("Done.");
        Console.WriteLine();

        Stopwatch watch = new Stopwatch();

        Console.Write("All thread searching... ");
        watch.Restart();
        AllThreadSearch();
        watch.Stop();
        Console.WriteLine($"Done in {watch.ElapsedMilliseconds} ms.");

        Console.Write("Few thread searching... ");
        watch.Restart();
        FewThreadSearch();
        watch.Stop();
        Console.WriteLine($"Done in {watch.ElapsedMilliseconds} ms.");

        Console.Write("Parallel thread searching... ");
        watch.Restart();
        ParallelThreadSearch();
        watch.Stop();
        Console.WriteLine($"Done in {watch.ElapsedMilliseconds} ms.");

        Console.WriteLine();
        Console.WriteLine("Press ENTER to quit.");
        Console.ReadLine();
    }
}

РЕДАКТИРОВАТЬ: Пожалуйста, не забудьте запустить приложение вне отладчика. VS Debugger замедляет работу нескольких потоков.


РЕДАКТИРОВАТЬ 2: еще несколько тестов.

Чтобы прояснить ситуацию, вот обновленный код, который гарантирует, что у нас одновременно работает 1000:

public static void AllThreadSearch()
{
    ManualResetEvent startEvent = new ManualResetEvent(false);
    List<Thread> threads = new List<Thread>();
    for (int i = 0; i < searchDataSize; i++)
    {
        var thread = new Thread(
        obj =>
        {
            startEvent.WaitOne();
            int index = (int)obj;
            SearchOne(index);
        });
        thread.Start(i);
        threads.Add(thread);
    }
    startEvent.Set();
    foreach (var t in threads) t.Join();
}

Тестирование с меньшим массивом - 100K элементов, результаты:

1000 против 8 потоков

               Method |     Mean |    Error |    StdDev | Scaled |
--------------------- |---------:|---------:|----------:|-------:|
      AllThreadSearch | 323.0 ms | 7.307 ms | 21.546 ms |   1.00 |
      FewThreadSearch | 164.9 ms | 3.311 ms |  5.251 ms |   1.00 |
 ParallelThreadSearch | 141.3 ms | 1.503 ms |  1.406 ms |   1.00 |

Теперь 1000 потоков намного медленнее, чем ожидалось. Параллельно. Для них все еще лучше, что тоже логично.

Однако при увеличении массива до 500 КБ (т.е. объема работы, выполняемого каждым потоком) все начинает выглядеть странно:

1000 против 8, 500 КБ

               Method |     Mean |    Error |   StdDev | Scaled |
--------------------- |---------:|---------:|---------:|-------:|
      AllThreadSearch | 890.9 ms | 17.74 ms | 30.61 ms |   1.00 |
      FewThreadSearch | 712.0 ms | 13.97 ms | 20.91 ms |   1.00 |
 ParallelThreadSearch | 714.5 ms | 13.75 ms | 12.19 ms |   1.00 |

Похоже, что переключение контекста имеет незначительные затраты. Затраты на создание потоков также относительно невелики. Единственная значительная стоимость слишком большого количества потоков - это потеря памяти (адресов памяти). Что само по себе достаточно плохо.

Теперь, действительно ли создание потоков стоит так мало? Нам повсеместно говорят, что создавать потоки очень плохо, а переключение контекста - зло.

Ответы

Ответ 1

Я думаю, что реальная проблема (кроме использования памяти) со слишком большим количеством потоков состоит в том, что ЦП может быть трудно оптимизировать себя, потому что он постоянно переключает задачи. В исходном бенчмарке OP все потоки работают над одной и той же задачей, поэтому вы не столкнетесь с большими расходами на дополнительные потоки.

Чтобы смоделировать потоки, работающие над различными задачами, я изменил переформулировку исходного кода Джодреллом (помеченную как "Нормальная" в данных ниже), чтобы сначала оптимизировать доступ к памяти, гарантируя, что все потоки работают в одном и том же блоке памяти одновременно и так далее. что блок помещается в кэш (4 МБ), используя метод из этой статьи о методах блокировки кеша. Затем я "изменил", чтобы каждый набор из 4 потоков работал в отдельном блоке памяти. Результаты для моей машины (в мс):

Intel Core i7-5500U CPU 2.40GHz (Max: 2.39GHz) (Broadwell), 1 CPU, 4 logical and 2 physical cores)
inputDataSize = 1_000_000; searchDataSize = 1000; blocks used for O/D: 10

Threads         1       2       3       4       6       8       10      18      32      56      100     178     316     562     1000
Normal(N)       5722    3729    3599    2909    3485    3109    3422    3385    3138    3220    3196    3216    3061    3012    3121
Optimized(O)    5480    2903    2978    2791    2826    2806    2820    2796    2778    2775    2775    2805    2833    2866    2988
De-optimized(D) 5455    3373    3730    3849    3409    3350    3297    3335    3365    3406    3455    3553    3692    3719    3900

Для O все потоки работали в одном и том же блоке кешируемой памяти одновременно (где 1 блок = 1/10 от inputData). Для D, для каждого набора из 4 потоков ни один поток не работал в одном и том же блоке памяти одновременно. Таким образом, в основном, в первом случае доступ к inputData был в состоянии использовать кэш, тогда как во втором случае для доступа к 4 потокам inputData был вынужден использовать основную память.

Это легче увидеть в диаграммах. На этих диаграммах вычитается стоимость создания потока, и обратите внимание, что ось x является логарифмической, а ось y обрезана, чтобы лучше показать форму данных. Кроме того, значение для 1 потока было уменьшено вдвое, чтобы показать теоретическую лучшую многопоточную производительность:

Combined Results Normal Data Optimized Data De-optimized Data

Быстрый взгляд выше показывает, что оптимизированные данные (O) действительно быстрее, чем другие. Он также более последовательный (более плавный), поскольку по сравнению с N ему не приходится иметь дело с промахами в кеше. Как предположил Джодрелл, около 100 потоков, по-видимому, есть "сладкое пятно", то есть число в моей системе, которое позволит потоку завершить свою работу в течение 1 интервала времени. После этого время линейно увеличивается с числом потоков (помните, что ось X имеет логарифмический масштаб на графике.)

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

N-O

Де-оптимизированная версия наиболее интересна. В худшем случае 4 потока были вынуждены работать в разных областях памяти, что препятствует эффективному кешированию. По мере увеличения числа потоков система может кэшироваться, поскольку потоки разделяют блоки памяти. Но, поскольку число потоков увеличивается, предположительно, переключение контекста усложняет повторное кэширование системы, и результаты возвращаются к худшему случаю:

D-O

Я думаю, что этот последний график показывает реальную стоимость переключения контекста. В исходной (N) версии все потоки выполняют одну и ту же задачу. В результате существует ограниченная конкуренция за ресурсы, отличные от времени ЦП, и ЦП может оптимизировать себя под рабочую нагрузку (т.е. эффективно кешировать). Если все потоки делают разные вещи, то ЦП не может оптимизировать себя и серьезная производительность ударил результаты. Так что проблема заключается не в непосредственном переключении контекста, а в конкуренции за ресурсы.

В этом случае разница для 4 потоков между O (2909ms) и D (3849ms) составляет 940 мс. Это составляет 32% снижения производительности. Поскольку моя машина имеет общий кэш L3, это снижение производительности проявляется даже при наличии только 4 потоков.

Ответ 2

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

Ответ 3

Я позволил себе перестроить ваш код для запуска с использованием BenchmarkDotNet, похоже,

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

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace Benchmarks
{
    public class Point
    {
        public double X { get; set; }
        public double Y { get; set; }

        public double GetDistanceFrom(Point p)
        {
            double dx, dy;
            dx = p.X - X;
            dy = p.Y - Y;
            return Math.Sqrt(dx * dx + dy * dy);
        }
    }

    [ClrJob(baseline: true)]
    public class SomeVsMany
    {
        [Params(1000)]
        public static int inputDataSize = 1000;

        [Params(10)]
        public static int searchDataSize = 10;

        static Point[] inputData = new Point[inputDataSize];
        static Point[] searchData = new Point[searchDataSize];
        static Point[] resultData = new Point[searchDataSize];

        [GlobalSetup]
        public static void Setup()
        {
            GenerateRandomData(inputData);
            GenerateRandomData(searchData);
        }

        [Benchmark]
        public static void AllThreadSearch()
        {
            List<Thread> threads = new List<Thread>();
            for (int i = 0; i < searchDataSize; i++)
            {
                var thread = new Thread(
                    obj =>
                    {
                        int index = (int)obj;
                        SearchOne(index);
                    });
                thread.Start(i);
                threads.Add(thread);
            }
            foreach (var t in threads) t.Join();
        }

        [Benchmark]
        public static void FewThreadSearch()
        {
            int threadCount = Environment.ProcessorCount;
            int workSize = searchDataSize / threadCount;
            List<Thread> threads = new List<Thread>();
            for (int i = 0; i < threadCount; i++)
            {
                var thread = new Thread(
                    obj =>
                    {
                        int[] range = (int[])obj;
                        int from = range[0];
                        int to = range[1];
                        for (int index = from; index < to; index++)
                        {
                            SearchOne(index);
                        }
                    }
                    );
                int rangeFrom = workSize * i;
                int rangeTo = workSize * (i + 1);
                thread.Start(new int[] { rangeFrom, rangeTo });
                threads.Add(thread);
            }
            foreach (var t in threads) t.Join();
        }

        [Benchmark]
        public static void ParallelThreadSearch()
        {
            System.Threading.Tasks.Parallel.For(0, searchDataSize,
                    index =>
                    {
                        SearchOne(index);
                    });
        }

        private static void GenerateRandomData(Point[] array)
        {
            Random rand = new Random();
            for (int i = 0; i < array.Length; i++)
            {
                array[i] = new Point()
                {
                    X = rand.NextDouble() * 100_000,
                    Y = rand.NextDouble() * 100_000
                };
            }
        }

        private static void SearchOne(int i)
        {
            var searchPoint = searchData[i];
            foreach (var p in inputData)
            {
                if (resultData[i] == null)
                {
                    resultData[i] = p;
                }
                else
                {
                    double oldDistance = searchPoint.GetDistanceFrom(resultData[i]);
                    double newDistance = searchPoint.GetDistanceFrom(p);
                    if (newDistance < oldDistance)
                    {
                        resultData[i] = p;
                    }
                }
            }
        }
    }

    public class Program
    {
        static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run<SomeVsMany>();
        }
    }
}

Когда я запускаю тест, я получаю эти результаты,

BenchmarkDotNet = v0.11.1, ОС = Windows 10.0.14393.2485 (1607/AnniversaryUpdate/Redstone1) Процессор Intel Core i7-7600U 2,80 ГГц (макс: 2,90 ГГц) (Kaby Lake), 1 ЦП, 4 логических и 2 физических ядра Частота = 2835938 Гц, разрешение = 352,6170 нс, таймер = TSC [хост]:.NET Framework 4.7.2 (CLR 4.0.30319.42000), 64-разрядная версия RyuJIT-v4.7.3163.0
Clr:.NET Framework 4.7.2 (CLR 4.0.30319.42000), 64-битный RyuJIT-v4.7.3163.0 Job = Clr Runtime = Clr

Method               inputDataSize searchDataSize Mean       Error     StdDev      
AllThreadSearch      1000          10             1,276.53us 51.0605us 142.3364us
FewThreadSearch      1000          10             547.72us   24.8199us 70.0049us
ParallelThreadSearch 1000          10             36.54us    0.6973us  0.8564us

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

Если я перезапущу тест с исходными значениями, я получу такие результаты,

Method               inputDataSize searchDataSize Mean    Error    StdDev
AllThreadSearch      1000000       1000           2.872s  0.0554s  0.0701s
FewThreadSearch      1000000       1000           2.384s  0.0471s  0.0612s
ParallelThreadSearch 1000000       1000           2.449s  0.0368s  0.0344s

Эти результаты подтверждают ваш вопрос.


FWIW Я сделал еще один тестовый прогон,

Method               inputDataSize searchDataSize Mean    Error    StdDev
AllThreadSearch      20000000      40             1.972s  0.0392s  0.1045s
FewThreadSearch      20000000      40             1.718s  0.0501s  0.1477s
ParallelThreadSearch 20000000      40             1.978s  0.0454s  0.0523s

Это может помочь отличить стоимость переключения контекста от создания потока, но, в конечном счете, должен присутствовать элемент того и другого.


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

Создание Thread связано с некоторыми фиксированными издержками. Когда работа большая, накладные расходы становятся незначительными.

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

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

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

Каждый поток будет продолжаться в течение определенного промежутка времени или до тех пор, пока не будет ожидать ресурс.

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

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

Итак, учитывая большой фиксированный размер для searchDataSize у нас есть три сценария. Границы этих сценариев будут зависеть от характеристик тестовой платформы.

inputDataSize маленький

Здесь стоимость создания потоков значительна, а AllThreadSearch значительно медленнее. ParallelThreadSearch имеет тенденцию к победе, поскольку сводит к минимуму стоимость создания потоков.

inputDataSize - средний

Стоимость создания потока незначительна. Важно, что работа может быть завершена за один отрезок времени. AllThreadSearch использует планирование на уровне ОС и избегает разумных, но значительных накладных расходов как Parallel.For и циклического FewThreadSearch в FewThreadSearch. Где-то в этой области самое приятное место для AllThreadSearch, возможно, что для некоторых комбинаций AllThreadSearch - самый быстрый вариант.

inputDataSize большой

Важно отметить, что работа не может быть завершена за один отрезок времени. Планировщик ОС и ThreadPool не могут предвидеть стоимость переключения контекста. Без какой-то дорогой эвристики, как они могли? FewThreadSearch выигрывает, потому что избегает переключения контекста, стоимость которого перевешивает стоимость циклов сегмента.


Как и всегда, если вы заботитесь о производительности, стоит сравнить ее с репрезентативной системой, репрезентативной рабочей нагрузкой и репрезентативной конфигурацией.

Ответ 4

Сначала вы должны понять разницу между Process и Thread, чтобы глубже погрузиться в преимущества параллелизма, чтобы добиться более быстрых результатов по сравнению с последовательным программированием.

Процесс - мы можем назвать это как экземпляр программы в процессе выполнения. Операционная система создает различные процессы при выполнении приложения. Приложение может иметь один или несколько процессов. Создание процесса - это то, что является дорогостоящей задачей для операционной системы, поскольку для ее создания необходимо предоставить несколько ресурсов, таких как память, регистры, открытые дескрипторы для доступа к системным объектам, контекст безопасности и т.д.,

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

Ниже на диаграмме будут подробно изложены мои слова. enter image description here

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

И чтобы исправить ваше окончательное предположение/вопрос, создание/уничтожение потоков не обходится дороже, чем создание/уничтожение процесса, поэтому всегда наличие "правильно обработанного кода потоков" повысит производительность приложения.

Ответ 5

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