Эффективная передача struct
по ссылке, даже если объявление функции указывает, что передача по значению - это обычная оптимизация: просто это обычно происходит косвенно через встраивание, поэтому это не очевидно из сгенерированного код.
Однако, чтобы это произошло, компилятор должен знать, что вызываемый не изменяет переданный объект во время компиляции вызывающего . В противном случае он будет ограничен платформой / языком ABI, который точно определяет, как значения передаются в функции.
Это может произойти даже без вставки!
Тем не менее, некоторые компиляторы делают реализуют эту оптимизацию даже при отсутствии встраивания, хотя обстоятельства относительно ограничены, по крайней мере на платформах, использующих SysV ABI (Linux, OSX и т. Д.) Из-за ограничений макета стека. Рассмотрим следующий простой пример, основанный непосредственно на вашем коде:
__attribute__((noinline))
int foo(S s) {
return s.i + s.j + s.k + s.l + s.m + s.n + s.o + s.p;
}
int bar(S s) {
return foo(s);
}
Здесь на уровне языка bar
вызывает foo
с семантикой передачи по значению в соответствии с требованиями C ++. Однако если мы рассмотрим сборку , сгенерированную gcc , она будет выглядеть следующим образом:
foo(S):
mov eax, DWORD PTR [rsp+12]
add eax, DWORD PTR [rsp+8]
add eax, DWORD PTR [rsp+16]
add eax, DWORD PTR [rsp+20]
add eax, DWORD PTR [rsp+24]
add eax, DWORD PTR [rsp+28]
add eax, DWORD PTR [rsp+32]
add eax, DWORD PTR [rsp+36]
ret
bar(S):
jmp foo(S)
Обратите внимание, что bar
просто вызывает foo
напрямую, без создания копии: bar
будет использовать ту же копию s
, которая была передана в bar
(в стеке). В частности, не делает никаких копий , как это подразумевается в семантике языка (игнорируя , как если бы ). Таким образом, gcc выполнил именно ту оптимизацию, которую вы запрашивали. Clang этого не делает: он создает копию в стеке, которую передает в foo()
.
К сожалению, случаи, когда это может работать, довольно ограничены: SysV требует, чтобы эти большие структуры передавались в стеке в определенной позиции, поэтому такое повторное использование возможно только в том случае, если вызываемый объект ожидает, что объект находится в том же месте.
Это возможно в примере foo/bar
, так как bar принимает его S
в качестве первого параметра таким же образом, как foo
, а bar
выполняет хвостовой вызов до foo
, который устраняет необходимость в неявном отправке адреса возврата, который в противном случае разрушил бы возможность повторного использования аргумента стека.
Например, если мы просто добавим + 1
к вызову foo
:
int bar(S s) {
return foo(s) + 1;
}
Трюк рухнул, так как теперь позиция bar::s
отличается от позиции foo
, ожидающей аргумента s
, и нам нужна копия:
bar(S):
push QWORD PTR [rsp+32]
push QWORD PTR [rsp+32]
push QWORD PTR [rsp+32]
push QWORD PTR [rsp+32]
call foo(S)
add rsp, 32
add eax, 1
ret
Это не значит, что абонент bar()
должен быть совершенно тривиальным. Например, он может изменить свою копию s, перед тем как передать ее:
int bar(S s) {
s.i += 1;
return foo(s);
}
... и оптимизация будет сохранена:
bar(S):
add DWORD PTR [rsp+8], 1
jmp foo(S)
В принципе, такая возможность для такого рода оптимизации значительно ограничена в соглашении о вызовах Win64, в котором для передачи больших структур используется скрытый указатель. Это дает гораздо большую гибкость в повторном использовании существующих структур в стеке или в другом месте для реализации передачи по ссылке под крышками.
Встраивание
Однако, кроме этого, main , как эта оптимизация происходит, через встраивание.
Например, при -O2
компиляции все clang, gcc и MSVC не делают ни одной копии объекта S 1 . И clang, и gcc на самом деле вообще не создают объект, а просто вычисляют результат более или менее напрямую, даже без ссылки на неиспользуемые поля. MSVC выделяет место в стеке для копии, но никогда не использует его: он заполняет только одну копию только S
и читает из нее, так же, как передача по ссылке (MSVC генерирует гораздо худший код, чем два других компилятора для этого случай).
Обратите внимание, что хотя foo
встроен в main
, компиляторы также генерируют отдельную автономную копию функции foo()
, поскольку она имеет внешнюю связь и может использоваться в этом объектном файле. , При этом компилятор ограничен двоичным интерфейсом приложения : ABI SysV (для Linux) или ABI Win64 (для Windows) точно определяет, как должны передаваться значения, в зависимости от типа и размера значения , Большие структуры передаются скрытым указателем, и компилятор должен учитывать это при компиляции foo
. Также необходимо учитывать компиляцию некоторого вызывающего объекта foo
, когда foo невозможно увидеть: поскольку он не знает, что будет делать foo
.
Таким образом, у компилятора очень мало времени для эффективной оптимизации, которая преобразует передачу по значению в передачу по ссылке, потому что:
1) Если он может видеть и звонящего и вызываемого абонента (main
и foo
в вашем примере), вполне вероятно, что вызываемый абонент будет встроен в вызывающего абонента, если он достаточно мал и когда функция становится большой и не встраиваемой, эффект таких вещей с фиксированной стоимостью, как издержки соглашения о вызовах, становится относительно меньшим.
2) Если компилятор не может видеть и вызывающего, и вызываемого абонента одновременно 2 , он обычно должен компилировать каждый в соответствии с ABI платформы. Нет возможности для оптимизации вызова на сайте вызова, так как компилятор не знает, что будет делать вызываемый, и нет возможности для оптимизации внутри вызываемого, потому что компилятор должен делать консервативные предположения о том, что сделал вызывающий.
1 Мой пример немного сложнее, чем ваш исходный, чтобы компилятор не оптимизировал все целиком (в частности, вы обращаетесь к неинициализированной памяти, поэтому ваша программа даже не имеет определенного поведения): Я заполняю некоторые поля s
argc
, это значение, которое компилятор не может предсказать.
2 Компилятор может видеть оба «одновременно», как правило, это означает, что они либо находятся в одной и той же единице перевода, либо используется оптимизация времени соединения.