Почему gcc разрешено спекулятивно загружать из структуры?

Пример Отображение gcc Оптимизация и код пользователя, который может быть неисправен

Функция 'foo' в нижеприведенном фрагменте будет загружать только один из элементов структуры A или B; ну, по крайней мере, это намерение неоптимизированного кода.

typedef struct {
  int A;
  int B;
} Pair;

int foo(const Pair *P, int c) {
  int x;
  if (c)
    x = P->A;
  else
    x = P->B;
  return c/102 + x;
}

Вот что gcc -O3 дает:

mov eax, esi
mov edx, -1600085855
test esi, esi
mov ecx, DWORD PTR [rdi+4]   <-- ***load P->B**
cmovne ecx, DWORD PTR [rdi]  <-- ***load P->A***
imul edx
lea eax, [rdx+rsi]
sar esi, 31
sar eax, 6
sub eax, esi
add eax, ecx
ret

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

#include <stdlib.h>  

int naughty_caller(int c) {
  Pair *P = (Pair*)malloc(sizeof(Pair)-1); // *** Allocation is enough for A but not for B ***
  if (!P) return -1;

  P->A = 0x42; // *** Initializing allocation only where it is guaranteed to be allocated ***

  int res = foo(P, 1); // *** Passing c=1 to foo should ensure only P->A is accessed? ***

  free(P);
  return res;
}

Если предположение загрузки произойдет в описанном выше сценарии, вероятность того, что загрузка P- > B вызовет исключение, потому что последний байт P- > B может лежать в нераспределенной памяти. Это исключение не произойдет, если оптимизация отключена.

Вопрос

Является ли gcc-оптимизация, показанная выше, правильной? Где спецификация говорит или подразумевает, что это нормально? Если оптимизация законна, как код в 'naughtly_caller' оказывается undefined поведение?

Ответ 1

Чтение переменной (которая не была объявлена ​​как volatile) не считается "побочным эффектом", как указано в стандарте C. Таким образом, программа может свободно читать местоположение, а затем отбрасывать результат в соответствии со стандартом C.

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

Формально это можно найти в поведении "абстрактной машины", C11, глава 5.1.2.3. Учитывая, что компилятор соответствует указанным там правилам, он может делать все, что угодно. И единственные перечисленные правила касаются объектов volatile и последовательности инструкций. Чтение другого члена структуры в структуре volatile не будет нормально.

Что касается случая выделения слишком малой памяти для всей структуры, это поведение undefined. Поскольку макет памяти структуры обычно не предназначен для программиста - например, компилятору разрешено добавлять дополнение в конце. Если памяти недостаточно, вы можете получить доступ к запрещенной памяти, даже если ваш код работает только с первым членом структуры.

Ответ 2

Нет, если *P выделено правильно P->B никогда не будет находиться в нераспределенной памяти. Это может быть не инициализировано, вот и все.

Компилятор имеет полное право делать то, что он делает. Единственное, что не разрешено, - это oops о доступе P->B с оправданием того, что он не инициализирован. Но то, что и как они делают все это, зависит от реализации, а не вашей заботы.

Если вы нарисуете указатель на блок, возвращаемый malloc на Pair*, который не может быть достаточно широким, чтобы удерживать Pair, ваша программа будет undefined.

Ответ 3

Это совершенно законно, потому что чтение некоторого места памяти не считается наблюдаемым поведением в общем случае (volatile изменит это).

Ваш примерный код действительно работает undefined, но я не могу найти какой-либо отрывок в стандартных документах, которые явно указывают это. Но я думаю, что этого достаточно, чтобы взглянуть на правила для эффективных типов... из N1570, §6.5 p6:

Если значение хранится в объекте, не имеющем объявленного типа, через lvalue, имеющий тип, который не является типом символа, тогда тип lvalue становится эффективный тип объекта для этого доступа и для последующих доступов, которые не изменяются сохраненное значение.

Итак, ваш доступ на запись к *P фактически дает этому объекту тип Pair - поэтому он просто распространяется в память, которую вы не выделяли, результатом является доступ за пределы.

Ответ 4

Постфиксное выражение, за которым следует оператор ->, и идентификатор обозначает член структуры или объект объединения. Значение - это имя именованного элемента объекта, к которому первое выражение указывает

Если вызов выражения P->A корректно определен, то P должен фактически указывать на объект типа struct Pair, и, следовательно, P->B также хорошо определен.

Ответ 5

Оператор

A -> на a Pair * подразумевает, что существует полностью выделенный объект Pair. (@Hurkyl цитирует стандарт.)

x86 (как и любая нормальная архитектура) не имеет побочных эффектов для доступа к нормальной выделенной памяти, поэтому семантика x86-памяти совместима с семантикой абстрактной машины C для памяти <volatile. Компиляторы могут спекулятивно загружать, если/когда они думают, что это будет победа в производительности на целевой микроархитектуре, в которой они настраиваются в любой заданной ситуации.

