Почему "выравнивание" одинаково в 32-битных и 64-битных системах?

Мне было интересно, будет ли компилятор использовать разные отступы в 32-битных и 64-битных системах, поэтому я написал следующий код в простом консольном проекте VS2019 C++:

struct Z
{
    char s;
    __int64 i;
};

int main()
{
    std::cout << sizeof(Z) <<"\n"; 
}

Что я ожидал от каждой настройки "Платформа":

x86: 12
X64: 16

Фактический результат:

x86: 16
X64: 16

Поскольку размер слова памяти в x86 составляет 4 байта, это означает, что он должен хранить байты i в двух разных словах. Поэтому я подумал, что компилятор сделает заполнение таким образом:

struct Z
{
    char s;
    char _pad[3];
    __int64 i;
};

Так я могу знать, в чем причина этого?

  1. Для прямой совместимости с 64-битной системой?
  2. Из-за ограничения поддержки 64-битных чисел на 32-битном процессоре?

Ответы

Ответ 1

Заполнение определяется не размером слова, а выравниванием каждого типа данных.

В большинстве случаев требование выравнивания равно размеру шрифта. Таким образом, для 64-битного типа, такого как int64 вы получите 8-байтовое (64-битное) выравнивание. Заполнение необходимо вставить в структуру, чтобы убедиться, что хранилище для типа заканчивается по адресу, который выровнен правильно.

Вы можете увидеть разницу в заполнении между 32 и 64 битами при использовании встроенных типов данных, которые имеют разные размеры в обеих архитектурах, например, типы указателей (int*).

Ответ 2

Это является требованием выравнивания типа данных, как указано в Padding и Alignment of Structure Elements

Каждый объект данных имеет требование выравнивания. Требование выравнивания для всех данных, кроме структур, объединений и массивов, - это либо размер объекта, либо текущий размер упаковки (задается либо с помощью /Zp либо с помощью прагмы pack, в зависимости от того, что меньше).

И значение по умолчанию для выравнивания элементов структуры указано в /Zp (Выравнивание элементов структуры )

Доступные значения упаковки описаны в следующей таблице:

/Zp аргумент Эффект
1 Упакует структуры на 1-байтовых границах. То же, что /Zp.
2 Пакетные структуры на 2-байтовых границах.
4 пакета структур на 4-байтовых границах.
8 Упаковывает структуры на 8-байтовых границах (по умолчанию для x86, ARM и ARM64).
16 Пакетные структуры на 16-байтовых границах (по умолчанию для x64).

Так как для x86 по умолчанию используется /Zp8, что составляет 8 байт, выходное значение равно 16.

Однако вы можете указать другой размер упаковки с параметром /Zp.
Вот демонстрация в реальном времени с /Zp4 которая выдает 12 вместо 16.

Ответ 3

"32-разрядный" и "64-разрядный" относятся к размеру/выравниванию указателей. Если вы используете void* вместо __int64, вы увидите ожидаемый результат.

Любая система на основе x86 может хранить свое поле __int64 начиная с адреса, не делимого на 8, но это будет неэффективно - например, это может разделить его на две строки кэша. Таким образом, все "базовые" типы данных требуют, чтобы их выравнивание было кратным их размеру.

Ответ 4

Size и alignof() (минимальное выравнивание, которое должен иметь любой объект этого типа) для каждого примитивного типа - это выбор проекта ABI 1, отдельный от ширины регистра архитектуры.

Правила упаковки структуры также могут быть более сложными, чем просто выравнивание каждого члена структуры в соответствии с его минимальным выравниванием внутри структуры; что другая часть ABI.

Ориентация MSVC на 32-разрядную __int64 x86 дает __int64 минимальное выравнивание 4, но ее стандартные правила упаковки структуры выравнивают типы в структурах по минимуму min(8, sizeof(T)) относительно начала структуры. (Только для неагрегированных типов). Это не прямая цитата, что моя перефразировка ссылки на документы MSVC из ответа @PW основана на том, что MSVC, кажется, действительно делает. (Я подозреваю, что "в зависимости от того, что меньше" в тексте должно быть за пределами символов "Паренс", но, возможно, они обращают особое внимание на взаимодействие с прагмой и параметром командной строки?)

(8-байтовая структура, содержащая char[8] все еще получает только 1-байтовое выравнивание внутри другой структуры, или структура, содержащая alignas(16) все еще получает 16-байтовое выравнивание внутри другой структуры.)

Обратите внимание, что ISO C++ не гарантирует, что примитивные типы имеют alignof(T) == sizeof(T). Также обратите внимание, что определение alignof() MSVC не соответствует стандарту ISO C++: MSVC говорит alignof(__int64) == 8, но некоторые объекты __int64 имеют меньшее значение, чем это выравнивание 2.


Удивительно, но мы получаем дополнительное заполнение, даже несмотря на то, что MSVC не всегда заботится о том, чтобы сама структура имела выравнивание более чем на 4 байта, если вы не укажете это с помощью alignas() для переменной или члена структуры, чтобы подразумевать, что для типа. (Например, локальная struct Z tmp в стеке внутри функции будет иметь только 4-байтовое выравнивание, потому что MSVC не использует дополнительные инструкции, такие как and esp, -8 для округления указателя стека до 8-байтовой границы.)

