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

121

В Noda Time v2 мы переходим к наносекундному разрешению. Это означает, что мы больше не можем использовать 8-байтовое целое число для представления всего интересующего нас диапазона времени. Это побудило меня исследовать использование памяти (многими) структурами Noda Time, что, в свою очередь, привело меня к чтобы выявить небольшую странность в решении CLR о выравнивании.

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

Мой основной сценарий состоит в том, что у меня есть structполе, содержащее поле ссылочного типа и два других поля типа значения, для которых эти поля являются простыми оболочками int. Я надеялся , что это будет представлено как 16 байтов в 64-битной среде CLR (8 для справки и 4 для каждого из остальных), но по какой-то причине он использует 24 байта. Между прочим, я измеряю пространство с помощью массивов - я понимаю, что макет может отличаться в разных ситуациях, но это было разумной отправной точкой.

Вот пример программы, демонстрирующей проблему:

using System;
using System.Runtime.InteropServices;

#pragma warning disable 0169

struct Int32Wrapper
{
    int x;
}

struct TwoInt32s
{
    int x, y;
}

struct TwoInt32Wrappers
{
    Int32Wrapper x, y;
}

struct RefAndTwoInt32s
{
    string text;
    int x, y;
}

struct RefAndTwoInt32Wrappers
{
    string text;
    Int32Wrapper x, y;
}    

class Test
{
    static void Main()
    {
        Console.WriteLine("Environment: CLR {0} on {1} ({2})",
            Environment.Version,
            Environment.OSVersion,
            Environment.Is64BitProcess ? "64 bit" : "32 bit");
        ShowSize<Int32Wrapper>();
        ShowSize<TwoInt32s>();
        ShowSize<TwoInt32Wrappers>();
        ShowSize<RefAndTwoInt32s>();
        ShowSize<RefAndTwoInt32Wrappers>();
    }

    static void ShowSize<T>()
    {
        long before = GC.GetTotalMemory(true);
        T[] array = new T[100000];
        long after  = GC.GetTotalMemory(true);        
        Console.WriteLine("{0}: {1}", typeof(T),
                          (after - before) / array.Length);
    }
}

И сборка и вывод на моем ноутбуке:

c:\Users\Jon\Test>csc /debug- /o+ ShowMemory.cs
Microsoft (R) Visual C# Compiler version 12.0.30501.0
for C# 5
Copyright (C) Microsoft Corporation. All rights reserved.


c:\Users\Jon\Test>ShowMemory.exe
Environment: CLR 4.0.30319.34014 on Microsoft Windows NT 6.2.9200.0 (64 bit)
Int32Wrapper: 4
TwoInt32s: 8
TwoInt32Wrappers: 8
RefAndTwoInt32s: 16
RefAndTwoInt32Wrappers: 24

Так:

  • Если у вас нет поля ссылочного типа, среда CLR с радостью упакует Int32Wrapperполя вместе ( TwoInt32Wrappersимеет размер 8).
  • Даже с полем ссылочного типа среда CLR по-прежнему может упаковывать intполя вместе ( RefAndTwoInt32sимеет размер 16).
  • Комбинируя эти два, каждое Int32Wrapperполе кажется дополненным / выровненным до 8 байтов. ( RefAndTwoInt32Wrappersимеет размер 24.)
  • Выполнение того же кода в отладчике (но все еще сборка выпуска) показывает размер 12.

Еще несколько экспериментов дали аналогичные результаты:

  • Помещение поля ссылочного типа после полей типа значения не помогает
  • Использование objectвместо stringне помогает (я ожидаю, что это "любой ссылочный тип")
  • Использование другой структуры в качестве «оболочки» вокруг ссылки не помогает
  • Использование общей структуры в качестве оболочки вокруг ссылки не помогает
  • Если я продолжу добавлять поля (попарно для простоты), intполя все равно будут считаться 4 байтами, а Int32Wrapperполя - 8 байтами.
  • Добавление [StructLayout(LayoutKind.Sequential, Pack = 4)]к каждой видимой структуре не меняет результатов

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

