В структуре три поля: byte, long, byte. Она занимает 24 байта. Меняем порядок объявления на byte, byte, long — та же структура занимает 16.

Массив из миллиона таких структур становится меньше на треть, а обход по нему ускоряется в 1,8 раза. Ничего не переписано, переставлены два поля.

Машина

Процессор

Система

Комп 1

Intel Core i9-10900KF 3.70GHz, 10 ядер

Windows 10 22H2

Комп 2

AMD Ryzen 9 5950X 3.39GHz, 16 ядер

Windows 10 1809

Комп 3

Intel Xeon W-2255 3.70GHz, 10 ядер

Windows Server 2022

Комп 4

Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра

Windows Server 2022

Все машины x64

Рантаймы 8.0.30, 9.0.19 и 10.0.5 — все три в одном запуске BenchmarkDotNet 0.15.8. Размеры совпали на четырёх машинах и трёх рантаймах до байта, поэтому в таблицах с размерами они приведены одним столбцом.

1. Сколько занимает объект

Исходник

У объекта в куче перед данными идут два служебных поля: указатель на таблицу методов и заголовок для блока синхронизации, по 8 байт каждый. Сама таблица методов описана на C# структурой с явными смещениями:

[StructLayout(LayoutKind.Explicit)]
internal unsafe struct MethodTable
{
    [FieldOffset(0)]
    public ushort ComponentSize;
 
    [FieldOffset(0)]
    private uint Flags;
 
    [FieldOffset(4)]
    public uint BaseSize;
 
    // ...
}

По смещению 0 записаны два разных значения. У массивов и строк это размер элемента, у остальных типов — флаги. Они различаются по старшему биту флагов.

По смещению 4 записан BaseSize — то число, которое среда выполнения берёт, когда выделяет объект. Полный размер считается вот так:

internal static unsafe uint GetRawObjectDataSize(object obj)
{
    MethodTable* pMT = GetMethodTable(obj);
 
    // See comment on RawArrayData for details
    nuint rawSize = pMT->BaseSize - (nuint)(2 * sizeof(IntPtr));
    if (pMT->HasComponentSize)
    {
        rawSize += (nuint)Unsafe.As<RawArrayData>(obj).Length * pMT->ComponentSize;
    }
 
    return (uint)rawSize;
}

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

Что показывает замер

Класс с одним полем. Тип поля не важен, пока он помещается в 8 байт:

Тип поля

Размер объекта

полей нет

24

bool, byte, short, char

24

int, float

24

long, double, nint, ссылка

24

DateTime, TimeSpan

24

decimal

32

Guid

32

Байты. Одинаково на четырёх машинах и трёх рантаймах

decimal и Guid сами занимают по 16 байт, поэтому в 24 не помещаются.

Теперь несколько полей одного типа: 

Полей

bool

char

int

long

1

24

24

24

2

24

24

24

32

3

32

40

4

24

24

32

5

32

8

24

9

32

Байты, .NET 10 

Класс с восемью полями bool занимает столько же, сколько класс без полей. Девятое поле добавляет 8 байт. У char граница проходит между четырьмя и пятью полями, у int — между двумя и тремя. Прочерк означает, что такое сочетание не замерялось.

Причина

Два служебных поля занимают 16 байт. Минимальный размер объекта — 24 байта, то есть под данные всегда отводится хотя бы 8. Пока поля в эти 8 байт помещаются, размер объекта не меняется.

Дальше размер растёт шагами по 8 байт: выделение в куче выравнивается по границе указателя. Поэтому девятое поле bool добавляет к объекту сразу 8 байт, а не один.

Ошибки при замере

Созданный объект надо куда-то записать. Начиная с .NET 9 JIT видит, что объект не выходит за пределы вызова, и не выделяет под него память — замер покажет ноль байт. Поэтому в проекте каждый объект записывается в поле, и это поле потом где-то используется.

Строка нулевой длины в рантайме одна на процесс: new string('a', 0) возвращает уже существующий экземпляр, и память под неё не выделяется. В отчёте такая строка помечена отдельно, а не считается расхождением.

2. Порядок полей

Исходник

Две структуры с одинаковым набором полей. Различие — порядок объявления:

internal struct Spread
{
    public byte A;
    public long B;
    public byte C;
}
 
internal struct Packed
{
    public byte A;
    public byte B;
    public long C;
}

Что показывает замер

Структура

Размер

Смещения полей

byte, long, byte

24

A = 0, B = 8, C = 16

byte, byte, long

16

A = 0, B = 1, C = 8

Байты, Unsafe.SizeOf и Marshal.OffsetOf 

В первой структуре поле B выравнивается по границе 8 байт, и между ним и полем A пропадают 7 байт. Поле C начинается с 16-го байта, занимает один и тянет за собой ещё 7 до конца структуры. Во второй структуре поля A и B идут подряд и умещаются в одни и те же 8 байт.

