Что мешает использовать аргумент функции в качестве скрытого указателя?

Я пытаюсь понять смысл System V AMD64 - ABI соглашениео вызовах и смотрю на следующий пример:

struct Vec3{
    double x, y, z;
};

struct Vec3 do_something(void);

void use(struct Vec3 * out){
    *out = do_something();
}

A Vec3 -variable имеет тип MEMORY, и, следовательно, вызывающая сторона (use) должна выделить место для возвращаемой переменной и передать его как скрытый указатель на вызываемый объект (т.е. do_something). Вот что мы видим в получающемся ассемблере (на Godbolt, скомпилированном с -O2):

use:
        pushq   %rbx
        movq    %rdi, %rbx           ;remember out
        subq    $32, %rsp            ;memory for returned object
        movq    %rsp, %rdi           ;hidden pointer to %rdi
        call    do_something
        movdqu  (%rsp), %xmm0        ;copy memory to out
        movq    16(%rsp), %rax
        movups  %xmm0, (%rbx)
        movq    %rax, 16(%rbx)
        addq    $32, %rsp            ;unwind/restore
        popq    %rbx
        ret

Я понимаю, что псевдоним указателя out (например, как глобальная переменная) может использоваться в do_something, и поэтому out не может быть передан как скрытый указатель на do_something: если это так, out будет изменено внутри do_something, а не когда do_something вернется, поэтому некоторые вычисления могут стать ошибочными. Например, эта версия do_something будет возвращать ошибочные результаты:

struct Vec3 global; //initialized somewhere
struct Vec3 do_something(void){
   struct Vec3 res;
   res.x = 2*global.x; 
   res.y = global.y+global.x; 
   res.z = 0; 
   return res;
}

если out где псевдоним для глобальной переменной global и использовался как скрытый указатель, переданный в %rdi, res также был псевдонимом global, потому что компилятор будет использовать память, указанную для непосредственно скрытым указателем (своего рода RVO в C), без фактического создания временного объекта и копирования его при возврате, тогда res.y будет 2*x+y (если x,y - старые значения global), а не x+y как и для любого другого скрытого указателя.

Мне было предложено, что использование restrict должно решить проблему, т.е.

void use(struct Vec3 *restrict out){
    *out = do_something();
}

потому что теперь компилятор знает, что в do_something нет псевдонимов out, поэтому ассемблер может быть таким простым:

use:
    jmp     do_something ; %rdi is now the hidden pointer

Однако это не относится ни к gcc, ни к clang - ассемблер остается неизменным (см. godbolt).

Что мешает использовать out в качестве скрытого указателя?


Примечание: желаемое (или очень похожее) поведение будет достигнуто для слегка отличающейся сигнатуры функции:

struct Vec3 use_v2(){
    return do_something();
}

что приводит к (см. крестник):

use_v2:
    pushq   %r12
    movq    %rdi, %r12
    call    do_something
    movq    %r12, %rax
    popq    %r12
    ret

Ответ 1

Функция может предполагать, что ее объект возвращаемого значения (на который указывает скрытый указатель) не является тем же объектом, что и все остальное. то есть, что его выходной указатель (переданный как скрытый первый аргумент) ничего не псевдоним.

Вы можете думать об этом как о скрытом первом выходном указателе arg с неявным restrict на нем. (Поскольку в абстрактной машине C возвращаемое значение является отдельным объектом, а x86-64 System V указывает, что вызывающая сторона предоставляет пространство. x86-64 SysV не предоставляет лицензии вызывающей стороне для введения псевдонимов. )

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

Я думаю, что соглашение о вызовах SysV x86-64 моделирует здесь абстрактную машину C, позволяя вызывающей стороне предоставлять объект реального возвращаемого значения, не заставляя вызывающего изобретать этот временный объект, если это необходимо, чтобы убедиться, что все записи в запрос происходят после любых других пишет. Это не то, что означает "вызывающая сторона предоставляет место для возвращаемого значения", IMO.

Это определенно то, как GCC и другие компиляторы интерпретируют это на практике, что является большой частью того, что имеет значение в соглашении о вызовах, которое существовало так давно (с года или двух до появления первого AMD64-процессора, так что в самом начале 2000-х).


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

struct Vec3{
    double x, y, z;
};
struct Vec3 glob3;

__attribute__((noinline))
struct Vec3 do_something(void) {  // copy glob3 to retval in some order
    return (struct Vec3){glob3.y, glob3.z, glob3.x};
}

__attribute__((noinline))
void use(struct Vec3 * out){   // copy do_something() result to *out
    *out = do_something();
}


void caller(void) {
    use(&glob3);
}

С предложенной оптимизацией выходным объектом do_something будет glob3. Но это также гласит glob3.

Допустимой реализацией для do_something было бы копирование элементов из glob3 в (%rdi) в исходном порядке, что сделало бы glob3.x = glob3.y перед чтением glob3.x в качестве 3-го элемента возвращаемого значения.

Это именно то, что делает gcc -O1 (проводник компилятора Godbolt)

do_something:
    movq    %rdi, %rax               # tmp90, .result_ptr
    movsd   glob3+8(%rip), %xmm0      # glob3.y, glob3.y
    movsd   %xmm0, (%rdi)             # glob3.y, <retval>.x
    movsd   glob3+16(%rip), %xmm0     # glob3.z, _2
    movsd   %xmm0, 8(%rdi)            # _2, <retval>.y
    movsd   glob3(%rip), %xmm0        # glob3.x, _3
    movsd   %xmm0, 16(%rdi)           # _3, <retval>.z
    ret     

