А чё, так можно было? Как порядок полей в структуре ускоряет код в 1,8 раза
При разработке производительных приложений обычно обращают внимание на алгоритмы, многопоточность и эффективность запросов. Однако заметный выигрыш иногда даёт гораздо более простая мера - изменение порядка полей в структуре. Перестановка нескольких переменных способна одновременно сократить объём памяти и ускорить обработку массивов почти в два раза.
Рассмотрим структуру из трёх полей: `byte`, `long` и `byte`. При таком порядке она занимает 24 байта. Если же объявить поля как `byte`, `byte`, `long`, размер уменьшается до 16 байт. Код при этом логически не меняется: меняется только расположение данных в памяти. Для массива из миллиона элементов это означает сокращение объёма с 24 до 16 МБ, а последовательный обход в проведённых измерениях ускоряется в 1,19-1,8 раза.
Откуда берётся разница в размере
Процессор обращается к данным эффективнее, когда они размещены с учётом естественного выравнивания. В 64-разрядной среде значение типа `long` обычно должно начинаться на границе 8 байт. Поэтому после первого `byte` среда выполнения добавляет семь байт заполнения, затем размещает `long`, а последнее поле снова оказывается в отдельном восьмибайтном блоке.
В результате поля имеют такие смещения:
- `byte, long, byte`: `A = 0`, `B = 8`, `C = 16`, итоговый размер - 24 байта;
- `byte, byte, long`: `A = 0`, `B = 1`, `C = 8`, итоговый размер - 16 байт.
Во втором варианте два маленьких поля помещаются в один блок, после чего на выровненной границе начинается `long`. Именно это и есть практическая основа, на которой строится оптимизация структуры данных в памяти.
Как устроен объект в куче
Размер экземпляра класса складывается не только из пользовательских полей. Перед данными объект содержит два служебных машинных слова: указатель на таблицу методов и заголовок блока синхронизации. В 64-разрядном процессе каждое занимает по 8 байт.
Поэтому даже пустой объект обычно занимает 24 байта: 16 байт приходится на служебную информацию, ещё минимум 8 байт резервируется под данные. Дальнейший рост происходит шагами по 8 байт, поскольку выделение памяти выравнивается по размеру указателя.
Именно поэтому восемь полей типа `bool` могут занимать столько же места, сколько объект без пользовательских данных. Девятое поле уже увеличивает размер сразу на 8 байт. Похожая картина наблюдается и для других типов: граница для `char` проходит между четырьмя и пятью полями, а для `int` - между двумя и тремя.
Одиночное поле, помещающееся в восьмибайтный блок, не всегда меняет общий размер объекта. Это справедливо для `bool`, `byte`, `short`, `char`, `int`, `float`, `long`, `double`, `nint` и ссылочных типов в зависимости от уже занятого пространства. А `decimal` и `Guid`, каждый из которых занимает 16 байт, требуют большего объёма и приводят к размеру объекта 32 байта.
Почему маленький объект может не попасть в замер
Современный JIT умеет устранять ненужные выделения. Начиная с .NET 9 объект, который не покидает пределы метода и фактически нигде не используется, может вообще не создаваться в куче. В таком случае измерительный инструмент покажет нулевое выделение, хотя в исходном коде присутствует оператор `new`.
Чтобы избежать оптимизации, объект нужно сохранить в поле или передать дальше так, чтобы JIT не мог доказать его ненужность. Отдельный случай - пустая строка: в среде выполнения существует общий экземпляр строки нулевой длины, поэтому `new string('a', 0)` не обязательно приводит к новому выделению памяти.
Порядок полей и скорость массивов
Проверка проводилась на четырёх системах:
- Intel Core i9-10900KF;
- AMD Ryzen 9 5950X;
- Intel Xeon W-2255;
- Intel Xeon Silver 4314.
Использовались Windows 10, Windows Server 2022, 64-разрядные процессы и .NET 8, 9 и 10. Размеры объектов на всех конфигурациях совпали, а производительность различалась в зависимости от процессора, объёма массива и состояния кэшей.
Массив из структур `byte-long-byte` требует больше памяти и чаще вытесняет полезные данные из кэша процессора. Вариант `byte-byte-long` плотнее упакован: на каждый элемент приходится 16 байт вместо 24. При последовательном чтении это сокращает число загрузок из кэша и уменьшает давление на пропускную способность памяти.
В тесте для каждого элемента выполнялись одинаковые операции - складывались все три поля. Для массива из 10 000 элементов время составляло примерно от 4,5-10,7 мкс для компактного варианта против 5,9-10,7 мкс для исходного. На миллионе элементов разрыв становился заметнее: на отдельных системах обработка занимала около 467-964 мкс против 781-2307 мкс. Для десяти миллионов элементов разница также сохранялась и достигала 1,8 раза.
Таким образом, ускорение появляется не благодаря более простой арифметике. Процессор выполняет те же операции, но читает меньший объём данных. Это наглядный пример того, как порядок полей структуры влияет на производительность.
Практические правила проектирования структур
При проектировании структур небольшие поля обычно стоит группировать рядом: `byte`, `bool`, `short` и другие типы малого размера лучше размещать до крупных полей, если это не противоречит требованиям к совместимости или сериализации. Такой подход помогает избежать внутренних промежутков, которые компилятор заполняет байтами выравнивания.
Однако слепо сортировать поля по размеру нельзя. Если структура используется во взаимодействии с нативным кодом, бинарными файлами или сетевым протоколом, порядок может быть частью внешнего контракта. В таких случаях изменение раскладки способно нарушить совместимость, даже если с точки зрения управляемого кода структура станет компактнее.
Проверять результат лучше инструментами, а не предположениями. Для этого подходят `Unsafe.SizeOf
Эффект особенно заметен в горячих циклах, индексах, таблицах, игровых движках, обработке изображений, финансовых расчётах и системах, работающих с большими потоками однотипных записей. Если структура используется один раз и содержит несколько полей, выигрыш будет небольшим. Но при миллионах элементов даже экономия в несколько байт превращается в мегабайты и напрямую влияет на кэширование.
В итоге уменьшение размера структуры данных - это не косметическая оптимизация. Грамотное выравнивание полей структуры C++ и аналогичные принципы в .NET помогают снизить расход памяти, повысить плотность данных в кэше и ускорить последовательный обход. В совокупности это даёт практическую оптимизацию памяти и скорости программ без изменения алгоритмов и бизнес-логики.