Однако new/malloc предоставляет вам 8-байтовую память в 32-битном режиме, так что это имеет большой смысл для динамически размещаемых объектов (которые являются общими). Принудительное выравнивание локальных элементов в стеке увеличит стоимость выравнивания указателя стека, но, установив struct layout для использования преимуществ 8-байтового хранилища с выравниванием, мы получим преимущество для статического и динамического хранения.


Это также может быть разработано для получения 32- и 64-разрядного кода для согласования некоторых структурных макетов для разделяемой памяти. (Но учтите, что по умолчанию для x86-64 установлено значение min(16, sizeof(T)), поэтому они все еще не полностью согласуются с разметкой структуры, если существуют 16-байтовые типы, которые не являются агрегатами (struct/union/массив) и не имеют alignas.)


Минимальное абсолютное выравнивание 4 происходит из выравнивания стека 4 байта, которое может принять 32-битный код. В статическом хранилище компиляторы выбирают естественное выравнивание до 8 или 16 байтов для переменных вне структур для эффективного копирования с векторами SSE2.

В больших функциях MSVC может решить выровнять стек на 8 по соображениям производительности, например, для double переменных в стеке, которыми фактически можно манипулировать с помощью отдельных инструкций, или, возможно, также для int64_t с векторами SSE2. См. Раздел "Выравнивание стека" в этой статье 2006 года: " Выравнивание данных Windows в IPF, x86 и x64". Таким образом, в 32-битном коде вы не можете зависеть от естественного выравнивания int64_t* или double*.

(Я не уверен, что MSVC когда-либо создаст еще менее выровненные int64_t или double объекты самостоятельно. Конечно, да, если вы используете #pragma pack 1 или -Zp1, но это меняет ABI. Но в противном случае, вероятно, нет, если вы не вырезаете пространство для int64_t из буфера вручную и не потрудитесь выровнять его. Но если предположить, что alignof(int64_t) по-прежнему равен 8, это будет C++ неопределенное поведение.)

Если вы используете alignas(8) int64_t tmp, MSVC alignas(8) int64_t tmp дополнительные инструкции and esp, -8. Если вы этого не сделаете, MSVC не делает ничего особенного, так что вам повезло, в конечном итоге tmp 8-байтовым или нет.


Возможны другие конструкции, например, i386 System V ABI (используется в большинстве не-Windows ОС) имеет alignof(long long) = 4 но sizeof(long long) = 8. Эти выборы

Вне структур (например, глобальные переменные или локальные переменные в стеке) современные компиляторы в 32-битном режиме предпочитают выравнивать int64_t по 8-байтовой границе для эффективности (чтобы его можно было загружать/копировать с помощью 64-битных загрузок MMX или SSE2). или x87 fild сделать int64_t → двойное преобразование).

Это одна из причин, по которой современная версия i386 System V ABI поддерживает выравнивание стека 16 байтов: так возможны выровненные локальные переменные с 8 и 16 байтами.


Когда разрабатывался 32-битный Windows ABI, процессоры Pentium были как минимум на горизонте. Pentium имеет 64-битные шины данных, поэтому его FPU действительно может загрузить 64-битный double за один доступ к кэшу, если он выровнен по 64-битному алгоритму.

Или для fild/fistp, загрузите/сохраните 64-битное целое число при конвертации в/из double. Интересный факт: естественно выровненные обращения до 64 бит гарантированно атомарны на x86, начиная с Pentium: почему целочисленное присваивание для естественно выровненной переменной атомарно на x86?


Сноска 1: ABI также включает соглашение о вызовах или, в случае MS Windows, выбор различных соглашений о вызовах, которые можно объявить с помощью атрибутов функций, таких как __fastcall), но требования к размерам и выравниванию для примитивных типов, таких как long long, также то, что компиляторы должны согласовать для создания функций, которые могут вызывать друг друга. (Стандарт ISO C++ говорит только об одной "реализации C++"; стандарты ABI - это то, как "реализации C++" обеспечивают совместимость друг с другом.)

Обратите внимание, что правила структурного макета также являются частью ABI: компиляторы должны договариваться друг с другом о структурном макете для создания совместимых двоичных файлов, которые передают структуры или указатели на структуры. В противном случае sx = 10; foo(&x); sx = 10; foo(&x); может записать смещение, отличное от базового значения структуры, чем ожидалось при чтении отдельно скомпилированной foo() (возможно, в DLL).


Сноска 2:

В GCC была эта ошибка C++ alignof(), пока она не была исправлена в 2018 году для g++ 8 спустя некоторое время после исправления для C11 _Alignof(). Посмотрите этот отчет об ошибках для обсуждения, основанного на цитатах из стандарта, которые заключают, что alignof(T) должен действительно сообщать о минимальном гарантированном выравнивании, которое вы можете когда-либо видеть, а не о предпочтительном выравнивании, которое вы хотите для производительности. то есть использование int64_t* с выравниванием меньше чем alignof(int64_t) является неопределенным поведением.

