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