Джон Скит
источник
1
На самом деле кажется, что вы не используете, Ref<T>а используете stringвместо этого, не то чтобы это должно иметь значение.
tvanfosson
2
Что произойдет , если вы положили два создать - структуру с двумя TwoInt32Wrappers, или Int64и а TwoInt32Wrappers? Как насчет того, чтобы создать общий шаблон, Pair<T1,T2> {public T1 f1; public T2 f2;}а затем создать Pair<string,Pair<int,int>>и Pair<string,Pair<Int32Wrapper,Int32Wrapper>>? Какие комбинации вынуждают JITter сглаживать вещи?
supercat
7
@supercat: Это , вероятно , лучше для вас , чтобы скопировать код и эксперимент для себя - но Pair<string, TwoInt32Wrappers> это даст только 16 байт, так что бы решить эту проблему. Захватывающий.
Джон Скит
9
@SLaks: Иногда, когда структура передается в собственный код, среда выполнения копирует все данные в структуру с другим макетом. Marshal.SizeOfвернет размер структуры, которая будет передана в машинный код, который не обязательно должен иметь какое-либо отношение к размеру структуры в .NET-коде.
supercat
5
Интересное наблюдение: Mono дает правильные результаты. Среда: CLR 4.0.30319.17020 в Unix 3.13.0.24 (64 бит) Int32Wrapper: 4 TwoInt32s: 8 TwoInt32Wrappers: 8 RefAndTwoInt32s: 16 RefAndTwoInt32Wrappers: 16
Андрей Акиншин

Ответы:

85

Я считаю это ошибкой. Вы видите побочный эффект автоматической компоновки: ей нравится выравнивать нетривиальные поля по адресу, кратному 8 байтам в 64-битном режиме. Это происходит даже тогда, когда вы явно применяете [StructLayout(LayoutKind.Sequential)]атрибут. Этого не должно быть.

Вы можете увидеть это, сделав члены структуры общедоступными и добавив тестовый код следующим образом:

    var test = new RefAndTwoInt32Wrappers();
    test.text = "adsf";
    test.x.x = 0x11111111;
    test.y.x = 0x22222222;
    Console.ReadLine();      // <=== Breakpoint here

При достижении точки останова используйте Debug + Windows + Memory + Memory 1. Переключитесь на 4-байтовые целые числа и введите &testв поле Address:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0- это строковый указатель на моей машине (не на вашей). Вы можете легко увидеть Int32Wrappers, с дополнительными 4 байтами заполнения, которые превратили размер в 24 байта. Вернитесь к структуре и поместите строку последней. Повторите, и вы увидите, что указатель на строку все еще находится первым. Нарушая LayoutKind.Sequential, вы попали LayoutKind.Auto.

Будет сложно убедить Microsoft исправить это, это работало так слишком долго, поэтому любое изменение что-то сломает . CLR только пытается [StructLayout]учесть управляемую версию структуры и сделать ее непреобразуемой, но в целом быстро отказывается. Как известно, для любой структуры, содержащей DateTime. Вы получаете настоящую гарантию LayoutKind только при маршалинге структуры. Маршалированная версия, конечно же, составляет 16 байт, как Marshal.SizeOf()вы скажете.

Использование LayoutKind.Explicitисправляет это, а не то, что вы хотели услышать.

Ганс Пассан
источник
7
«Будет сложно убедить Microsoft исправить это, это работало так слишком долго, поэтому любое изменение что-то сломает». Тот факт, что это явно не проявляется в 32-битном или моно, может помочь (согласно другим комментариям).
NPSF3000
Документация StructLayoutAttribute довольно интересна. В основном, через StructLayout в управляемой памяти контролируются только непреобразуемые типы. Интересно, никогда этого не знал.
Майкл Штум
@Soner нет, это не исправить. Вы поместили макет в оба поля со смещением 8? Если это так, то x и y одинаковы, и изменение одного меняет другое. Очевидно, не то, что нужно Джону.
BartoszAdamczewski
Замена stringдругим новым ссылочным типом ( class), к которому он был применен [StructLayout(LayoutKind.Sequential)], похоже, ничего не меняет. В обратном направлении, применяя [StructLayout(LayoutKind.Auto)]к struct Int32Wrapperизменениям использования памяти в TwoInt32Wrappers.
Jeppe Stig Nielsen
1
«Будет сложно убедить Microsoft исправить это, это работало так слишком долго, поэтому любое изменение что-то сломает». xkcd.com/1172
iCodeSometime
19

EDIT2

struct RefAndTwoInt32Wrappers
{
    public int x;
    public string s;
}

Этот код будет выровнен по 8 байтов, поэтому структура будет иметь 16 байтов. Для сравнения:

struct RefAndTwoInt32Wrappers
{
    public int x,y;
    public string s;
}

