Почему передача структуры по ссылке не является обычной оптимизацией? - PullRequest
24 голосов
/ 16 февраля 2009

До сегодняшнего дня я всегда думал, что приличные компиляторы автоматически преобразуют структурную передачу по значению в передачу по ссылке, если структура достаточно велика, чтобы последняя была быстрее. Насколько я знаю, это кажется легкой задачей. Однако, чтобы удовлетворить мое любопытство относительно того, происходит ли это на самом деле, я создал простой тестовый пример на C ++ и D и посмотрел на выходные данные как GCC, так и Digital Mars D. Оба настаивали на передаче 32-байтовых данных. структуры по значению, когда вся рассматриваемая функция складывала члены и возвращала значения без изменения переданной структуры. Версия C ++ приведена ниже.

#include "iostream.h"

struct S {
    int i, j, k, l, m, n, o, p;
};

int foo(S s) {
    return s.i + s.j + s.k + s.l + s.m + s.n + s.o + s.p;
}

int main() {
    S s;
    int bar = foo(s);
    cout << bar;
}

У меня такой вопрос: почему, черт возьми, компилятор не может оптимизировать что-то подобное для передачи по ссылке вместо того, чтобы фактически помещать все эти int в стек?

Примечание: Используемые переключатели компилятора: GCC -O2 (-O3 встроенный foo ().), DMD -O -inline -релиз.

Редактировать: Очевидно, что в общем случае семантика передачи по значению и передачи по ссылке не будет одинаковой, например, если задействованы конструкторы копирования или исходная структура изменяется в вызываемом объекте. Однако во многих реальных сценариях семантика будет идентичной с точки зрения наблюдаемого поведения. Это те случаи, о которых я спрашиваю.

Ответы [ 12 ]

23 голосов
/ 16 февраля 2009

Не забывайте, что в C / C ++ компилятор должен иметь возможность компилировать вызов функции, основываясь только на объявлении функции.

Учитывая, что вызывающие могут использовать только эту информацию, компилятор не может скомпилировать функцию, чтобы воспользоваться преимуществами той оптимизации, о которой вы говорите. Вызывающий не может знать, что функция ничего не изменит, и поэтому не может пройти мимо ref. Так как некоторые вызывающие абоненты могут передавать по значению из-за отсутствия подробной информации, функция должна быть скомпилирована с учетом передачи по значению, и каждый должен передавать по значению.

Обратите внимание, что даже если вы пометили параметр как 'const', компилятор все равно не сможет выполнить оптимизацию, потому что функция может лежать и отбрасывать постоянство (это разрешено и четко определено, если передаваемый объект на самом деле не является константой).

Я думаю, что для статических функций (или для тех, которые находятся в анонимном пространстве имен), компилятор может выполнить оптимизацию, о которой вы говорите, поскольку функция не имеет внешней связи. Пока адрес функции не передается какой-либо другой подпрограмме или хранится в указателе, он не должен вызываться из другого кода. В этом случае компилятор может полностью знать все вызывающие объекты, поэтому я полагаю, что он может выполнить оптимизацию.

Я не уверен, что кто-нибудь это сделает (на самом деле, я бы удивился, если бы кто-нибудь это сделал, поскольку, вероятно, его нельзя было применять очень часто).

Конечно, как программист (при использовании C ++) вы можете заставить компилятор выполнять эту оптимизацию, используя параметры const&, когда это возможно. Я знаю, вы спрашиваете, почему компилятор не может сделать это автоматически, но я полагаю, что это следующая лучшая вещь.

10 голосов
/ 16 февраля 2009

Проблема в том, что вы просите компилятор принять решение о намерении использования пользовательского кода. Может быть, я хочу, чтобы моя сверхбольшая структура передавалась по значению, чтобы я мог что-то сделать в конструкторе копирования. Поверьте, у кого-то есть что-то, что ему действительно нужно вызывать в конструкторе копирования именно для такого сценария. При переключении на ref обходится конструктор копирования.

Принятие решения компилятором было бы плохой идеей. Причина в том, что это делает невозможным рассуждать о потоке вашего кода. Вы не можете посмотреть на звонок и знать, что именно он будет делать. Вы должны а) знать код и б) угадать оптимизацию компилятора.

10 голосов
/ 16 февраля 2009

Один из ответов заключается в том, что компилятор должен обнаружить, что вызываемый метод не изменяет содержимое структуры каким-либо образом. Если это так, то эффект передачи по ссылке будет отличаться от эффекта передачи по значению.

4 голосов
/ 16 февраля 2009

Это правда, что компиляторы в некоторых языках могут делать это, если у них есть доступ к вызываемой функции и если они могут предполагать, что вызываемая функция не изменится. Это иногда называют глобальной оптимизацией, и кажется вероятным, что некоторые компиляторы C или C ++ фактически оптимизируют такие случаи, как этот, - более вероятно, путем включения кода для такой тривиальной функции.

3 голосов
/ 16 февраля 2009

