Двухъядерная производительность хуже, чем одноядерная?

Следующий тест nunit сравнивает производительность между запуском одного потока и запуском 2 потоков на двухъядерном компьютере. В частности, это двухъядерный виртуальный компьютер с Windows 7 VMWare, работающий на четырехъядерном узле SLED Linux с Dell Inspiron 503.

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

При запуске первого теста диспетчер задач показывает, что одно ядро ​​занято на 100%, а другое ядро ​​почти бездействует. Вот результаты теста для однопоточного теста:

readCounter 360687000
readCounter2 0
total readCounter 360687000
addCounter 360687000
addCounter2 0

Вы видите более 360 миллионов приращений!

Затем двухпоточный тест показывает, что оба ядра загружены на 100% в течение всех 5 секунд теста. Однако его вывод показывает только:

readCounter 88687000
readCounter2 134606500
totoal readCounter 223293500
addCounter 88687000
addCounter2 67303250
addFailure0

Это всего лишь 223 миллиона приращений чтения. Что это за творение бога, если эти два процессора делают за эти 5 секунд меньше работы?

Любая возможная подсказка? И можете ли вы запустить тесты на своей машине, чтобы увидеть, есть ли у вас другие результаты? Одна идея состоит в том, что, возможно, двухъядерная производительность VMWare - это не то, на что вы надеялись.

using System;
using System.Threading;
using NUnit.Framework;

namespace TickZoom.Utilities.TickZoom.Utilities
{
    [TestFixture]
    public class ActiveMultiQueueTest
    {
        private volatile bool stopThread = false;
        private Exception threadException;
        private long addCounter;
        private long readCounter;
        private long addCounter2;
        private long readCounter2;
        private long addFailureCounter;

        [SetUp]
        public void Setup()
        {
            stopThread = false;
            addCounter = 0;
            readCounter = 0;
            addCounter2 = 0;
            readCounter2 = 0;
        }


        [Test]
        public void TestSingleCoreSpeed()
        {
            var speedThread = new Thread(SpeedTestLoop);
            speedThread.Name = "1st Core Speed Test";
            speedThread.Start();
            Thread.Sleep(5000);
            stopThread = true;
            speedThread.Join();
            if (threadException != null)
            {
                throw new Exception("Thread failed: ", threadException);
            }
            Console.Out.WriteLine("readCounter " + readCounter);
            Console.Out.WriteLine("readCounter2 " + readCounter2);
            Console.Out.WriteLine("total readCounter " + (readCounter + readCounter2));
            Console.Out.WriteLine("addCounter " + addCounter);
            Console.Out.WriteLine("addCounter2 " + addCounter2);
        }

        [Test]
        public void TestDualCoreSpeed()
        {
            var speedThread1 = new Thread(SpeedTestLoop);
            speedThread1.Name = "Speed Test 1";
            var speedThread2 = new Thread(SpeedTestLoop2);
            speedThread2.Name = "Speed Test 2";
            speedThread1.Start();
            speedThread2.Start();
            Thread.Sleep(5000);
            stopThread = true;
            speedThread1.Join();
            speedThread2.Join();
            if (threadException != null)
            {
                throw new Exception("Thread failed: ", threadException);
            }
            Console.Out.WriteLine("readCounter " + readCounter);
            Console.Out.WriteLine("readCounter2 " + readCounter2);
            Console.Out.WriteLine("totoal readCounter " + (readCounter + readCounter2));
            Console.Out.WriteLine("addCounter " + addCounter);
            Console.Out.WriteLine("addCounter2 " + addCounter2);
            Console.Out.WriteLine("addFailure" + addFailureCounter);
        }

        private void SpeedTestLoop()
        {
            try
            {
                while (!stopThread)
                {
                    for (var i = 0; i < 500; i++)
                    {
                        ++addCounter;
                    }
                    for (var i = 0; i < 500; i++)
                    {
                        readCounter++;
                    }
                }
            }
            catch (Exception ex)
            {
                threadException = ex;
            }
        }

        private void SpeedTestLoop2()
        {
            try
            {
                while (!stopThread)
                {
                    for (var i = 0; i < 500; i++)
                    {
                        ++addCounter2;
                        i++;
                    }
                    for (var i = 0; i < 500; i++)
                    {
                        readCounter2++;
                    }
                }
            }
            catch (Exception ex)
            {
                threadException = ex;
            }
        }


    }
}

