Я пытался измерить разницу между использованием a for
и a foreach
при доступе к спискам типов значений и ссылочных типов.
Я использовал следующий класс для профилирования.
public static class Benchmarker
{
public static void Profile(string description, int iterations, Action func)
{
Console.Write(description);
// Warm up
func();
Stopwatch watch = new Stopwatch();
// Clean up
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
watch.Start();
for (int i = 0; i < iterations; i++)
{
func();
}
watch.Stop();
Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations);
}
}
Я использовал double
для своего типа значения. И я создал этот «поддельный класс» для проверки ссылочных типов:
class DoubleWrapper
{
public double Value { get; set; }
public DoubleWrapper(double value)
{
Value = value;
}
}
Наконец, я запустил этот код и сравнил разницу во времени.
static void Main(string[] args)
{
int size = 1000000;
int iterationCount = 100;
var valueList = new List<double>(size);
for (int i = 0; i < size; i++)
valueList.Add(i);
var refList = new List<DoubleWrapper>(size);
for (int i = 0; i < size; i++)
refList.Add(new DoubleWrapper(i));
double dummy;
Benchmarker.Profile("valueList for: ", iterationCount, () =>
{
double result = 0;
for (int i = 0; i < valueList.Count; i++)
{
unchecked
{
var temp = valueList[i];
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
{
double result = 0;
foreach (var v in valueList)
{
var temp = v;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
dummy = result;
});
Benchmarker.Profile("refList for: ", iterationCount, () =>
{
double result = 0;
for (int i = 0; i < refList.Count; i++)
{
unchecked
{
var temp = refList[i].Value;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
Benchmarker.Profile("refList foreach: ", iterationCount, () =>
{
double result = 0;
foreach (var v in refList)
{
unchecked
{
var temp = v.Value;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
SafeExit();
}
Я выбрал Release
и Any CPU
варианты, запустили программу и получили следующие времена:
valueList for: average time: 483,967938 ms
valueList foreach: average time: 477,873079 ms
refList for: average time: 490,524197 ms
refList foreach: average time: 485,659557 ms
Done!
Затем я выбрал параметры Release и x64, запустил программу и получил следующие результаты:
valueList for: average time: 16,720209 ms
valueList foreach: average time: 15,953483 ms
refList for: average time: 19,381077 ms
refList foreach: average time: 18,636781 ms
Done!
Почему 64-битная версия намного быстрее? Я ожидал некоторой разницы, но не такой большой.
У меня нет доступа к другим компьютерам. Не могли бы вы запустить это на своих машинах и сообщить мне результаты? Я использую Visual Studio 2015, и у меня есть Intel Core i7 930.
Вот SafeExit()
метод, который вы можете скомпилировать / запустить самостоятельно:
private static void SafeExit()
{
Console.WriteLine("Done!");
Console.ReadLine();
System.Environment.Exit(1);
}
По запросу, используя double?
вместо my DoubleWrapper
:
Любой процессор
valueList for: average time: 482,98116 ms
valueList foreach: average time: 478,837701 ms
refList for: average time: 491,075915 ms
refList foreach: average time: 483,206072 ms
Done!
x64
valueList for: average time: 16,393947 ms
valueList foreach: average time: 15,87007 ms
refList for: average time: 18,267736 ms
refList foreach: average time: 16,496038 ms
Done!
И последнее, но не менее важное: создание x86
профиля дает мне почти такие же результаты использованияAny CPU
.
источник
double
наfloat
,long
или,int
и вы получите аналогичные результаты.Ответы:
Я могу воспроизвести это на 4.5.2. Здесь нет RyuJIT. Разборки для x86 и x64 выглядят разумно. Проверка диапазона и т. Д. Такие же. Та же основная структура. Нет разворачивания петли.
x86 использует другой набор инструкций с плавающей запятой. Производительность этих инструкций кажется сравнимой с инструкциями x64, за исключением разделения :
Операция деления делает 32-битную версию чрезвычайно медленной. Раскомментирование деления в значительной степени выравнивает производительность (32 бита с 430 мс до 3,25 мс).
Питер Кордес отмечает, что задержки инструкций двух модулей с плавающей запятой не так уж отличаются. Возможно, некоторые из промежуточных результатов представляют собой денормализованные числа или NaN. Это может вызвать медленный путь в одном из юнитов. Или, возможно, значения различаются между двумя реализациями из-за 10-байтовой или 8-байтовой точности с плавающей запятой.
Питер Кордес также указывает, что все промежуточные результаты - NaN ... Устранение этой проблемы (
valueList.Add(i + 1)
так что ни один делитель не равен нулю) в основном выравнивает результаты. Судя по всему, 32-битный код вообще не любит NaN-операнды. Давайте печатать некоторые промежуточные значения:if (i % 1000 == 0) Console.WriteLine(result);
. Это подтверждает, что данные теперь в порядке.При тестировании вам необходимо оценить реалистичную рабочую нагрузку. Но кто бы мог подумать, что ни в чем не повинное подразделение может испортить ваш тест ?!
Попробуйте просто суммировать числа, чтобы получить лучший результат.
Деление и модуль всегда очень медленные. Если вы измените
Dictionary
код BCL, чтобы просто не использовать оператор по модулю для вычисления измеримого индекса корзины, производительность улучшится. Вот насколько медленное деление.Вот 32-битный код:
64-битный код (та же структура, быстрое деление):
Это не векторизуется, несмотря на использование инструкций SSE.
источник
fdiv
- задержка 7–27 циклов (и такая же обратная пропускная способность).divsd
составляет 7-22 цикла.addsd
при задержке 3c, пропускной способности 1 / c. Division - единственная исполнительная единица без конвейерной обработки в процессорах Intel / AMD. C # JIT не векторизует цикл для x86-64 (сdivPd
).MXCSR
).NaN
Могу я подумать, что разная обработка денормальных чисел может объяснить фактор 26 перф. Разницы. C # может устанавливать значение denormals-are-zero в MXCSR.valueList[i] = i
, начиная сi=0
, значит первая итерация цикла0.0 / 0.0
. Таким образом, каждая операция во всем вашем тесте выполняется с помощьюNaN
s. Это разделение выглядит все менее и менее невинным! Я не эксперт по производительности сNaN
s или разнице между x87 и SSE, но я думаю, что это объясняет разницу в 26x производительности. Бьюсь об заклад, ваши результаты будут намного ближе между 32 и 64 битами, если вы инициализируетеvalueList[i] = i+1
.double
было бы довольно редко. Один из основных шаблонов использования 80-битного типа заключался в том, чтобы позволить суммировать несколько чисел без необходимости полностью округлять результаты до самого конца. В соответствии с этим шаблоном переполнение просто не проблема.valueList[i] = i
, начиная сi=0
, поэтому выполняется первая итерация цикла0.0 / 0.0
. Таким образом, каждая операция во всем вашем тесте выполняется с помощьюNaN
s.Как @usr показал в выводе разборки , 32-битная версия использовала x87 с плавающей запятой, а 64-битная - SSE с плавающей запятой.
Я не эксперт по производительности с
NaN
s или разнице между x87 и SSE, но я думаю, что это объясняет разницу в 26x производительности. Бьюсь об заклад, ваши результаты будут намного ближе между 32 и 64 битами, если вы инициализируетеvalueList[i] = i+1
. (обновление: usr подтвердил, что это делает 32- и 64-битную производительность довольно близкими.)Деление происходит очень медленно по сравнению с другими операциями. См. Мои комментарии к ответу @usr. Также см. Http://agner.org/optimize/ для получения множества замечательных материалов об оборудовании и оптимизации asm и C / C ++, некоторые из которых относятся к C #. У него есть таблицы инструкций по задержке и пропускной способности для большинства инструкций для всех последних процессоров x86.
Однако 10B x87
fdiv
не намного медленнее, чем SSE2 с двойной точностью 8Bdivsd
для нормальных значений. IDK о различиях производительности с NaN, бесконечностями или денормальными числами.Однако у них есть разные средства управления тем, что происходит с NaN и другими исключениями FPU. X87 управление ФПОМ слово отделено от регистра управления округлением / исключений SSE (MXCSR). Если x87 получает исключение ЦП для каждого подразделения, а SSE - нет, это легко объясняет множитель 26. Или, может быть, есть просто разница в производительности при обработке NaN. Оборудование не оптимизировано для работы
NaN
послеNaN
.IDK, если SSE-контроль для предотвращения замедления с денормальными значениями, вступит в игру здесь, поскольку я считаю, что
result
будет всегдаNaN
. IDK, если C # устанавливает в MXCSR флаг денормальных равных нулю или флаг сброса в ноль (который записывает нули в первую очередь, вместо того, чтобы обрабатывать денормальные значения как ноль при обратном чтении).Я нашел статью Intel об элементах управления SSE с плавающей запятой, сравнивая ее с управляющим словом x87 FPU. Однако здесь особо не о чем говорить
NaN
. Заканчивается на этом:IDK, если это помогает с делением на ноль.
для vs. foreach
Было бы интересно протестировать тело цикла с ограниченной пропускной способностью, а не просто одну цепочку зависимостей с переносом цикла. Как бы то ни было, вся работа зависит от предыдущих результатов; ЦП ничего не может делать параллельно (кроме проверки границ следующего массива, пока выполняется цепочка mul / div).
Вы могли бы увидеть большую разницу между методами, если бы «реальная работа» занимала больше ресурсов выполнения ЦП. Кроме того, на Intel до Sandybridge существует большая разница между подгонкой петли в 28-омный буфер петли или без нее. Если нет, вы получите инструкции по декодированию узких мест, особенно. когда средняя длина инструкции больше (что происходит с SSE). Команды, которые декодируют более одного мупа, также ограничивают пропускную способность декодера, если только они не входят в шаблон, который удобен для декодеров (например, 2-1-1). Таким образом, цикл с большим количеством инструкций по накладным расходам цикла может иметь значение, подходит ли цикл в 28-элементный кеш uop или нет, что имеет большое значение для Nehalem и иногда полезно для Sandybridge и более поздних версий.
источник
NaN
s действительно редко встречаются на практике? Я оставил все материалы о денормальных величинах и ссылку на материалы Intel, в основном для удобства читателей, а не потому, что я думал, что это действительно сильно повлияет на этот конкретный случай.У нас есть наблюдение, что 99,9% всех операций с плавающей запятой будут включать NaN, что, по крайней мере, весьма необычно (впервые обнаружено Питером Кордесом). У нас есть еще один эксперимент usr, который показал, что удаление инструкций деления почти полностью устраняет разницу во времени.
Однако факт заключается в том, что NaN генерируются только потому, что самое первое деление вычисляет 0,0 / 0,0, что дает начальное NaN. Если деления не выполняются, результат всегда будет 0,0, и мы всегда будем вычислять 0,0 * temp -> 0,0, 0,0 + temp -> temp, temp - temp = 0,0. Таким образом, удаление деления не только удалило деления, но и удалило NaN. Я ожидал, что NaN на самом деле являются проблемой, и что одна реализация обрабатывает NaN очень медленно, в то время как другая не имеет проблемы.
Было бы целесообразно запустить цикл с i = 1 и снова измерить. Четыре операции приводят к * temp, + temp, / temp, - temp эффективно добавляют (1 - temp), поэтому у нас не будет никаких необычных чисел (0, бесконечность, NaN) для большинства операций.
Единственная проблема может заключаться в том, что деление всегда дает целочисленный результат, и некоторые реализации деления имеют ярлыки, когда правильный результат не использует много битов. Например, деление 310,0 / 31,0 дает 10,0 в качестве первых четырех битов с остатком 0,0, и некоторые реализации могут прекратить оценку оставшихся 50 или около того битов, в то время как другие не могут. Если есть существенная разница, то запуск цикла с результатом = 1.0 / 3.0 будет иметь значение.
источник
Может быть несколько причин, по которым это выполняется быстрее в 64-битной версии на вашем компьютере. Причина, по которой я спросил, какой процессор вы используете, заключалась в том, что, когда впервые появились 64-битные процессоры, AMD и Intel имели разные механизмы для обработки 64-битного кода.
Архитектура процессора:
Архитектура процессора Intel была чисто 64-битной. Чтобы выполнить 32-битный код, 32-битные инструкции необходимо преобразовать (внутри ЦП) в 64-битные инструкции перед выполнением.
Архитектура ЦП AMD должна была построить 64-битную прямо поверх 32-битной архитектуры; то есть, по сути, это была 32-битная архитектура с 64-битными расширениями - не было процесса преобразования кода.
Очевидно, это было несколько лет назад, поэтому я понятия не имею, изменилась ли / как технология, но, по сути, вы ожидаете, что 64-битный код будет работать лучше на 64-битной машине, так как процессор может работать с удвоенным количеством бит на инструкцию.
.NET JIT
Утверждается, что .NET (и другие управляемые языки, такие как Java) способны превосходить такие языки, как C ++, из-за того, как JIT-компилятор может оптимизировать ваш код в соответствии с архитектурой вашего процессора. В этом отношении вы можете обнаружить, что JIT-компилятор использует что-то в 64-битной архитектуре, что, возможно, было недоступно или требовало обходного пути при выполнении в 32-битной среде.
Заметка:
Рассматривали ли вы использование
Nullable<double>
или сокращенный синтаксис вместо использования DoubleWrapper :double?
- Мне было бы интересно посмотреть, повлияет ли это на ваши тесты.Примечание 2: Некоторые люди, кажется, объединяют мои комментарии о 64-битной архитектуре с IA-64. Чтобы уточнить, в моем ответе 64-битная версия относится к x86-64, а 32-битная относится к x86-32. Здесь ничего не говорится о IA-64!
источник