Я думаю, что это определенно оптимизация, которую вы могли бы реализовать (при некоторых предположениях, см. Последний абзац), но мне не ясно, что это будет выгодно. Вместо того, чтобы помещать аргументы в стек (или передавать их через регистры, в зависимости от соглашения о вызовах), вы бы помещали указатель, через который вы читали бы значения. Эта дополнительная косвенность будет стоить циклов. Также потребуется, чтобы переданный аргумент находился в памяти (чтобы вы могли указывать на него), а не в регистрах. Было бы полезно, если бы передаваемые записи имели много полей, а функция, получающая запись, считывала только некоторые из них. Дополнительные циклы, потраченные на косвенное обращение, должны компенсировать циклы, не потраченные впустую, путем нажатия ненужных полей.

Вы можете быть удивлены, что обратная оптимизация, продвижение аргументов , фактически реализована в LLVM. Это преобразует ссылочный аргумент в аргумент значения (или агрегат в скаляры) для внутренних функций с небольшим количеством полей, которые считываются только из. Это особенно полезно для языков, которые передают почти все по ссылке. Если вы выполните это с устранением мертвых аргументов , вам также не придется пропускать поля, которые не были затронуты.

Следует упомянуть, что оптимизации, которые изменяют способ вызова функции, могут работать только тогда, когда оптимизируемая функция является внутренней по отношению к компилируемому модулю (это можно получить, объявив функцию static в C и с шаблонами в C ++) , Оптимизатор должен исправить не только функцию, но и все точки вызова. Это делает такие оптимизации довольно ограниченными по объему, если вы не делаете их во время ссылки. Кроме того, оптимизация никогда не будет вызвана, когда задействован конструктор копирования (как упоминали другие авторы), потому что она может потенциально изменить семантику программы, чего никогда не должен делать хороший оптимизатор.

2 голосов
/ 16 февраля 2009

Передача по ссылке - просто синтаксический сахар для передачи по адресу / указателю. Таким образом, функция должна неявно разыменовать указатель, чтобы прочитать значение параметра. Разыменование указателя может быть более дорогим (если в цикле), чем копирование структуры для копирования по значению.

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

2 голосов
/ 16 февраля 2009

Изменение значения по ссылке на ссылку изменит сигнатуру функции. Если функция не является статической, это приведет к ошибкам компоновки для других модулей компиляции, которые не знают о проведенной вами оптимизации.
Действительно, единственный способ сделать такую ​​оптимизацию - это какая-то фаза пост-линковой глобальной оптимизации. Общеизвестно, что это трудно сделать, но некоторые компиляторы делают это до некоторой степени.

2 голосов
/ 16 февраля 2009

Есть много причин для перехода по значению, и компилятор оптимизирует ваши намерения, что может нарушить ваш код.

Пример, если вызываемая функция каким-либо образом изменяет структуру. Если вы предполагаете, что результаты будут переданы обратно вызывающей стороне, вы либо передадите указатель / ссылку, либо вернете их самостоятельно.

Вы просите компилятор изменить поведение вашего кода, что будет считаться ошибкой компилятора.

Если вы хотите провести оптимизацию и перейти по ссылке, то непременно измените чьи-либо определения функций / методов, чтобы они принимали ссылки; это не так сложно сделать. Вы можете быть удивлены поломкой, которую вызываете, не осознавая этого.

1 голос
/ 20 июля 2018

Эффективная передача 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 Компилятор может видеть оба «одновременно», как правило, это означает, что они либо находятся в одной и той же единице перевода, либо используется оптимизация времени соединения.

1 голос
/ 19 июля 2018

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

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

struct FOO { ... };

void func1(struct FOO *foo1);
void func2(struct FOO foo2);

void test(void)
{
  struct FOO foo;
  func1(&foo);
  func2(foo);
}

нет способа, которым компилятор мог бы знать, может ли foo быть изменен во время выполнения func2 (func1 мог сохранить копию foo1 или указатель, полученный из него, в объекте области файла который затем используется func2). Однако такие модификации не должны влиять на копию foo (т.е. foo2), полученную func2. Если foo были переданы по ссылке и func2 не сделал копию, действия, которые влияют на foo, будут неправильно влиять на foo2.

Обратите внимание, что даже void func3(const struct FOO); не имеет смысла: вызываемому разрешено отбрасывать const, а обычное соглашение о вызовах asm все еще позволяет вызываемому пользователю изменять память, содержащую копию по значению.

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


Сноска 1: Например, Windows x64 передает объекты размером более 8 байт по неконстантной ссылке (вызываемый «владеет» указанной памятью). Это не поможет избежать копирования вообще; мотивация состоит в том, чтобы все аргументы функций помещались в 8 байтов каждый, чтобы они образовывали массив в стеке (после разлива регистровых аргументов в пространство теней), что облегчает реализацию функций с переменными числами.

В отличие от этого, x86-64 System V делает то, что описывает вопрос для объектов размером более 16 байт: копирует их в стек. (Меньшие объекты упакованы в два регистра.)

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...