Будет выровнено по 4 байта, поэтому эта структура также будет иметь 16 байтов. Таким образом, обоснование здесь состоит в том, что выравнивание структуры в CLR определяется количеством наиболее выровненных полей, очевидно, что clases не могут этого сделать, поэтому они будут оставаться выровненными на 8 байт.

Теперь, если мы объединим все это и создадим структуру:

struct RefAndTwoInt32Wrappers
{
    public int x,y;
    public Int32Wrapper z;
    public string s;
}

Он будет иметь 24 байта, {x, y} будет иметь 4 байта каждый, а {z, s} будет иметь 8 байтов. После того, как мы введем тип ref в структуру, CLR всегда будет выравнивать нашу настраиваемую структуру в соответствии с выравниванием класса.

struct RefAndTwoInt32Wrappers
{
    public Int32Wrapper z;
    public long l;
    public int x,y;  
}

Этот код будет иметь 24 байта, поскольку Int32Wrapper будет выровнен так же, как и long. Таким образом, пользовательская оболочка структуры всегда будет выравниваться по полю с самым высоким / наилучшим выравниванием в структуре или по своим внутренним наиболее значимым полям. Таким образом, в случае строки ссылки, которая выровнена по 8 байтов, оболочка структуры будет выровнена по ней.

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


РЕДАКТИРОВАТЬ

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

Я проверю код cli и опубликую обновления, если будет найдено что-то полезное.


Это стратегия выравнивания, используемая распределителем памяти .NET.

public static RefAndTwoInt32s[] test = new RefAndTwoInt32s[1];

static void Main()
{
    test[0].text = "a";
    test[0].x = 1;
    test[0].x = 1;

    Console.ReadKey();
}

Этот код, скомпилированный с .net40 под x64, в WinDbg позволяет делать следующее:

Давайте сначала найдем тип в куче:

    0:004> !dumpheap -type Ref
       Address               MT     Size
0000000003e72c78 000007fe61e8fb58       56    
0000000003e72d08 000007fe039d3b78       40    

Statistics:
              MT    Count    TotalSize Class Name
000007fe039d3b78        1           40 RefAndTwoInt32s[]
000007fe61e8fb58        1           56 System.Reflection.RuntimeAssembly
Total 2 objects

Как только он у нас будет, давайте посмотрим, что находится под этим адресом:

    0:004> !do 0000000003e72d08
Name:        RefAndTwoInt32s[]
MethodTable: 000007fe039d3b78
EEClass:     000007fe039d3ad0
Size:        40(0x28) bytes
Array:       Rank 1, Number of elements 1, Type VALUETYPE
Fields:
None

Мы видим, что это ValueType, и это тот, который мы создали. Поскольку это массив, нам нужно получить значение ValueType def для одного элемента в массиве:

    0:004> !dumparray -details 0000000003e72d08
Name:        RefAndTwoInt32s[]
MethodTable: 000007fe039d3b78
EEClass:     000007fe039d3ad0
Size:        40(0x28) bytes
Array:       Rank 1, Number of elements 1, Type VALUETYPE
Element Methodtable: 000007fe039d3a58
[0] 0000000003e72d18
    Name:        RefAndTwoInt32s
    MethodTable: 000007fe039d3a58
    EEClass:     000007fe03ae2338
    Size:        32(0x20) bytes
    File:        C:\ConsoleApplication8\bin\Release\ConsoleApplication8.exe
    Fields:
                      MT    Field   Offset                 Type VT     Attr            Value Name
        000007fe61e8c358  4000006        0            System.String      0     instance     0000000003e72d30     text
        000007fe61e8f108  4000007        8             System.Int32      1     instance                    1     x
        000007fe61e8f108  4000008        c             System.Int32      1     instance                    0     y

На самом деле структура составляет 32 байта, так как 16 байтов зарезервированы для заполнения, поэтому на самом деле каждая структура с самого начала имеет размер не менее 16 байтов.

если вы добавите 16 байтов из целых чисел и ссылку на строку в: 0000000003e72d18 + 8 байтов EE / заполнение, вы получите 0000000003e72d30, и это начальная точка для ссылки на строку, и поскольку все ссылки заполнены 8 байтами от их первого фактического поля данных это составляет 32 байта для этой структуры.

Посмотрим, действительно ли строка заполнена таким образом:

