Что такое псевдонимы и как это влияет на производительность?
В событии GoingNative во время Интерактивная панель в День2 с отметкой в 9 минут Чандлер Каррут говорит:
Указатели создают проблемы с псевдонимом. Они замедляют ваши двоичные файлы, а не ускоряют их.
Что это значит? Можно ли это проиллюстрировать (простым) примером?
Ответы
Ответ 1
Алиасинг влияет на производительность, не позволяя компилятору выполнять определенные оптимизации. Например:
void foo(int *array,int *size,int *value) {
for(int i=0;i<*size;++i) {
array[i] = 2 * *value;
}
}
Глядя на этот код, вы можете ожидать, что компилятор может загрузить *value
за пределы цикла, а затем быстро установить каждый элемент в массиве. Но это не так из-за псевдонимов. Поскольку *value
может быть псевдонимом для элемента массива, он может меняться на любой заданной итерации. Поэтому код должен загружать значение каждой отдельной итерации, что приводит к потенциально большому замедлению.
Если переменные не могли быть псевдонимом, тогда приведенный выше код будет эквивалентен следующему:
void foo(int *array,int size,int value) {
for(int i=0;i<size;++i) {
array[i] = 2 * value;
}
}
Используя LLVM онлайн-демонстрацию, чтобы получить сгенерированный код, вот разные результаты:
1) С псевдонимом
foo: # @foo
.cfi_startproc
# BB#0:
cmpl $0, (%rsi)
jle .LBB0_3
# BB#1:
xorl %eax, %eax
.align 16, 0x90
.LBB0_2: # %.lr.ph
# =>This Inner Loop Header: Depth=1
movl (%rdx), %ecx
addl %ecx, %ecx
movl %ecx, (%rdi,%rax,4)
incq %rax
cmpl (%rsi), %eax
jl .LBB0_2
.LBB0_3: # %._crit_edge
ret
.size foo, .Ltmp1-foo
.cfi_endproc
.Leh_func_end0:
2) Без сглаживания
foo: # @foo
.cfi_startproc
# BB#0:
testl %esi, %esi
jle .LBB0_3
# BB#1: # %.lr.ph
addl %edx, %edx
.align 16, 0x90
.LBB0_2: # =>This Inner Loop Header: Depth=1
movl %edx, (%rdi)
addq $4, %rdi
decl %esi
jne .LBB0_2
.LBB0_3: # %._crit_edge
ret
.size foo, .Ltmp1-foo
.cfi_endproc
.Leh_func_end0:
Вы можете видеть, что версия с псевдонимом должна делать больше работы в теле цикла (между метками LBB0_2
и LBB0_3
).
Ответ 2
Тип проблемы, о которой говорил Чендлер, можно легко проиллюстрировать упрощенным strcpy
:
char *stpcpy (char * dest, const char * src);
При написании этой реализации вы можете предположить, что память, на которую указывает dest
, полностью отделена от памяти, на которую указывает src
. Компилятор), возможно, захочет оптимизировать его, прочитав блок символов из строки, на которую указывает src
, и сразу все записи в dest
. Но если dest
указал на один байт впереди src
, поведение этого будет отличаться от простой посимвольной копии.
Здесь проблема сглаживания заключается в том, что src
может иметь псевдоним dest
, а сгенерированный код должен быть менее эффективным, чем мог бы быть, если src
не было разрешено псевдоним dest
.
В реальном strcpy
используется дополнительное ключевое слово Restrict (которое технически только часть C, а не С++, которая сообщает компилятору предположить, что src
и dest
не перекрываются, и это позволяет компилятору генерировать гораздо более эффективный код.
Вот еще более простой пример, где мы видим большую разницу в сборке:
void my_function_1(int* a, int* b, int* c) {
if (*a) *b = *a;
if (*a) *c = *a;
}
void my_function_2(int* __restrict a, int* __restrict b, int* __restrict c) {
if (*a) *b = *a;
if (*a) *c = *a;
}
Предположим, что это упрощение функции, где на самом деле имеет смысл использовать два оператора if, а не только if (*a) { *b=*a; *c=*a; }
, но намерение одинаков.
Мы можем предположить при написании этого, что a != b
, потому что есть некоторая причина, почему это не имеет смысла для my_function
. Но компилятор не может этого допустить и хранить в памяти b
и перезагрузку a
из памяти перед выполнением второй строки, чтобы охватить случай, когда b == a
:
0000000000400550 <my_function_1>:
400550: 8b 07 mov (%rdi),%eax
400552: 85 c0 test %eax,%eax <= if (*a)
400554: 74 0a je 400560 <my_function_1+0x10>
400556: 89 06 mov %eax,(%rsi)
400558: 8b 07 mov (%rdi),%eax
40055a: 85 c0 test %eax,%eax <= if (*a)
40055c: 74 02 je 400560 <my_function_1+0x10>
40055e: 89 02 mov %eax,(%rdx)
400560: f3 c3 repz retq
Если мы удалим потенциал для сглаживания, добавив __restrict
, компилятор генерирует более короткий и быстрый код:
0000000000400570 <my_function_2>:
400570: 8b 07 mov (%rdi),%eax
400572: 85 c0 test %eax,%eax
400574: 74 04 je 40057a <_Z9my_function_2PiS_S_+0xa>
400576: 89 06 mov %eax,(%rsi)
400578: 89 02 mov %eax,(%rdx)
40057a: f3 c3 repz retq
Ответ 3
Рассмотрим следующую функцию:
void f(float* lhs, float* rhs, float* out, int size) {
for(int i = 0; i < size; i++) {
out[i] = *lhs + *rhs;
}
}
Какая самая быстрая версия этой функции? Вероятно, вы вытащили *lhs + *rhs
из цикла. Проблема заключается в том, что происходит, когда псевдонимы указателей. Представьте себе, что делает эта оптимизация, если я называю это следующим образом:
float arr[6] = { ... };
f(arr, arr + 1, arr, 6);
Как вы можете видеть, проблема в том, что *lhs + *rhs
нельзя выровнять из цикла, потому что out[i]
изменяет свои значения. Фактически, компилятор не может вытащить любую логику из цикла. Таким образом, компилятор не может выполнить "очевидную" оптимизацию, потому что если параметры псевдонимы логики теперь неверны. Однако, если поплавки берутся по значению, компилятор знает, что они не могут использовать псевдоним и могут выполнять подъем.
Конечно, эта функция довольно глупа, но она демонстрирует точку.
Ответ 4
указатель - это значение, которое представляет адрес памяти, иногда два указателя могут представлять один и тот же адрес памяти, в котором используется псевдонимы
int * p;
*p = 5;
int * alias;
alias = p;
переменная alias
является псевдонимом p и *alias
равна 5, если вы меняете *alias
, а затем *p
изменяется вместе с ней