Изменить: я тестировал вышеуказанное на четырехъядерном ноутбуке без vmware и получил аналогичное снижение производительности. Поэтому я написал еще один тест, аналогичный приведенному выше, но в котором каждый метод потока находится в отдельном классе. Моей целью было протестировать 4 ядра.

Что ж, этот тест показал отличные результаты, которые улучшились почти линейно с 1, 2, 3 или 4 ядрами.

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

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

Кажется, что CLR «синхронизируется», поэтому только один поток одновременно может работать с этим методом. Однако мое тестирование показывает, что это не так. Так что до сих пор неясно, что происходит.

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

С уважением, Уэйн

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

Вот обновленный модульный тест, который тестирует 1, 2, 3 и 4 потока с их всеми в одном экземпляре класса. Использование массивов с переменными использует в цикле потока не менее 10 элементов друг от друга. И производительность по-прежнему значительно снижается для каждого добавленного потока.

using System;
using System.Threading;
using NUnit.Framework;

namespace TickZoom.Utilities.TickZoom.Utilities
{
    [TestFixture]
    public class MultiCoreSameClassTest
    {
        private ThreadTester threadTester;
        public class ThreadTester
        {
            private Thread[] speedThread = new Thread[400];
            private long[] addCounter = new long[400];
            private long[] readCounter = new long[400];
            private bool[] stopThread = new bool[400];
            internal Exception threadException;
            private int count;

            public ThreadTester(int count)
            {
                for( var i=0; i<speedThread.Length; i+=10)
                {
                    speedThread[i] = new Thread(SpeedTestLoop);
                }
                this.count = count;
            }

            public void Run()
            {
                for (var i = 0; i < count*10; i+=10)
                {
                    speedThread[i].Start(i);
                }
            }

            public void Stop()
            {
                for (var i = 0; i < stopThread.Length; i+=10 )
                {
                    stopThread[i] = true;
                }
                for (var i = 0; i < count * 10; i += 10)
                {
                    speedThread[i].Join();
                }
                if (threadException != null)
                {
                    throw new Exception("Thread failed: ", threadException);
                }
            }

            public void Output()
            {
                var readSum = 0L;
                var addSum = 0L;
                for (var i = 0; i < count; i++)
                {
                    readSum += readCounter[i];
                    addSum += addCounter[i];
                }
                Console.Out.WriteLine("Thread readCounter " + readSum + ", addCounter " + addSum);
            }

            private void SpeedTestLoop(object indexarg)
            {
                var index = (int) indexarg;
                try
                {
                    while (!stopThread[index*10])
                    {
                        for (var i = 0; i < 500; i++)
                        {
                            ++addCounter[index*10];
                        }
                        for (var i = 0; i < 500; i++)
                        {
                            ++readCounter[index*10];
                        }
                    }
                }
                catch (Exception ex)
                {
                    threadException = ex;
                }
            }
        }

        [SetUp]
        public void Setup()
        {
        }


        [Test]
        public void SingleCoreTest()
        {
            TestCores(1);
        }

        [Test]
        public void DualCoreTest()
        {
            TestCores(2);
        }

        [Test]
        public void TriCoreTest()
        {
            TestCores(3);
        }

        [Test]
        public void QuadCoreTest()
        {
            TestCores(4);
        }

        public void TestCores(int numCores)
        {
            threadTester = new ThreadTester(numCores);
            threadTester.Run();
            Thread.Sleep(5000);
            threadTester.Stop();
            threadTester.Output();
        }
    }
}

person Wayne    schedule 26.12.2011    source источник
comment
Вы запускаете это в режиме выпуска без прикрепленного отладчика?   -  person Jim Mischel    schedule 26.12.2011
comment
Примечание: в вашем коде нет операций синхронизации потоков (блокировки или блокировки или что-то еще). Если вы сохраните это так, вы также можете использовать Random для всех значений, поскольку нет способа правильно запустить многопоточный код без синхронизации.   -  person Alexei Levenkov    schedule 26.12.2011
comment
Джим, я попробовал оба варианта. Но обсуждаемые числа относятся к режиму отладки. Alexel .. Я в курсе необходимости блокировок и чередования. Конечно. Но это всего лишь экспериментальный код. Эксперимент начался с блокировок, но производительность была ужасной - хуже с большим количеством потоков / ядер. Поэтому я снял замки, чтобы посмотреть, будет ли это быстрее. Нет ... все еще плохо. Итак, я пытаюсь изолировать, почему 4 ядра работают с одним и тем же кодом без каких-либо блокировок, так как собака медленная ??? Ты знаешь почему?   -  person Wayne    schedule 26.12.2011
comment
Фактический ответ - конкуренция в кэше L1. С массивами будьте осторожны, потому что каждый доступ к массиву в C # считывает длину массива под крышками для проверки границ. Таким образом, любая запись в начале массива любым другим потоком приведет к пропуску кеша и значительному снижению производительности.   -  person Wayne    schedule 26.12.2011
comment
SpeedTestLoop2 дважды увеличивает i счетчик, он же делает i ++ дважды, это сделано намеренно. могли бы мы сохранить ++ постоянным образом, ++ counter vs counter ++, чтобы код было легче читать, если следовать шаблону.   -  person Seabizkit    schedule 31.01.2020
comment
Было бы интересно, каков был бы результат, если бы вы удалили private volatile bool stopThread = false;, два потока просматривают это vs 1, что означает 0 состязаний. но все же интересно ... как вы и не подумали, это будет такая неуверенность.   -  person Seabizkit    schedule 31.01.2020