(Обычно это нормально работает на x86, но векторизация, предполагающая, что целое число итераций int64_t достигнет 16- или 32-байтовой границы выравнивания, может дать сбой. См. Почему нелицензированный доступ к памяти mmap иногда вызывает segfault на AMD64? Например с gcc.)

В отчете об ошибках gcc обсуждается i386 System V ABI, который имеет правила структурирования, отличные от MSVC: основанный на минимальном выравнивании, не предпочтительный. Но современная i386 System V поддерживает 16-байтовое выравнивание стека, поэтому только внутри структур (из-за правил упаковки структур, являющихся частью ABI) компилятор всегда создает int64_t и double объекты, которые выровнены не так, как обычно. В любом случае, именно поэтому в отчете об ошибках GCC обсуждались члены структуры как особый случай.

В отличие от 32-битной Windows с MSVC, где правила структурирования alignof(int64_t) == 8 совместимы с alignof(int64_t) == 8 но alignof(int64_t) == 8 в стеке всегда потенциально не выровнены, если вы не используете alignas() для специального запроса на выравнивание.

32-битный MSVC имеет странное поведение, что alignas(int64_t) int64_t tmp отличается от int64_t tmp; и выдает дополнительные инструкции для выравнивания стека. Это потому, что alignas(int64_t) похож на alignas(8), который более выровнен, чем фактический минимум.

void extfunc(int64_t *);

void foo_align8(void) {
    alignas(int64_t) int64_t tmp;
    extfunc(&tmp);
}

(32-разрядная версия) x86 MSVC 19.20 -O2 компилирует его следующим образом ( на Godbolt также включает 32-разрядную версию GCC и контрольный пример структуры):

_tmp$ = -8                                          ; size = 8
void foo_align8(void) PROC                       ; foo_align8, COMDAT
        push    ebp
        mov     ebp, esp
        and     esp, -8                             ; fffffff8H  align the stack
        sub     esp, 8                                  ; and reserve 8 bytes
        lea     eax, DWORD PTR _tmp$[esp+8]             ; get a pointer to those 8 bytes
        push    eax                                     ; pass the pointer as an arg
        call    void extfunc(__int64 *)           ; extfunc
        add     esp, 4
        mov     esp, ebp
        pop     ebp
        ret     0

Но без alignas() или alignas(4) мы получим гораздо проще

_tmp$ = -8                                          ; size = 8
void foo_noalign(void) PROC                                ; foo_noalign, COMDAT
        sub     esp, 8                             ; reserve 8 bytes
        lea     eax, DWORD PTR _tmp$[esp+8]        ; "calculate" a pointer to it
        push    eax                                ; pass the pointer as a function arg
        call    void extfunc(__int64 *)           ; extfunc
        add     esp, 12                             ; 0000000cH
        ret     0

Это может просто push esp вместо LEA/push; что незначительная пропущенная оптимизация.

Передача указателя на не встроенную функцию доказывает, что она не просто локально изменяет правила. Некоторая другая функция, которая просто получает int64_t* в качестве аргумента, должна иметь дело с этим потенциально int64_t* указателем, не получая никакой информации о том, откуда она взялась.

Если alignof(int64_t) действительно alignof(int64_t) 8, эта функция может быть написана от руки в asm таким образом, что это alignof(int64_t) указателей. Или это может быть написано на C с внутренними SSE2, такими как _mm_load_si128() которые требуют 16-байтового выравнивания, после обработки 0 или 1 элементов для достижения границы выравнивания.

Но при реальном поведении MSVC возможно, что ни один из int64_t массива int64_t будет выровнен на 16, поскольку все они охватывают 8-байтовую границу.


Кстати, я бы не рекомендовал использовать специфичные для компилятора типы, такие как __int64 напрямую. Вы можете написать переносимый код, используя int64_t из <cstdint>, он же <stdint.h>.

В MSVC int64_t будет того же типа, что и __int64.

На других платформах он обычно будет long или long long. int64_t гарантированно будет ровно 64 бита без дополнения и 2 дополнения, если предусмотрено вообще. (Это все нормальные компиляторы, нацеленные на нормальные процессоры. C99 и C++ требуют, чтобы long long был как минимум 64-битным, а на машинах с 8-битными байтами и регистрами, которые имеют степень 2, long long обычно ровно 64 биты и может использоваться как int64_t. Или если long является 64-битным типом, то <cstdint> может использовать это как typedef.)

Я предполагаю, что __int64 и long long - это один и тот же тип в MSVC, но MSVC все равно не применяет строгий псевдоним, поэтому не имеет значения, являются ли они одним и тем же типом или нет, просто они используют одно и то же представление.

Ответ 5

Выравнивание структуры - это размер ее наибольшего члена.

Это означает, что если в структуре есть 8-байтовый (64-битный) член, то структура будет выровнена до 8 байтов.

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


Скажем, у нас есть процессор с 16-байтовой строкой кэша. Рассмотрим такую структуру:

struct Z
{
    char s;      // 1-4 byte
    __int64 i;   // 5-12 byte
    __int64 i2;  // 13-20 byte, need two cache line fetches to read this variable
};