Скорость доступа к локальным и глобальным переменным в gcc/g++ на разных уровнях оптимизации
Я обнаружил, что разные уровни оптимизации компилятора в gcc дают совершенно разные результаты при доступе к локальной или глобальной переменной в цикле. Причина этого меня удивила в том, что если доступ к одному типу переменной более оптимизирован, чем доступ к другому, я бы подумал, что gcc-оптимизация будет использовать этот факт.
Вот два примера (в С++, но их C-копии дают практически одинаковые тайминги):
global = 0;
for (int i = 0; i < SIZE; i++)
global++;
который использует глобальную переменную long global
, по сравнению с
long tmp = 0;
for (int i = 0; i < SIZE; i++)
tmp++;
global = tmp;
На уровне оптимизации -O0 время по существу равно (как и ожидалось), при -O1 оно несколько быстрее, но все равно равно, но из -O2 версия с использованием глобальной переменной намного быстрее (фактор 7 или около того).
С другой стороны, в следующих фрагментах кода, где start указывает на блок байтов размера SIZE:
global = 0;
for (const char* p = start; p < start + SIZE; p++)
global += *p;
против
long tmp = 0;
for (const char* p = start; p < start + SIZE; p++)
tmp += *p;
global = tmp;
Здесь при -O0 тайминги близки, хотя версия, использующая локальную переменную, немного быстрее, что не кажется слишком неожиданным, поскольку, возможно, оно будет храниться в регистре, тогда как global
не будет. Затем при -O1 и выше версия с использованием локальной переменной значительно быстрее (более 50% или 1,5 раза). Как отмечалось ранее, это меня удивляет, потому что я думаю, что для gcc было бы так же легко, как использовать локальную переменную (в сгенерированном оптимизированном коде) для последующего назначения глобальной.
Итак, мой вопрос: что это за глобальные и локальные переменные, которые заставляют gcc выполнять определенные оптимизации только одному типу, а не другому?
Некоторые детали, которые могут быть или не быть релевантными: я использовал gcc/g++ версию 3.4.5 на машине с RHEL4 с двумя одноядерными процессорами и 4 ГБ оперативной памяти. Значение, которое я использовал для SIZE, являющегося макросом препроцессора, составляло 1000000000. Блок байтов во втором примере был динамически распределен.
Ниже приведены некоторые временные выходы для уровней оптимизации от 0 до 4 (в том же порядке, что и выше):
$ ./st0
Result using global variable: 1000000000 in 2.213 seconds.
Result using local variable: 1000000000 in 2.210 seconds.
Result using global variable: 0 in 3.924 seconds.
Result using local variable: 0 in 3.710 seconds.
$ ./st1
Result using global variable: 1000000000 in 0.947 seconds.
Result using local variable: 1000000000 in 0.947 seconds.
Result using global variable: 0 in 2.135 seconds.
Result using local variable: 0 in 1.212 seconds.
$ ./st2
Result using global variable: 1000000000 in 0.022 seconds.
Result using local variable: 1000000000 in 0.552 seconds.
Result using global variable: 0 in 2.135 seconds.
Result using local variable: 0 in 1.227 seconds.
$ ./st3
Result using global variable: 1000000000 in 0.065 seconds.
Result using local variable: 1000000000 in 0.461 seconds.
Result using global variable: 0 in 2.453 seconds.
Result using local variable: 0 in 1.646 seconds.
$ ./st4
Result using global variable: 1000000000 in 0.063 seconds.
Result using local variable: 1000000000 in 0.468 seconds.
Result using global variable: 0 in 2.467 seconds.
Result using local variable: 0 in 1.663 seconds.
ИЗМЕНИТЬ
Это сгенерированная сборка для первых двух фрагментов с переключателем -O2, где наибольшая разница. Насколько я понимаю, это похоже на ошибку в компиляторе: 0x3b9aca00 является SIZE в шестнадцатеричном формате, 0x80496dc должен быть адресом глобального.
Я проверил с новым компилятором, и этого больше не происходит. Однако разница во второй паре фрагментов аналогична.
void global1()
{
int i;
global = 0;
for (i = 0; i < SIZE; i++)
global++;
}
void local1()
{
int i;
long tmp = 0;
for (i = 0; i < SIZE; i++)
tmp++;
global = tmp;
}
080483d0 <global1>:
80483d0: 55 push %ebp
80483d1: 89 e5 mov %esp,%ebp
80483d3: c7 05 dc 96 04 08 00 movl $0x0,0x80496dc
80483da: 00 00 00
80483dd: b8 ff c9 9a 3b mov $0x3b9ac9ff,%eax
80483e2: 89 f6 mov %esi,%esi
80483e4: 83 e8 19 sub $0x19,%eax
80483e7: 79 fb jns 80483e4 <global1+0x14>
80483e9: c7 05 dc 96 04 08 00 movl $0x3b9aca00,0x80496dc
80483f0: ca 9a 3b
80483f3: c9 leave
80483f4: c3 ret
80483f5: 8d 76 00 lea 0x0(%esi),%esi
080483f8 <local1>:
80483f8: 55 push %ebp
80483f9: 89 e5 mov %esp,%ebp
80483fb: b8 ff c9 9a 3b mov $0x3b9ac9ff,%eax
8048400: 48 dec %eax
8048401: 79 fd jns 8048400 <local1+0x8>
8048403: c7 05 dc 96 04 08 00 movl $0x3b9aca00,0x80496dc
804840a: ca 9a 3b
804840d: c9 leave
804840e: c3 ret
804840f: 90 nop
Наконец, вот код оставшихся фрагментов, теперь сгенерированный gcc 4.3.3 с использованием -O3 (хотя старая версия, похоже, генерирует аналогичный код). Похоже, что global2 (..) компилируется в функцию, обращающуюся к глобальной ячейке памяти на каждой итерации цикла, где local2 (..) использует регистр. Мне все еще не ясно, почему gcc не будет оптимизировать глобальную версию, используя регистр в любом случае. Это просто недостающая функция, или это действительно приведет к неприемлемому поведению исполняемого файла?
void global2(const char* start)
{
const char* p;
global = 0;
for (p = start; p < start + SIZE; p++)
global += *p;
}
void local2(const char* start)
{
const char* p;
long tmp = 0;
for (p = start; p < start + SIZE; p++)
tmp += *p;
global = tmp;
}
08048470 <global2>:
8048470: 55 push %ebp
8048471: 31 d2 xor %edx,%edx
8048473: 89 e5 mov %esp,%ebp
8048475: 8b 4d 08 mov 0x8(%ebp),%ecx
8048478: c7 05 24 a0 04 08 00 movl $0x0,0x804a024
804847f: 00 00 00
8048482: 8d b6 00 00 00 00 lea 0x0(%esi),%esi
8048488: 0f be 04 11 movsbl (%ecx,%edx,1),%eax
804848c: 83 c2 01 add $0x1,%edx
804848f: 01 05 24 a0 04 08 add %eax,0x804a024
8048495: 81 fa 00 ca 9a 3b cmp $0x3b9aca00,%edx
804849b: 75 eb jne 8048488 <global2+0x18>
804849d: 5d pop %ebp
804849e: c3 ret
804849f: 90 nop
080484a0 <local2>:
80484a0: 55 push %ebp
80484a1: 31 c9 xor %ecx,%ecx
80484a3: 89 e5 mov %esp,%ebp
80484a5: 31 d2 xor %edx,%edx
80484a7: 53 push %ebx
80484a8: 8b 5d 08 mov 0x8(%ebp),%ebx
80484ab: 90 nop
80484ac: 8d 74 26 00 lea 0x0(%esi,%eiz,1),%esi
80484b0: 0f be 04 13 movsbl (%ebx,%edx,1),%eax
80484b4: 83 c2 01 add $0x1,%edx
80484b7: 01 c1 add %eax,%ecx
80484b9: 81 fa 00 ca 9a 3b cmp $0x3b9aca00,%edx
80484bf: 75 ef jne 80484b0 <local2+0x10>
80484c1: 5b pop %ebx
80484c2: 89 0d 24 a0 04 08 mov %ecx,0x804a024
80484c8: 5d pop %ebp
80484c9: c3 ret
80484ca: 8d b6 00 00 00 00 lea 0x0(%esi),%esi
Спасибо.
Ответы
Ответ 1
Локальная переменная tmp
, адрес которой не занят, не может указываться указателем p
, и компилятор может оптимизировать соответственно. Гораздо сложнее сделать вывод о том, что глобальная переменная global
не указана, если она не была static
, поскольку адрес этой глобальной переменной можно было бы взять в другой блок компиляции и передать.
Если чтение сборки указывает на то, что компилятор заставляет себя загружать из памяти чаще, чем вы ожидали, и вы знаете, что сглаживание, о котором он беспокоится, не может существовать на практике, вы можете помочь ему, скопировав глобальную переменную в локальную переменная в верхней части функции и использование только локальной в остальной части функции.
Наконец, обратите внимание, что если указатель p
имел другой тип, компилятор мог бы вызывать "правила строгого сглаживания" для оптимизации независимо от его неспособности сделать вывод, что p
не указывает на global
. Но поскольку lvalues типа char
часто используются для наблюдения за представлением других типов, имеется учет такого псевдонима, и компилятор не может воспользоваться этим ярлыком в вашем примере.
Ответ 2
Глобальная переменная = глобальная память и подвержена сглаживанию (читайте как: плохо для оптимизатора - должна читать-модифицировать-писать в худшем случае).
Локальная переменная = регистр (если компилятор действительно не может это сделать, иногда она также должна помещаться в стек, но стек практически гарантированно находится в L1)
Доступ к регистру осуществляется по порядку одного цикла, доступ к памяти составляет порядка 15-1000 циклов (в зависимости от того, находится ли строка кэша в кеше и не является недействительной другим ядром, и в зависимости от того, в TLB).