Ответы (2)


Это всего лишь 223 миллиона приращений чтения. Что это за творение бога, если эти два процессора делают за эти 5 секунд меньше работы?

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

person Community    schedule 26.12.2011
comment
Интересный ракурс. Пожалуйста, посмотрите мои правки по результатам других экспериментов. Считаете ли вы, что конкуренция за кэш L1 происходит в среде CLR, когда точка входа потока находится в одних и тех же экземплярах класса, а не в отдельных экземплярах? Если так, возможно, это у вас есть в объяснении. - person Wayne; 26.12.2011
comment
Для справки, эта проблема подкачки памяти называется перегрузкой - en.wikipedia.org/wiki/Thrashing_ (компьютерная наука) - person Corey Ogburn; 26.12.2011
comment
Еще несколько тестов показывают, что с добавлением использования Interlocked для увеличения производительность последовательно: снижается с 1 до использования 2 потоков на четырехъядерном процессоре, затем идет вверх на 3-м потоке и снова уменьшается при добавлении 4-го потока. Понятно, что мне не хватает чего-то общего с многопроцессорными процессорами. Происходит какое-то взаимодействие, будь то кеш L1 или что-то еще. - person Wayne; 26.12.2011
comment
@Wayne - это меньше связано с потоками, работающими в одном экземпляре, и больше связано с локализацией памяти ваших переменных-членов. Перенос счетчиков на метод locals, вероятно, значительно повысит производительность. - person Bevan; 26.12.2011
comment
Хорошо. Это логично, но это просто микротест. В реальном мире этим потокам необходимо каким-то образом обмениваться данными. Но до сих пор большинство методов, с которыми я экспериментирую для обмена данными, приводят к серьезному снижению производительности. В этом случае они даже не обмениваются данными. Но когда у меня есть Interlocked.Increment () на том же счетчике, производительность также ухудшается. Нет возможности разделить счетчики и получить параллельную производительность? Может быть нет. Знаете ли вы какие-нибудь книги, в которых более подробно рассказывается о локальности данных? - person Wayne; 26.12.2011
comment
Мое тестирование показывает, что это конфликт потоков. Если я использую массив счетчиков, то производительность падает с количеством потоков. Если я выделяю класс счетчика для каждого потока, производительность увеличивается линейно с увеличением количества потоков (т.е. 4 потока дают мне 4-кратную производительность по сравнению с 1 потоком). - person Jim Mischel; 26.12.2011
comment
Спасибо, что попробовал это, Джим. Итак, кто-нибудь, почему возникает конфликт потоков, если в коде не указана какая-либо блокировка? CLR автоматически выполняет какие-то блокировки под крышками? Или это какая-то конкуренция ядер за кеш памяти? Я очень запуталась. - person Wayne; 26.12.2011
comment
Этот PDF-файл объясняет проблему с кешем L1, и тестирование доказывает, что duskwuff дал правильный ответ на вопрос "хитрый гвоздь". multicoreinfo.com/research/intel/mem-issues.pdf Это Оказывается, на производительность могут СЕРЬЕЗНО повлиять 2 потока, которые пишут в память, если эта память находится рядом друг с другом - в одной строке кэша. Пока они находятся достаточно далеко друг от друга, вы можете получить настоящую параллельную производительность. Поэтому необходимо проектировать таким образом, чтобы выполнялась как можно более совместная запись в память. - person Wayne; 26.12.2011

