четверг, 29 августа 2013 г.

Об итерировании статичных массивов в .NET, часть 2

Блог переехал. Актуальная версия поста находится по адресу: http://aakinshin.net/ru/blog/dotnet/static-array-iteration/.


В первой части я встретился с весьма интересной ситуацией. Были измерены скорости работы двух методов, в первом из которых считалась сумма элементов массива, ссылка на который хранилась в обычном поле объекта, а во втором — массива, ссылка на который хранилась в статичном поле. Результаты меня удивили: массивы были одинаковые, но второй метод работал ощутимо дольше. Сперва я подумал, что дело в организации скорости доступа к статичным полям, но более детальный анализ ситуации и разговоры с коллегами помогли мне понять, что истинная причина такого поведения намного интересней: для массивов, длина которых кратна 4, JIT использует различные оптимизации в случае обычных и статичных массивов. Давайте разберёмся с ситуацией более детально.

Напомню методы, поведение которых мы будем изучать:

private const int N = 1000, IterationCount = 1000000;
private int[] nonStaticField;
private static int[] staticField;

public void Run()
{
    nonStaticField = staticField = new int[N];
    NonStaticRun();
    StaticRun();
}

private int NonStaticRun()
{
    int sum = 0;
    for (int iteration = 0; iteration < IterationCount; iteration++)
        for (int i = 0; i < N; i++)
            sum += nonStaticField[i];
    return sum;
}

private int StaticRun()
{
    int sum = 0;
    for (int iteration = 0; iteration < IterationCount; iteration++)
        for (int i = 0; i < N; i++)
            sum += staticField[i];
    return sum;
}

Для начала поменяем Platform target на x86 и запустим бенчмарк. Получим следующие результаты:

Non-static : 708ms
Static     : 709ms

Интересный вывод: на x86 результаты тестов одинаковы. Чтобы лучше разобраться в проблеме взглянем на нативный код, который получается после JIT-оптимизаций (изучается версия в Release mode without debugging). Конфигурация моей машины, на которой я проводил тестирование: Intel Core i7-3632QM CPU 2.20GHz.

NonStaticRun-x86.asm

00 push ebp 
01 mov  ebp,esp 
03 push edi 
04 push esi 
05 push ebx 
06 xor  edi,edi                     ; sum = 0
08 xor  ebx,ebx                     ; iteration
0a xor  edx,edx                     ; i = 0
0c mov  eax,dword ptr [ecx+4]       ; eax = &nonStaticField
0f mov  esi,dword ptr [eax+4]       ; esi = nonStaticField.Length
12 cmp  edx,esi                     ; if i >= nonStaticField.Length then
14 jae  00000033                    ; throw IndexOutOfRangeException
16 add  edi,dword ptr [eax+edx*4+8] ; sum += nonStaticField[i];
1a inc  edx                         ; i++
1b cmp  edx,3E8h                    ; if i < 1000 then
21 jl   00000012                    ; loop by i
23 inc  ebx                         ; iteration++
24 cmp  ebx,0F4240h                 ; if iteration < 1000000 then
2a jl   0000000A                    ; loop by iteration
2c mov  eax,edi                     ; eax = sum (Result)
2e pop  ebx 
2f pop  esi 
30 pop  edi 
31 pop  ebp 
32 ret 
33 call 63495C4D                    ; IndexOutOfRangeException
38 int  3 

StaticRun-x86.asm

00 push ebp 
01 mov  ebp,esp 
03 push edi 
04 push esi 
05 push ebx 
06 xor  edi,edi                      ; sum = 0
08 xor  ebx,ebx                      ; iteration = 0
0a xor  eax,eax                      ; i = 0
0c mov  edx,dword ptr ds:[03943380h] ; edx = &staticField
12 mov  esi,dword ptr [edx+4]        ; esi = staticField.Length 
15 cmp  eax,esi                      ; if i >= staticField.Length then
17 jae  00000035                     ; throw IndexOutOfRangeException
19 add  edi,dword ptr [edx+eax*4+8]  ; sum += staticField[i];
1d inc  eax                          ; i++
1e cmp  eax,3E8h                     ; if i < 1000 then
23 jl   00000015                     ; loop by i
25 inc  ebx                          ; iteration++
26 cmp  ebx,0F4240h                  ; if iteration < 1000000 then
2c jl   0000000A                     ; loop by iteration
2e mov  eax,edi                      ; eax = sum (Result)
30 pop  ebx 
31 pop  esi 
32 pop  edi 
33 pop  ebp 
34 ret 
35 call 639E52D5                     ; IndexOutOfRangeException
3a int  3 

Из этого кода становится понятно, что разнице во времени взяться неоткуда: методы отличаются только в одной строчке, в которой берётся адрес интересующего нас массива. В обоих случаях используется команда move, просто её аргументы разнятся, это не должно сказаться на производительности.

Теперь поменяем платформу на x64 и запустим бенчмарк:

Non-static : 347ms
Static     : 533ms

Любопытно: в обоих случаях быстродействие значительно возросло, но только в случае статичного поля оптимизация вышла «слабее». В чём же дело? Обратимся опять к машинному коду:

