Эффективность проверки границ массива в .net 4 и выше
Мне интересно, как эффективные низкоуровневые алгоритмы могут быть в .net. Я бы хотел, чтобы мы решили записать больше нашего кода на С#, а не на С++ в будущем, но один камень преткновения - это проверка границ в .net, которая происходит с циклом и произвольным доступом к массивам.
Мотивирующим примером является функция, которая вычисляет сумму произведений соответствующих элементов в двух массивах (это точечное произведение двух векторов).
static void SumProduct(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
for (int i = 0; i < length; i++) // Check X.Length instead? See below
sum += X[i] * Y[i];
}
Из того, что я могу сказать, и не знаю достаточно IL или x86 для проверки, компилятор не будет оптимизировать проверку границ X
и Y
. Я ошибаюсь и/или есть способ написать мой код, чтобы позволить компилятору помочь мне?
Дополнительная информация
Существует множество аргументов эффективности для и против использования определенных языков, не в последнюю очередь, что лучше сосредоточиться на алгоритмической стоимости "большого О", а не на константе пропорциональности, и языки более высокого уровня помогут вам это сделать. Что касается проверки границ в .net, лучшая статья, которую я нашел, это Проверка границ массива в CLR на MSDN (также упоминается в ответ на переполнение стека о важности включения оптимизации).
Это датируется 2009 годом, поэтому я задаюсь вопросом, значительно ли изменилось с тех пор. Кроме того, в статье раскрываются некоторые настоящие тонкости, которые заставили бы меня покончить, поэтому по этой причине я бы приветствовал некоторые советы экспертов.
Например, похоже, что в моем коде выше было бы лучше писать i< X.Length
, а не i < length
. Кроме того, я также наивно полагал, что для алгоритма с одним массивом запись цикла foreach
лучше объявит ваше намерение компилятору и даст ему наилучшие возможности для оптимизации проверки границ.
Согласно статье MSDN, SumForBAD
, ниже, которая, как я думал, была бы оптимизирована, не будет. В то время как SumFor
будет легко оптимизирован, а SumForEach
также будет оптимизирован, но не тривиально (и вообще не может быть оптимизирован, если массив передан в функцию как IEnumerable<int>
)?
static double SumForBAD(double[] X)
{
double sum = 0;
int length = X.Length; // better to use i < X.length in loop
for (int i = 0; i < length; i++)
sum += X[i];
return sum;
}
static double SumFor(double[] X)
{
double sum = 0;
for (int i = 0; i < X.Length; i++)
sum += X[i];
return sum;
}
static double SumForEach(double[] X)
{
double sum = 0;
foreach (int element in X)
sum += element;
return sum;
}
Я провел некоторое расследование, основанное на ответе doug65536. В С++ я сравнивал времена SumProduct, который выполняет одну проверку границ
for(int i=0; i<n; ++i) sum += v1[i]*v2[i];
против другой версии, которая выполняет две проверки границ
for(int i=0; i<n1 && i <n2; ++i) sum += v1[i]*v2[i];
Я обнаружил, что вторая версия была медленнее, но только примерно на 3,5% (Visual Studio 2010, оптимизированная сборка, параметры по умолчанию). Однако мне пришло в голову, что в С# могут быть три проверки границ. Один явный (i < length
в функции static void SumProduct(double[] X, double[] Y)
в начале этого вопроса) и два неявных (X[i]
и Y[i]
). Поэтому я протестировал третью С++-функцию с тремя ограничениями проверки
for(int i=0; i<n1 && i <n2 && i <n3; ++i) sum += v1[i]*v2[i];
Это произошло на 35% медленнее первого, чего стоит заботиться. Я сделал еще несколько исследований в этом вопросе, Почему добавление дополнительного цикла проверки делает большую разницу на некоторых машинах и небольшая разница в других?. Интересно, что, по-видимому, стоимость проверки границ значительно различается на разных машинах.
Ответы
Ответ 1
Проверка границ не имеет значения, потому что:
-
Проверка границ состоит из пары инструкций cmp
/jae
, которая объединена в один микрооператор на современных архитектурах процессора (термин "слияние макросов" ). Сравнение и ветвь очень оптимизированы.
-
Проверка границ - это передовая ветка, которая будет статически предсказана, чтобы не быть принятой, а также снизить стоимость. Филиал никогда не будет принят. (Если это когда-либо будет принято, исключение все равно будет выброшено, поэтому стоимость неверного прогноза станет совершенно неактуальной).
-
Как только есть какая-либо задержка памяти, спекулятивное выполнение приостанавливает многие итерации цикла, поэтому стоимость декодирования дополнительной пары команд почти исчезает.
Доступ к памяти, скорее всего, будет вашим узким местом, поэтому эффект микро-оптимизации, такой как удаление проверок границ, исчезнет.
Ответ 2
64-битный
64-разрядный джиттер отлично справляется с устранением проверок границ (по крайней мере, в простых сценариях). Я добавил return sum;
в конце вашего метода, а затем скомпилировал программу с помощью Visual Studio 2010 в режиме Release. В приведенной ниже разборке (которую я аннотировал с переводом на С#), обратите внимание, что:
- Нет проверок ограничений для
X
, даже если ваш код сравнивает i
с length
вместо X.Length
. Это улучшает поведение, описанное в статье.
- Перед основным циклом есть одна проверка, чтобы убедиться, что
Y.Length >= X.Length
.
- Основной цикл (смещения от 00000032 до 00000052) не содержит проверок границ.
Демонтажные
; Register assignments:
; rcx := i
; rdx := X
; r8 := Y
; r9 := X.Length ("length" in your code, "XLength" below)
; r10 := Y.Length ("YLength" below)
; r11 := X.Length - 1 ("XLengthMinus1" below)
; xmm1 := sum
; (Prologue)
00000000 push rbx
00000001 push rdi
00000002 sub rsp,28h
; (Store arguments X and Y in rdx and r8)
00000006 mov r8,rdx ; Y
00000009 mov rdx,rcx ; X
; int XLength = X.Length;
0000000c mov r9,qword ptr [rdx+8]
; int XLengthMinus1 = XLength - 1;
00000010 movsxd rax,r9d
00000013 lea r11,[rax-1]
; int YLength = Y.Length;
00000017 mov r10,qword ptr [r8+8]
; if (XLength != YLength)
; throw new ArgumentException("X and Y must be same size");
0000001b cmp r9d,r10d
0000001e jne 0000000000000060
; double sum = 0;
00000020 xorpd xmm1,xmm1
; if (XLength > 0)
; {
00000024 test r9d,r9d
00000027 jle 0000000000000054
; int i = 0;
00000029 xor ecx,ecx
0000002b xor eax,eax
; if (XLengthMinus1 >= YLength)
; throw new IndexOutOfRangeException();
0000002d cmp r11,r10
00000030 jae 0000000000000096
; do
; {
; sum += X[i] * Y[i];
00000032 movsd xmm0,mmword ptr [rdx+rax+10h]
00000038 mulsd xmm0,mmword ptr [r8+rax+10h]
0000003f addsd xmm0,xmm1
00000043 movapd xmm1,xmm0
; i++;
00000047 inc ecx
00000049 add rax,8
; }
; while (i < XLength);
0000004f cmp ecx,r9d
00000052 jl 0000000000000032
; }
; return sum;
00000054 movapd xmm0,xmm1
; (Epilogue)
00000058 add rsp,28h
0000005c pop rdi
0000005d pop rbx
0000005e ret
00000060 ...
00000096 ...
32-битный
32-разрядный джиттер, к сожалению, не такой уж умный. При демонстрации ниже обратите внимание, что:
- Нет проверок ограничений для
X
, даже если ваш код сравнивает i
с length
вместо X.Length
. Опять же, это улучшение по сравнению с поведением, описанным в статье.
- Основной цикл (смещения от 00000018 до 0000002a) содержит проверку границ для
Y
.
Демонтажные
; Register assignments:
; eax := i
; ecx := X
; edx := Y
; esi := X.Length ("length" in your code, "XLength" below)
; (Prologue)
00000000 push ebp
00000001 mov ebp,esp
00000003 push esi
; double sum = 0;
00000004 fldz
; int XLength = X.Length;
00000006 mov esi,dword ptr [ecx+4]
; if (XLength != Y.Length)
; throw new ArgumentException("X and Y must be same size");
00000009 cmp dword ptr [edx+4],esi
0000000c je 00000012
0000000e fstp st(0)
00000010 jmp 0000002F
; int i = 0;
00000012 xor eax,eax
; if (XLength > 0)
; {
00000014 test esi,esi
00000016 jle 0000002C
; do
; {
; double temp = X[i];
00000018 fld qword ptr [ecx+eax*8+8]
; if (i >= Y.Length)
; throw new IndexOutOfRangeException();
0000001c cmp eax,dword ptr [edx+4]
0000001f jae 0000005A
; sum += temp * Y[i];
00000021 fmul qword ptr [edx+eax*8+8]
00000025 faddp st(1),st
; i++;
00000027 inc eax
; while (i < XLength);
00000028 cmp eax,esi
0000002a jl 00000018
; }
; return sum;
0000002c pop esi
0000002d pop ebp
0000002e ret
0000002f ...
0000005a ...
Подведение итогов
Дисктер улучшился с 2009 года, а 64-разрядный джиттер может генерировать более эффективный код, чем 32-разрядный джиттер.
При необходимости, однако, вы всегда можете полностью обходить проверки границ массива, используя небезопасный код и указатели (как указывает svick). Этот метод используется некоторым критическим для производительности кодом в библиотеке базового класса.
Ответ 3
Один из способов убедиться, что проверка границ не выполняется, - это использовать указатели, которые вы можете делать на С# в небезопасном режиме (для этого требуется установить флаг в свойствах проекта):
private static unsafe double SumProductPointer(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
fixed (double* xp = X, yp = Y)
{
for (int i = 0; i < length; i++)
sum += xp[i] * yp[i];
}
return sum;
}
Я попытался измерить ваш оригинальный метод, ваш метод с изменением X.Length
и мой код с помощью указателей, скомпилированных как x86 и x64 в .Net 4.5. В частности, я попытался вычислить метод для векторов длиной 10 000 и запустить метод 10 000 раз.
Результаты в значительной степени согласуются с ответом Майкла Лю: нет никакой измеримой разницы между тремя методами, что означает, что проверка границ либо не выполняется, либо ее влияние на производительность незначительно. Между x86 и x64 была заметная разница: x64 примерно на 34% медленнее.
Полный код, который я использовал:
static void Main()
{
var random = new Random(42);
double[] x = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();
double[] y = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();
// make sure JIT doesn't affect the results
SumProduct(x, y);
SumProductLength(x, y);
SumProductPointer(x, y);
var stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
SumProduct(x, y);
}
Console.WriteLine(stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
for (int i = 0; i < 10000; i++)
{
SumProductLength(x, y);
}
Console.WriteLine(stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
for (int i = 0; i < 10000; i++)
{
SumProductPointer(x, y);
}
Console.WriteLine(stopwatch.ElapsedMilliseconds);
}
private static double SumProduct(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
for (int i = 0; i < length; i++)
sum += X[i] * Y[i];
return sum;
}
private static double SumProductLength(double[] X, double[] Y)
{
double sum = 0;
if (X.Length != Y.Length)
throw new ArgumentException("X and Y must be same size");
for (int i = 0; i < X.Length; i++)
sum += X[i] * Y[i];
return sum;
}
private static unsafe double SumProductPointer(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
fixed (double* xp = X, yp = Y)
{
for (int i = 0; i < length; i++)
sum += xp[i] * yp[i];
}
return sum;
}