Массив из таких структур обходится циклом, который складывает все три поля:

long total = 0;
Spread[] data = _spread;
 
for (int i = 0; i < data.Length; i++)
{
    total += data[i].A + data[i].B + data[i].C;
}

Элементов

Комп 1

Комп 2

Комп 3

Комп 4

10 000

5,990 / 4,503

6,758 / 4,552

7,581 / 6,388

10,738 / 7,732

1 000 000

1 106,628 / 685,847

781,340 / 467,193

2 306,907 / 1 528,505

1 738,395 / 963,587

10 000 000

15 032,813 / 10 241,234

11 590,615 / 7 855,197

25 407,261 / 19 368,161

20 888,186 / 15 924,898

Микросекунды, byte-long-byte и byte-byte-long, .NET 10 

Разница от 1,19 до 1,80 раза в зависимости от машины и размера массива. Работа одинаковая: те же три сложения на элемент.

Причина

Разница берётся не из арифметики, а из объёма данных. Массив из миллиона структур по 24 байта занимает 24 миллиона байт, из структур по 16 байт — 16 миллионов. Через кеш процессора проходит на треть меньше, и обход занимает меньше времени.

У структур раскладка по умолчанию последовательная, поэтому порядок объявления сохраняется. Пустое место между полями появляется ровно там, где его создаёт порядок объявления.

Ошибки при замере

С классом такой приём не сработает. У классов раскладка автоматическая, и порядок полей выбирает среда выполнения. В таблице ниже размеры с учётом заголовка: к 24 и 16 байтам самой структуры добавляются 16 байт служебных полей. 

Тип

Порядок byte, long, byte

Порядок byte, byte, long

class

32

32

struct в упаковке

40

32

Байты, размер объекта в куче 

Массив должен быть заметно больше кеша процессора. На 10 000 элементов оба массива попадают в кеш, и разница падает до 1,19–1,48 раза против 1,51–1,80 на миллионе.

3. Массивы и строки

Что в заголовке объекта

У массивов и строк в таблице методов записан размер одного элемента — например, 4 для int[] и 2 для строки. Он занимает то же смещение, где у обычных типов записаны флаги, и понять, что там именно, можно по старшему биту. Вот что выводит отчёт:

  тип                  первое поле   размер элемента   база
 
  object без полей       0x00000200               нет     24
  class {int × 3}        0x00000200               нет     32
  byte[8]                0x800A0001                 1     24
  int[4]                 0x800A0004                 4     24
  object[2]              0x810A0008                 8     24
  строка из 4            0x80000002                 2     22

Что показывает замер

Длина

byte[]

int[]

long[]

object[]

строка

0

24

24

24

24

не выделяется

1

32

32

32

32

24

2

32

40

40

32

4

40

32

8

32

40

9

40

16

40

Байты, размер объекта в куче

byte[1] и byte[8] занимают поровну: под данные отводится не меньше 8 байт. Девятый байт добавляет ещё 8. Прочерк означает, что такая длина не замерялась.

Причина

База у массива равна 24: 16 байт служебных полей и 8 под длину. Дальше добавляется длина, умноженная на размер элемента, и результат выравнивается по 8.

У строки база 22, а не 24. В неё входит место под завершающий ноль, который рантайм добавляет после последнего символа. Памяти при этом всё равно выделяется не меньше 24 байт.

Что ещё есть в исходниках

Размер значимого типа можно узнать двумя способами, и они дают разные числа. Unsafe.SizeOf<bool>() возвращает 1, Marshal.SizeOf<bool>() — 4. У char наоборот: 2 и 1. Первый способ показывает раскладку в управляемой памяти, второй — во что тип превратится при передаче в неуправляемый код.

Marshal.SizeOf<DateTime>() не работает и бросает исключение: у типа нет однозначного представления для неуправляемого кода. При этом Unsafe.SizeOf<DateTime>() возвращает 8.

Структура из трёх полей byte занимает ровно 3 байта. Выравнивание до 8 добавляет не сама структура, а тип, в который она попадает: в массиве такие структуры идут вплотную, одна за другой.

Границы замеров

Все замеры сняты на x64. В 32-битной сборке указатель занимает 4 байта, служебные поля — 8, и числа получатся другими.

Размер объекта читается из таблицы методов по числовым смещениям. Публичного доступа к ней нет, и при изменении раскладки чтение перестанет работать: сверка это заметит и остановит прогон.

Код из статьи

  • ObjectSizeProof — замеры, отчёты и выгрузки с четырёх машин

Ссылки

Всем удачи и до новых встреч!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Знали, что порядок полей в структуре меняет её размер?
58.82%Все знают10
11.76%Знал, но не думал, что настолько2
29.41%А чё, так можно было что ли!5
Проголосовали 17 пользователей. Воздержавшихся нет.