Обратите внимание на хранилище glob3.y, <retval>.x перед загрузкой glob3.x.

Таким образом, без restrict где-либо в источнике, GCC уже генерирует asm для do_something, который не предполагает наложения псевдонима между retval и glob3.


Я не думаю, что использование struct Vec3 *restrict out не поможет вообще: это только говорит компилятору, что внутри use() вы не получите доступ к объекту *out через любое другое имя. Поскольку use() не ссылается на glob3, он не должен передавать &glob3 в качестве аргумента версии restrict use.

Я могу ошибаться здесь; @MM в комментариях утверждает, что *restrict out может сделать эту оптимизацию безопасной, потому что выполнение do_something() происходит во время out(). (Компиляторы все еще на самом деле этого не делают, но, возможно, им будет разрешено использовать указатели restrict.)

Обновление: Ричард Бинер сказал в отчете об ошибках пропущенной оптимизации в GCC, что MM верен, и если компилятор может доказать, что функция возвращает нормально (не исключение или longjmp), оптимизация допустима в теории (но GCC, скорее всего, не ищет):

  Если это так, ограничение сделает эту оптимизацию безопасной, если мы можем доказать, что   do_something - это "noexcept" и не longjmp.

Yes.

Есть объявление noexecpt, но нет (AFAIK) объявления nolongjmp, которое вы можете поместить в прототип.

Таким образом, это означает, что это возможно (даже теоретически) в качестве межпроцедурной оптимизации, когда мы можем видеть другое тело функции. Если noexcept также не означает нет longjmp.

Ответ 2

Существенно переписано:

Я понимаю, что псевдоним указателя out (например, как глобальная переменная) может использоваться в do_something и, следовательно, [out] не может быть передан как скрытый указатель на do_something: в противном случае out будет изменен внутри do_something, а не когда do_something возвращается, таким образом некоторые вычисления могут стать ошибочными.

За исключением случаев наложения псевдонимов внутри do_something(), разница во времени относительно изменения *out не имеет значения в том смысле, что вызывающий use() не может определить разницу. Такие проблемы возникают только в отношении доступа из других потоков, и, если это возможно, они возникают в любом случае, если не применяется соответствующая синхронизация.

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

Если тип имеет класс MEMORY, то вызывающая сторона предоставляет пространство для возврата значение и передает адрес этого хранилища в %rdi

(выделение добавлено).

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

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

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

Мне предложили, что использование restrict должно решить проблему,

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

Переводчик может свободно игнорировать любые или все возможные последствия использования restrict.

(C2011, 6.7.3.1/6)

restrict -qualifying out выражает, что компилятору не нужно беспокоиться о том, что он имеет псевдоним для любого другого указателя, к которому обращаются в рамках вызова use(), включая во время выполнения функций другие функции, которые он вызывает. Таким образом, в принципе, я мог видеть, как компилятор воспользовался этим для сокращения ABI, предлагая кому-то еще пространство для возвращаемого значения вместо предоставления самого пространства, но только то, что это могло бы быть, не означает, что это будет делать.

Что мешает использовать out в качестве скрытого указателя?

Соответствие ABI. Ожидается, что вызывающая сторона предоставит место, которое принадлежит ему, а не кому-либо еще, для хранения возвращаемого значения. На практике, однако, я не вижу в случае restrict -qualified ничего такого, что могло бы сделать недействительным сокращение ярлыка ABI, поэтому я считаю, что это просто не оптимизация, реализованная рассматриваемым компилятором.

Примечание: желаемое (или очень похожее) поведение будет достигнуто для слегка отличающейся сигнатуры функции: [...]

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

Ответ 3

Ответы @JohnBollinger и @PeterCordes многое прояснили для меня, но я решил ошибка gcc-developers. Вот как я понимаю их ответ.

Как указал @PeterCordes, вызываемый объект предполагает, что скрытый указатель ограничен. Однако это делает и другое (менее очевидное) предположение: память, на которую указывает скрытый указатель, неинициализирована.

Почему это важно, вероятно, проще увидеть с помощью примера C++:

struct Vec3 do_something(void){
   struct Vec3 res;
   res.x = 0.0; 
   res.y = func_which_throws(); 
   res.z = 0.0; 
   return res;
}

do_something пишет непосредственно в память, указанную %rdi (как показано в нескольких списках в этом разделе Q & A), и это разрешено делать только потому, что эта память неинициализирована: если выбрасывает func_which_throws() и где-то перехватывается исключение, то никто не узнает, что мы изменили только x-компонент результата, потому что никто не знает, какое исходное значение оно имело до передачи в do_something (никто не мог прочитал исходное значение, потому что это будет UB).

Вышеприведенное будет нарушено для передачи out -pointer в качестве скрытого указателя, поскольку можно заметить, что только часть, а не вся память была изменена в случае возникновения и перехвата исключения.

Теперь в C есть что-то похожее на исключения C++: setjmp и longjmp. Никогда не слышал о них раньше, но, похоже, по сравнению с C++ -пример setjmp лучше всего описать как try ... catch ... и longjmp как throw.

Это означает, что также для C мы должны гарантировать, что пространство, предоставленное вызывающим абонентом, не инициализировано.

Даже без setjmp/longjmp есть и другие проблемы, в том числе: совместимость с C++ -кодом, который имеет исключения, и опция -fexceptions gcc-компилятора.


Следствие: желаемая оптимизация была бы возможна, если бы у нас был квалификатор для унифицированной памяти (которой у нас нет), например uninit, затем

void use(struct Vec3 *restrict uninit out);

сделает свое дело.