NonStaticRun-x64.asm

00 sub  rsp,28h
04 mov  r8,rcx
07 xor  ecx,ecx
09 mov  edx,ecx                      ; sum = 0
0b nop  dword ptr [rax+rax]
10 xor  r10d,r10d                    ; i = 0
13 mov  r9,qword ptr [r8+8]          ; r9 = &nonStaticField
17 mov  rax,qword ptr [r9+8]         ; rax = nonStaticField.Length
1b mov  r11d,3E4h                    ; r11 = 996
21 cmp  r11,rax                      ; if r11 >= nonStaticField.Length then
24 jae  000000000000008A             ; throw IndexOutOfRangeException
26 mov  r11d,3E5h                    ; r11 = 997
2c cmp  r11,rax                      ; if r11 >= nonStaticField.Length then
2f jae  000000000000008A             ; throw IndexOutOfRangeException
31 mov  r11d,3E6h                    ; r11 = 998
37 cmp  r11,rax                      ; if r11 >= nonStaticField.Length then
3a jae  000000000000008A             ; throw IndexOutOfRangeException
3c mov  r11d,3E7h                    ; r11 = 999
42 cmp  r11,rax                      ; if r11 >= nonStaticField.Length then
45 jae  000000000000008A             ; throw IndexOutOfRangeException
47 nop  word ptr [rax+rax+00000000h]
50 mov  eax,dword ptr [r9+r10+10h]   ; eax = nonStaticField[i]
55 add  edx,eax                      ; sum += eax
57 mov  eax,dword ptr [r9+r10+14h]   ; eax = nonStaticField[i+1]
5c add  edx,eax                      ; sum += eax
5e mov  eax,dword ptr [r9+r10+18h]   ; eax = nonStaticField[i+2]
63 add  edx,eax                      ; sum += eax
65 mov  eax,dword ptr [r9+r10+1Ch]   ; eax = nonStaticField[i+3]
6a add  edx,eax                      ; sum += eax
6c add  r10,10h                      ; i += 4
70 cmp  r10,0FA0h                    ; if i < 1000 then
77 jl   0000000000000050             ; loop by i
79 inc  ecx                          ; iteration++
7b cmp  ecx,0F4240h                  ; if iteration < 1000000  then
81 jl   0000000000000010             ; loop by iteration
83 mov  eax,edx                      ; eax = sum (Result)
85 add  rsp,28h
89 ret   
8a call 000000005FA4AE14             ; IndexOutOfRangeException
8f nop 

Этот пример намного интересней! Вспомним, что в x86 нам доступно только 8 регистров по 32 бита (EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI, R8D), а в x64 доступно 16 регистров по 64 бита (RAX, RCX, RDX, RBX, RSP, RBP, RSI, RDI, R8 — R15). Увеличение количества регистров позволило произвести JIT-оптимизацию «размотка цикла» (см. Loop unwinding). При этом важную роль играет то обстоятельство, что количество итераций в каждом из циклов кратно четвёрке. Мы ещё вернёмся к этому моменту, а пока взглянем на static-версию.

StaticRun-x64.asm

00 sub  rsp,28h 
04 xor  ecx,ecx                       ; iteration = 0
06 mov  edx,ecx                       ; sum = 0
08 nop  dword ptr [rax+rax+00000000h] 
10 xor  r8d,r8d                       ; i = 0
13 mov  r9,12D756F0h                  ; r9 = staticField
1d mov  r9,qword ptr [r9]             ; r9 = &staticField
20 mov  r10,qword ptr [r9+8]          ; r10 = staticField.Length
24 cmp  r8,r10                        ; if r8 >= staticField.Length then
27 jae  0000000000000080              ; throw IndexOutOfRangeException
29 mov  eax,dword ptr [r9+r8*4+10h]   ; eax = staticField[i]
2e add  edx,eax                       ; sum += eax
30 lea  rax,[r8+1]                    ; rax = i+1
34 cmp  rax,r10                       ; if rax >= staticField.Length then
37 jae  0000000000000080              ; throw IndexOutOfRangeException
39 mov  eax,dword ptr [r9+rax*4+10h]  ; eax = staticField[i+1]
3e add  edx,eax                       ; sum += eax
40 lea  rax,[r8+2]                    ; rax = i+2
44 cmp  rax,r10                       ; if rax >= staticField.Length then
47 jae  0000000000000080              ; throw IndexOutOfRangeException
49 mov  eax,dword ptr [r9+rax*4+10h]  ; eax = staticField[i+2]
4e add  edx,eax                       ; sum += eax
50 lea  rax,[r8+3]                    ; rax = i+3
54 cmp  rax,r10                       ; if rax >= staticField.Length then
57 jae  0000000000000080              ; throw IndexOutOfRangeException
59 mov  eax,dword ptr [r9+rax*4+10h]  ; eax = staticField[i+3]
5e add  edx,eax                       ; sum += eax
60 add  r8,4                          ; i += 4
64 cmp  r8,3E8h                       ; if i < 1000 then
6b jl   0000000000000013              ; loop by i
6d inc  ecx                           ; iteration++
6f cmp  ecx,0F4240h                   ; if iteration < 1000000 then
75 jl   0000000000000010              ; loop by iteration
77 mov  eax,edx                       ; eax = sum (result)
79 add  rsp,28h 
7d ret     
7e xchg ax,ax 
80 call 000000005FA49F64              ; IndexOutOfRangeException
85 nop   

