Чому вирівнювання структури залежить від того, тип поля є примітивним або визначеним користувачем?


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, або an Int64і a 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: Іноді, коли структура передається до нативного коду, Runtime копіює всі дані в структуру з іншим компонуванням. Marshal.SizeOfповерне розмір структури, яка буде передана нативним кодом, який не повинен мати жодного відношення до розміру структури в .NET-коді.
supercat

5
Цікаве спостереження: Моно дає правильні результати. Навколишнє середовище: CLR 4.0.30319.17020 на Unix 3.13.0.24 (64 біт) Int32Wrapper: 4 TwoInt32s: 8 TwoInt32Wrappers: 8 RefAndTwoInt32s: 16 RefAndTwoInt32Wrappers: 16
AndreyAkinshin

Відповіді:


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

Коли точка перерви потрапить, використовуйте налагодження + Windows + пам'ять + пам'ять 1. Перейдіть до 4-байтних цілих чисел та введіть &testу поле Адреса:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

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

Це буде важко переконати Microsoft виправити це, він працював таким чином занадто довго, щоб будь-які зміни щось зламали . CLR робить лише спробу вшанувати [StructLayout]керовану версію структури та зробити її простою, вона загалом швидко здається. Знаменито для будь-якої структури, що містить DateTime. Справжню гарантію LayoutKind ви отримуєте лише при маршалінгу структури. Як Marshal.SizeOf()скажемо вам, версія з розмежуваним рівнем - це 16 байт .

Використовуючи LayoutKind.Explicitвиправлення, не те, що ви хотіли почути.


7
"Буде важко переконати Microsoft виправити це. Він працював таким чином занадто довго, щоб будь-які зміни щось зламали". Той факт, що це, очевидно, не виявляється в 32-бітовому або моно-режимі, може допомогти (як за іншими коментарями).
NPSF3000

Документація StructLayoutAttribute є досить цікавою. В основному за допомогою StructLayout в керованій пам'яті керуються лише види, що виблискують. Цікаво, ніколи цього не знали.
Майкл Штум

@ Скоріше ні це не виправить. Ви поставили макет на обох полях для зміщення 8? Якщо так, то х і у є однаковими, а зміна одного змінює інше. Ясна річ не те, що Йон переслідує.
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 визначається кількістю найбільш вирівняних полів, фрази, очевидно, не можуть цього зробити, тому вони залишаться вирівняними у 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 буде вирівняний так само, як і довгий. Таким чином, спеціальна обгортка структури завжди буде вирівнюватися до найвищого / найкращо вирівняного поля в структурі або до власних найбільш значущих внутрішніх полів. Так що у випадку рядка ref, розміщеного на 8 байт, обертка структури буде вирівнюватися до цього.

Висновок спеціального поля структури у структурі завжди буде вирівнюватися до найвищого вирівняного поля екземпляра в структурі. Тепер, якщо я не впевнений, що це помилка, але без жодних доказів я буду дотримуватися своєї думки, що це може бути свідомим рішенням.


EDIT

Розміри насправді точні лише при розподілі на купі, але самі структури мають менші розміри (точні розміри поля). Подальший аналіз аналізу дозволяє припустити, що це може бути помилка в коді CLR, але її потрібно підкріпити доказами.

Я огляну код коду та опублікую подальші оновлення, якщо буде знайдено щось корисне.


Це стратегія вирівнювання, використовувана алокатором пам'яті .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 одного елемента в масиві:

    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 байт з ints та рядка ref на: 0000000003e72d18 + 8 байт EE / padding, ви закінчитеся на 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

Насправді це правильно, оскільки його структура не відповідає нам нічого, якщо це obj або 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 для напр.) Таким чином, вам надається власний блок пам'яті, і ви керуєте нею, як вам потрібно.

Остаточне питання тут, чому раптом ми можемо отримати такий макет. Добре, якщо ви порівнюєте збитий код та продуктивність інт [] приросту з struct [] із збільшенням лічильника поля, другий буде генерувати 8-байтну вирівняну адресу як об'єднання, але коли jited це переводить на більш оптимізований код збірки (singe LEA проти декількох MOV). Однак у описуваному тут випадку продуктивність буде фактично гіршою, тому я вважаю, що це узгоджується з базовою реалізацією CLR, оскільки це власний тип, який може мати декілька полів, тому може бути простіше / краще поставити початковий адресу замість Значення (оскільки це було б неможливо) і виконайте там прокладку по структурі, що призведе до більшого розміру байтів.


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

Підсумок див. Відповідь Ханса Пасанта, ймовірно, вище. Послідовний макет не працює


Деякі тестування:

Це, безумовно, лише на 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 бітах
Бен Адамс

Мої висновки такі ж, як і всі інші для різних середовищ.
Джессі К. Слікер
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.