Обратите внимание, что защита x86-памяти работает со степенью детализации страницы. Компилятор может развернуть цикл или векторизовать с помощью SIMD способом, который читает вне объекта, если все затронутые страницы содержат некоторые байты объекта. Можно ли прочитать конец буфера на одной странице на x86 и x64?. libc strlen(), написанные в сборке, делают это, но AFAIK gcc не использует вместо этого скалярные циклы для оставшихся элементов в конце автоиндексированного цикла, даже если он уже выравнивает указатели с (полностью развернутым) цикл запуска. (Возможно, потому, что это затрудняет проверку времени выполнения с помощью valgrind).


Чтобы получить ожидаемое поведение, используйте const int * arg.

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

В C вы можете передать этой функции указатель на один int, если c отличен от нуля. При компиляции для x86 gcc должен предположить, что он может указывать на последний int на странице, при этом следующая страница не отображается.

Источник + gcc и вывод clang для этого и других вариантов в проводнике компилятора Godbolt

// exactly equivalent to  const int p[2]
int load_pointer(const int *p, int c) {
  int x;
  if (c)
    x = p[0];
  else
    x = p[1];  // gcc missed optimization: still does an add with c known to be zero
  return c + x;
}

load_pointer:    # gcc7.2 -O3
    test    esi, esi
    jne     .L9
    mov     eax, DWORD PTR [rdi+4]
    add     eax, esi         # missed optimization: esi=0 here so this is a no-op
    ret
.L9:
    mov     eax, DWORD PTR [rdi]
    add     eax, esi
    ret

В C, вы можете передать сортировку объекта массива (по ссылке) на функцию, гарантируя функции, которая позволяла касаться всей памяти, даже если абстрактная машина C не имеет значения, т. Синтаксис int p[static 2]

int load_array(const int p[static 2], int c) {
  ... // same body
}

Но gcc не использует преимущества и испускает идентичный код для load_pointer.


Отключить тему: clang компилирует все версии (struct и array) одинаково, используя cmov для безраспределенного вычисления адреса загрузки.

    lea     rax, [rdi + 4]
    test    esi, esi
    cmovne  rax, rdi
    add     esi, dword ptr [rax]
    mov     eax, esi            # missed optimization: mov on the critical path
    ret

Это не обязательно хорошо: он имеет более высокую задержку, чем код структуры gcc, потому что адрес загрузки зависит от пары дополнительных ALU uops. Это очень хорошо, если оба адреса небезопасны для чтения, а ветвь будет плохо прогнозировать.

Мы можем получить лучший код для той же стратегии из gcc и clang, используя setcc (1 uop с задержкой 1 c на всех процессорах, за исключением некоторых действительно древних) вместо cmovcc (2 uops на Intel перед Skylake), xor -zeroing всегда дешевле, чем LEA.

int load_pointer_v3(const int *p, int c) {
  int offset = (c==0);
  int x = p[offset];
  return c + x;
}

    xor     eax, eax
    test    esi, esi
    sete    al
    add     esi, dword ptr [rdi + 4*rax]
    mov     eax, esi
    ret

gcc и clang оба помещают окончательный mov на критический путь. И в семействе Intel Sandybridge режим индексированной адресации не остается микроконфигурированным с помощью add. Таким образом, это было бы лучше, например, что он делает в ветвящейся версии:

    xor     eax, eax
    test    esi, esi
    sete    al
    mov     eax, dword ptr [rdi + 4*rax]
    add     eax, esi
    ret

Простые режимы адресации, такие как [rdi] или [rdi+4], имеют меньшую задержку на 1 с, чем другие, на процессорах Intel SnB-семейства, поэтому на Skylake может оказаться хуже латентность (где cmov дешево). test и lea могут работать параллельно.

После встраивания окончательный mov, вероятно, не будет существовать, и он может просто add в esi.

Ответ 6

Это всегда разрешено в соответствии с правилом "как есть", если никакая соответствующая программа не может отличить эту информацию. Например, реализация может гарантировать, что после каждого блока, выделенного с помощью malloc, существует не менее восьми байтов, к которым можно получить доступ без побочных эффектов. В этой ситуации компилятор может генерировать код, который будет undefined, если вы написали его в своем коде. Поэтому компилятору было бы полезно читать P [1], когда P [0] правильно распределяется, даже если это будет undefined поведение в вашем собственном коде.

Но в вашем случае, если вы не выделяете достаточно памяти для структуры, то чтение любого члена - это поведение undefined. Поэтому здесь компилятор разрешает это делать, даже если чтение P- > B завершается с ошибкой.