Из примера видно, что для статичного массива оптимизация размотки цикла прошла несколько иначе. Причём, если в методе StaticRun сохранить ссылку на статический массив в локальную переменную и итерировать по ней, то машинный код будет аналогичен примеру NonStaticRun-x64.asm, а производительность обоих методов станет одинаковой. В текущей версии static-версия «проседает» по скорости из-за следующих обстоятельств:

  • Вместо того, чтобы явно хранить смещения элементов, хранится индекс, который в момент вычисления адреса умножается на 4 для получения смещения.
  • Вычисление адресов элементов [i+1], [i+2], [i+3] происходит в регистрах вместо того, чтобы использовать константные смещения в 4h, 8h, bh, относительно элемента [i].

Теперь попробуем изменить длину массива, чтобы она больше не делилась на 4: N = 1001. Результаты бенчмарка:

Non-static : 550ms
Static     : 719ms

Static-версия по скорости «вернулась» к результату без оптимизации, который мы видели в x86-версии. В NonStatic-версии результат интереснее: текущая версия работает медленнее, чем для N=1000, но быстрее, чем для x86. Опять обратимся к машинному коду, чтобы разобраться:

NonStaticRun-x64-1001.asm

00 sub  rsp,28h 
04 mov  r9,rcx 
07 xor  ecx,ecx 
09 mov  edx,ecx 
0b nop  dword ptr [rax+rax] 
10 xor  r8d,r8d 
13 mov  r10,qword ptr [r9+8] 
17 mov  rax,qword ptr [r10+8] 
1b mov  r11d,3E8h 
21 cmp  r11,rax 
24 jae  0000000000000055 
26 nop  word ptr [rax+rax+00000000h] 
30 mov  eax,dword ptr [r10+r8+10h] 
35 add  edx,eax 
37 add  r8,4 
3b cmp  r8,0FA4h 
42 jl   0000000000000030 
44 inc  ecx 
46 cmp  ecx,0F4240h 
4c jl   0000000000000010 
4e mov  eax,edx 
50 add  rsp,28h 
54 ret    
55 call 000000005FA5AE14 
5a nop    

StaticRun-x64-1001.asm

00 sub  rsp,28h 
04 xor  ecx,ecx 
06 mov  edx,ecx 
08 nop  dword ptr [rax+rax+00000000h] 
10 xor  r8d,r8d 
13 mov  r9,12B556F0h 
1d mov  r9,qword ptr [r9] 
20 mov  rax,qword ptr [r9+8] 
24 cmp  r8,rax 
27 jae  0000000000000050 
29 mov  eax,dword ptr [r9+r8*4+10h] 
2e add  edx,eax 
30 inc  r8 
33 cmp  r8,3E9h 
3a jl   0000000000000013 
3c inc  ecx 
3e cmp  ecx,0F4240h 
44 jl   0000000000000010 
46 mov  eax,edx 
48 add  rsp,28h 
4c ret   
4d nop  dword ptr [rax] 
50 call 000000005FA69FA4 
55 nop       

Из примеров можно заметить, что в методах NonStaticRun-x86, StaticRun-x86, StaticRun-x64-1001 для вычисления очередного элемента массива используется формула: BaseAddress + i * 4 + 10h, а в методе NonStaticRun: BaseAddress + offset + 10h, где offset = i * 4 — уже готовое смещение. Этим и объясняется разница в скорости.

Данную тему можно изучать ещё очень долго: пробовать менять конфигурацию сборки, пробовать различные длины массивов и т.п. Но я ограничусь формулировкой основного вывода.

Вывод: скорость итерирования может значительно зависеть от следующих обстоятельств:

  • Тип ссылки на массив: статичное поле или обычное поле/локальная переменная
  • Используемая архитектура процессора
  • Делимость количества элементов на степени двойки
  • Версия CLR
  • Фаза луны

Всем хороших бенчмарков =)

2 комментария:

  1. Если массив «скешировать» в локальную переменную, допустим, а, а в цикле for границу выхода из цикла поставить как a.Length, то JIT при компиляции в машинный код выкинет проверку на границу массива. Будет ещё быстрее итерироваться. :-)

    ОтветитьУдалить
    Ответы
    1. Сейчас проверил эту гипотезу. У меня получились следующие результаты:
      1. Машинные команды по проверке на выход за границы массива действительно ушли.
      2. Изменение в быстродействии ощущается очень слабо. Программа NonStaticRun-x86 изменила свой результат с 710ms до 700ms, победа в 1.5%.
      3. В x64-версии все оптимизации ушли, т.к. длина массива теперь заранее не известна.

      Удалить