0:004> !do 0000000003e72d30    
Name:        System.String
MethodTable: 000007fe61e8c358
EEClass:     000007fe617f3720
Size:        28(0x1c) bytes
File:        C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
String:      a
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
000007fe61e8f108  40000aa        8         System.Int32  1 instance                1 m_stringLength
000007fe61e8d640  40000ab        c          System.Char  1 instance               61 m_firstChar
000007fe61e8c358  40000ac       18        System.String  0   shared           static Empty
                                 >> Domain:Value  0000000001577e90:NotInit  <<

Теперь давайте проанализируем вышеуказанную программу таким же образом:

public static RefAndTwoInt32Wrappers[] test = new RefAndTwoInt32Wrappers[1];

static void Main()
{
    test[0].text = "a";
    test[0].x.x = 1;
    test[0].y.x = 1;

    Console.ReadKey();
}

0:004> !dumpheap -type Ref
     Address               MT     Size
0000000003c22c78 000007fe61e8fb58       56    
0000000003c22d08 000007fe039d3c00       48    

Statistics:
              MT    Count    TotalSize Class Name
000007fe039d3c00        1           48 RefAndTwoInt32Wrappers[]
000007fe61e8fb58        1           56 System.Reflection.RuntimeAssembly
Total 2 objects

Наша структура теперь составляет 48 байтов.

0:004> !dumparray -details 0000000003c22d08
Name:        RefAndTwoInt32Wrappers[]
MethodTable: 000007fe039d3c00
EEClass:     000007fe039d3b58
Size:        48(0x30) bytes
Array:       Rank 1, Number of elements 1, Type VALUETYPE
Element Methodtable: 000007fe039d3ae0
[0] 0000000003c22d18
    Name:        RefAndTwoInt32Wrappers
    MethodTable: 000007fe039d3ae0
    EEClass:     000007fe03ae2338
    Size:        40(0x28) bytes
    File:        C:\ConsoleApplication8\bin\Release\ConsoleApplication8.exe
    Fields:
                      MT    Field   Offset                 Type VT     Attr            Value Name
        000007fe61e8c358  4000009        0            System.String      0     instance     0000000003c22d38     text
        000007fe039d3a20  400000a        8             Int32Wrapper      1     instance     0000000003c22d20     x
        000007fe039d3a20  400000b       10             Int32Wrapper      1     instance     0000000003c22d28     y

Здесь ситуация такая же, если мы добавим к 0000000003c22d18 + 8 байтов строки ref, мы окажемся в начале первой оболочки Int, где значение фактически указывает на адрес, по которому мы находимся.

Теперь мы видим, что каждое значение является ссылкой на объект, что снова позволяет подтвердить это, просмотрев 0000000003c22d20.

0:004> !do 0000000003c22d20
<Note: this object has an invalid CLASS field>
Invalid object

На самом деле это правильно, так как это структура, адрес которой ничего не говорит нам, если это объект или vt.

0:004> !dumpvc 000007fe039d3a20   0000000003c22d20    
Name:        Int32Wrapper
MethodTable: 000007fe039d3a20
EEClass:     000007fe03ae23c8
Size:        24(0x18) bytes
File:        C:\ConsoleApplication8\bin\Release\ConsoleApplication8.exe
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
000007fe61e8f108  4000001        0         System.Int32  1 instance                1 x

Так что на самом деле это больше похоже на тип Union, который на этот раз будет выровнен по 8 байтам (все отступы будут выровнены с родительской структурой). Если бы это было не так, у нас было бы 20 байтов, а это не оптимально, поэтому распределитель памяти никогда не допустит этого. Если вы снова выполните математические вычисления, окажется, что структура действительно имеет размер 40 байт.

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

Последний вопрос: почему вдруг мы можем получить такой макет? Хорошо, если вы сравните код jited и производительность приращения int [] с struct [] с приращением поля счетчика, второй будет генерировать 8-байтовый выровненный адрес, являющийся объединением, но при jited это переводится в более оптимизированный код сборки (одиночный LEA против нескольких MOV). Однако в случае, описанном здесь, производительность будет на самом деле хуже, поэтому я считаю, что это согласуется с базовой реализацией CLR, поскольку это настраиваемый тип, который может иметь несколько полей, поэтому может быть проще / лучше указать начальный адрес вместо value (поскольку это было бы невозможно) и сделайте там заполнение структуры, что приведет к большему размеру байта.

