В структуре три поля: 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 — замеры, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!