Несколько вещей:

  1. Вероятно, вам следует протестировать каждую настройку не менее 10 раз и взять средний
  2. Насколько мне известно, Thread.sleep не совсем точен - это зависит от того, как ОС переключает ваши потоки.
  3. Thread.join происходит не сразу. Опять же, это зависит от того, как ОС переключает ваши потоки.

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

  1. Время, сколько времени нужно, чтобы просуммировать от одного до миллиона
  2. Посчитайте, сколько времени нужно, чтобы суммировать один до 500000 в одном потоке и один от 500001 до 1000000 в другом

Вы были правы, когда думали, что два потока будут работать быстрее, чем один поток. Но запущены не только ваши потоки - в ОС есть потоки, в вашем браузере есть потоки и так далее. Имейте в виду, что ваше расписание не будет точным и может даже колебаться.

Наконец, существуют другие причины (см. Слайд 24), почему потоки работают медленнее.

person James Lim    schedule 26.12.2011
comment
Все указанные вами факторы были проверены. То есть на машине, в браузере и т. Д. Больше ничего не работало. Только этот процесс занимал центральный процессор, за исключением небольшого приложения диспетчера задач, чтобы следить за ним. Кроме того, я неоднократно запускал этот тест и обнаружил, что (как отредактировано), если потоки запускаются в одном экземпляре класса, их производительность постоянно снижается на каждый добавленный поток. Но в отдельных случаях их производительность значительно и почти линейно улучшается с каждым добавленным потоком. Любое объяснение? - person Wayne; 26.12.2011
comment
В таком случае я согласен с duskwuff. Более вероятно, что сбой происходит, когда потоки находятся в одном экземпляре. Попробуйте это: вместо добавления в readCounter и readCounter2 добавьте в массив, например. arr [0] и arr [5]. Посмотрите, что произойдет, если вы измените расстояние между ними. - person James Lim; 26.12.2011
comment
Итак, используя массив, чтобы поместить их в отдельные строки кеша? Интересный. Я воспользуюсь тем же тестовым кодом, что и выше, и попробую именно это. Дайте мне несколько минут, пожалуйста. Не могу дождаться, чтобы попробовать. - person Wayne; 26.12.2011
comment
Да это правильно. Это довольно распространенный метод их разнесения, но ширина пространства зависит от системы ... - person James Lim; 26.12.2011
comment
На всякий случай я поместил их по 10 элементов в массив длинных значений. Я редактировал с помощью нового модульного теста, который немного более элегантен. Что ж, производительность по-прежнему ужасно ухудшается для каждого дополнительного потока на четырехъядерном процессоре. Пока что единственным общим элементом является то, что они находятся в одном экземпляре потока. Теперь у меня есть еще один тест, в котором каждый поток находится в отдельном экземпляре. Он отлично работает, пока они не поделятся доступом к общей очереди, что и было первоначальной целью теста. Так что, возможно, размещение элементов внутри этой очереди поможет. Сообщите мне, если у вас появятся другие идеи. - person Wayne; 26.12.2011
comment
На самом деле, больший интервал! = Лучшая производительность. Это зависит от того, как кеш организует строки кеша. Вы каким-то образом блокируете очередь? - person James Lim; 26.12.2011
comment
Целью очереди было никогда не позволять ни одному потоку (ядру) ждать другого. Таким образом, очередь фактически содержит массив очередей, равный количеству процессоров. При постановке в очередь поток будет проходить цикл с помощью Interlocked.CompareExchange (), чтобы заблокировать один и записать в эту очередь. Каждая очередь предназначена для одновременного чтения и записи, поэтому для каждой очереди в CompareExchange используются отдельные переменные для потоков, которые необходимо удалить из очереди. - person Wayne; 26.12.2011
comment
Привет, я обнаружил проблему с массивами. Это конкуренция в кэше L1 за границы, проверяемые для массива. То есть каждый доступ к массиву сравнивает длину массива. Если один поток выполняет запись в индексе 0 массива, то он находится в той же строке кэша, что и длина, которую должны читать другие потоки, поэтому это создает конкуренцию. Напротив, если ни один из потоков не записывает нигде в начале массива, вы получаете очень высокую производительность с потоками, записывающими отдельно в другие части массива. - person Wayne; 26.12.2011
comment
Я начал с вашей программы и добавил несколько полей типа long между readcounter и readcounter2. Это показало улучшение производительности следующим образом: с полем от 0 до 5 long между readcounter readcounter2, без изменений. В версии 6 производительность многопоточности упала еще больше. Но с 7 или более производительность многопоточности увеличилась до 170% от производительности однопоточной. Итак, более 48 байтов между полями обеспечили волшебство. - person Elroy Flynn; 27.12.2011