BartoszAdamczewski
источник
1
Глядя на это сам, размер RefAndTwoInt32Wrappers не 32 байта, а 24, что соответствует моему коду. Если вы посмотрите в представление памяти вместо использования dumparrayи посмотрите на память для массива с (скажем) 3 элементами с различимыми значениями, вы можете ясно увидеть, что каждый элемент состоит из 8-байтовой строковой ссылки и двух 8-байтовых целых чисел , Я подозреваю, что dumparrayон показывает значения как ссылки просто потому, что не умеет отображать Int32Wrapperзначения. Эти «ссылки» указывают на самих себя; это не отдельные ценности.
Джон Скит
1
Я не совсем уверен, откуда вы берете «16-байтовое заполнение», но подозреваю, что это может быть из-за того, что вы смотрите на размер объекта массива, который будет «16 байт + количество * размер элемента». Итак, массив со счетчиком 2 имеет размер 72 (16 + 2 * 24), что и dumparrayпоказано.
Джон Скит
@jon вы сбросили свою структуру и проверили, сколько места она занимает в куче? Обычно размер массива хранится в начале массива, это также можно проверить.
BartoszAdamczewski
@jon указанный размер также содержит смещение строки, которая начинается с 8. Я не думаю, что эти дополнительные 8 байтов, упомянутые, поступают из массива, поскольку большая часть материала массива находится перед адресом первого элемента, но я дважды проверю и прокомментируйте это.
BartoszAdamczewski
1
Нет, ThreeInt32Wrappers заканчивается 12 байтами, FourInt32Wrappers - 16, FiveInt32Wrappers - 20. Я не вижу ничего логичного в добавлении поля ссылочного типа, столь радикально меняющем макет. И обратите внимание, что 8-байтовое выравнивание можно игнорировать, когда поля имеют тип Int32. Честно говоря, меня не слишком беспокоит, что он делает в стеке, но я его не проверял.
Джон Скит
9

Резюме см., Вероятно, ответ @Hans Passant выше. Layout Sequential не работает


Некоторое тестирование:

Это определенно только на 64-битной, и ссылка на объект «отравляет» структуру. 32 бит делает то, что вы ожидаете:

Environment: CLR 4.0.30319.34209 on Microsoft Windows NT 6.2.9200.0 (32 bit)
ConsoleApplication1.Int32Wrapper: 4
ConsoleApplication1.TwoInt32s: 8
ConsoleApplication1.TwoInt32Wrappers: 8
ConsoleApplication1.ThreeInt32Wrappers: 12
ConsoleApplication1.Ref: 4
ConsoleApplication1.RefAndTwoInt32s: 12
ConsoleApplication1.RefAndTwoInt32Wrappers: 12
ConsoleApplication1.RefAndThreeInt32s: 16
ConsoleApplication1.RefAndThreeInt32Wrappers: 16

Как только добавляется ссылка на объект, все структуры расширяются до 8 байтов, а не до 4 байтов. Расширяем тесты:

Environment: CLR 4.0.30319.34209 on Microsoft Windows NT 6.2.9200.0 (64 bit)
ConsoleApplication1.Int32Wrapper: 4
ConsoleApplication1.TwoInt32s: 8
ConsoleApplication1.TwoInt32Wrappers: 8
ConsoleApplication1.ThreeInt32Wrappers: 12
ConsoleApplication1.Ref: 8
ConsoleApplication1.RefAndTwoInt32s: 16
ConsoleApplication1.RefAndTwoInt32sSequential: 16
ConsoleApplication1.RefAndTwoInt32Wrappers: 24
ConsoleApplication1.RefAndThreeInt32s: 24
ConsoleApplication1.RefAndThreeInt32Wrappers: 32
ConsoleApplication1.RefAndFourInt32s: 24
ConsoleApplication1.RefAndFourInt32Wrappers: 40

Как вы можете видеть, как только ссылка добавляется, каждый Int32Wrapper становится 8 байтов, поэтому выравнивание не простое. Я сократил выделение массива, если это было выделение LoH, которое выровнено по-другому.

Бен Адамс
источник
4

Просто чтобы добавить немного данных в микс - я создал еще один тип из тех, что были у вас:

struct RefAndTwoInt32Wrappers2
{
    string text;
    TwoInt32Wrappers z;
}

Программа выписывает:

RefAndTwoInt32Wrappers2: 16

Итак, похоже, что TwoInt32Wrappersструктура правильно выровнена в новой RefAndTwoInt32Wrappers2структуре.

Джесси С. Слайсер
источник
Вы используете 64-битную версию? Выравнивание в 32 бита нормально
Бен Адамс
Мои выводы такие же, как и у всех остальных, для различных сред.
Джесси С. Слайсер