Недавно на работе мы столкнулись с большим потреблением памяти в игре. Связано оно было с хранением большого количества информации о террейне и свойствах его клеток, которое нужно игре для разных геймплейных расчетов. Проблема насущная, особенно остро проявляется как раз в больших массивах или матрицах с большими размерностями.

Довольно типичный случай — у вас есть вполне себе нормальная структура, которая хранит информацию об одном объекте. Но самих объектов очень и очень много. Скажем, у вас 1000x1000 клеток террейна. А это уже целый миллион объектов! И вот ваша структура размером с несчастные 32 байта множится миллион раз и разрастается до объемов 30.5 Mб оперативной памяти.

Пожалуй, самым верным решением в данном случае будет кардинально пересмотреть то, как хранится в памяти весь этот огромный массив данных. Есть большое количество вариантов, куда можно пойти копать:

  • Стриминг. В один момент времени загружаем из диска в память небольшое количество данных, которые интересуют нас в конкретный момент. То, что неактуально — выгружаем

  • Сжатие данных. Храним данные в упакованном, сжатом виде. При запросе на чтение распаковываем их на лету

  • Хитрые структуры данных, которые позволяют хранить одинаковые соседние данные в очень компактном виде. Яркий пример — квадро-деревья

Но эта статья — она не про эти умные решения, она чуть приземленнее. Прежде чем приступать к дорогостоящим по времени и ресурсам переделкам, стоит совершить, так сказать, нулевой шаг оптимизации — попробовать оптимизировать саму структуру или класс, хранящий информацию, так, чтобы он стал занимать меньше места. Сэкономим на спичках!

Сферический пример

Для наглядности возьмем чистенький теоретический пример. У нас есть структура PersonInfo и некоторые ассоциированные с ней типы:

enum class RoleType {
    Employee,
    Student,
    Contractor,
    Retired
};

struct EmployeeData {};
struct StudentData {};
struct ContractorData {};
struct RetiredData {};

struct PersonInfo {
    uint16_t id;
    int age;
    uint32_t salary;

    bool isMarried;
    bool hasDrivingLicense;
    bool isRemoteWorker;
    bool hasChildren;
    bool ownsHouse;
    bool isSmoker;
    bool isShareholder;

    RoleType role;
    void* roleData;

    bool isAvailable;
};

Что здесь происходит:

  • id. 16-битный ID сотрудника

  • age. Возраст сотрудника

  • salary. Ззаплата сотрудника, представлена в некотором внутреннем decimal-формате, который мы для простоты опишем в виде 32-битного числа

  • Набор разных bool-полей с различной информацией о сотруднике

  • role и roleData. Пара полей, описывающих роль сотрудника и данные, ассоциированные с конкретной ролью. По сути это tagged union на коленке. Для роли RoleType::Employee за void* будет прятаться EmployeeData*, для RoleType::StudentStudentData* и т.д.

  • isAvailable. Доступен ли сотрудник данный момент или он в отпуске/болеет и т.п.

Сотрудников много, очень много. И у нас есть приложение, которое хранит в памяти большое количество объектов PersonInfo. Наша задача — уменьшить объем потребляемой памяти, занимаемой этим большим массивом с PersonInfo.

Дисклеймеры, оговорки

Давайте вкратце очертим условия, в которых работает приложение:

  • 64-битная платформа

  • Подразумеваем типовой промышленный компилятор типа gcc, msvc, clang, icc с дефолтными флагами компиляции

  • Теоретически memory layout структуры может отличаться от компилятора к компилятору, от платформы к платформе. На практике для вышеозвученных компиляторов и для всего, что будет показано ниже Godbolt (он же — Compiler Explorer) выдает одни и те же результаты по memory layout структуры

  • Мы не будем использовать bitfields. Максимально implementation-defined вещь, на которую не стоит полагаться, если вам нужна хоть какая-то портабельность

Отправная точка

Итак, наша исходная структура struct PersonInfo:

struct PersonInfo {
    uint16_t id;
    int age;
    uint32_t salary;

    bool isMarried;
    bool hasDrivingLicense;
    bool isRemoteWorker;
    bool hasChildren;
    bool ownsHouse;
    bool isSmoker;
    bool isShareholder;

    RoleType role;
    void* roleData;

    bool isAvailable;
};

имеет следующее расположение полей в памяти:

Итого — структура занимает 40 байт и в ней есть несколько нехороших зияющих дыр в памяти. Это padding, который призван расположить поля структуры так, чтобы они были правильно выровнены в памяти относительно своего размера. Компилятор не имеет право переставлять местами поля структуры, поэтому ничего соптимизировать самостоятельно не сможет. Особенно коробит 7 потраченных байт в конце структуры — PersonInfo имеет выравнивание по 8 байт, поэтому последний bool, который вылез на 32-ой байт заставил структуру отъесть сразу 8 байт.

Явление широко известное и описано в сотнях статей по всему интернету. Если вы не понимаете, что и почему здесь происходит, вы можете смело гуглить "struct padding" или "c++ alignment", чтобы изучить вопрос.

Перемешиваем структуру

Самый простой и наиболее распространенный способ борьбы с padding'ом — переставить поля местами от наибольших полей к наименьшим:

struct PersonInfo {
    void* roleData;
    uint32_t salary;
    int age;
    RoleType role;
    uint16_t id;

    bool isMarried;
    bool hasDrivingLicense;
    bool isRemoteWorker;
    bool hasChildren;
    bool ownsHouse;
    bool isSmoker;
    bool isShareholder;
    bool isAvailable;
};

Это самый простой на свете и фактически бесплатный трюк, который не просит вообще ничего и дает мгновенный результат:

Эта картинка выглядит куда приятнее, и мы смогли сэкономить 8 байт, не сделав фактически ничего! Теперь мы занимаем 32 байта.

Урезаем бюджеты

При пристальном взгляде на структуру становится ясно, что есть пара мест, где мы не потеряем ничего, если немного урежем размерность типов.

Первый кричащий случай — поле age. На него без задней мысли выделили int, хотя мы не планируем учитывать еще не родившихся сотрудников с отрицательным возрастом и библейских долгожителей с показателями 900+. 640Kb uint8_t на самом деле хватит всем и даст нам выкинуть лишних 3 байта.

Второй момент не такой очевидный — это enum class RoleType. Он объявлен по-простому, без указания underlying-типа, что дефолтит его до того же самого int, который мы уже один раз забороли. Так как RoleType имеет всего 4 варианта, ему бы хватило и двух бит, но мы дадим ему целых 8, потому что меньше не можем: enum class RoleType : uint8_t

Посмотрим на получившийся код:

enum class RoleType : uint8_t {
    Employee,
    Student,
    Contractor,
    Retired
};

struct PersonInfo {
    void* roleData;
    uint32_t salary;
    uint16_t id;
    uint8_t age;
    RoleType role;

    bool isMarried;
    bool hasDrivingLicense;
    bool isRemoteWorker;
    bool hasChildren;
    bool ownsHouse;
    bool isSmoker;
    bool isShareholder;
    bool isAvailable;
};

Все еще не так много отступлений от оригинала, но посмотрите на разметку памяти:

Ура, мы добились плотнейшей упаковки и уместились в 24 байта.

Жмем шакалов

В массовых языках программирования (а может быть и во всех) есть один неприятный момент, связанный с булевыми значениями: логически они представляют один бит информации, на практике же они реализованы как 1-байтовые типы, т.е. занимают в 8 раз больше информации, чем необходимо! В некоторых языках и того хуже. Таковы реалии — машинам проще оперировать целым машинным словом, чем вычленять отдельные биты.

Если внимательно посмотреть на наше текущее положение полей в памяти, приведенное выше --^, можно увидеть любопытный момент: восемь булевых выстроились в ровный ряд. 8 бит, которые распухли до 8 байт — 7 байт, которые мы потеряли. И мы можем отлично упаковать их в один uint8_t (или std::byte), чтобы наконец-то восстановить справедливость.

Здесь, конечно, код будет модифицирован существенно, ведь нам придется убрать 8 публичных полей и заменить их одним приватным. А на месте булевых мы расположим интерфейс из геттеров и сеттеров:

struct PersonInfo {
    void* roleData;
    uint32_t salary;
    uint16_t id;
    uint8_t age;
    RoleType role;

    bool isMarried() const;
    bool hasDrivingLicense() const;
    bool isRemoteWorker() const;
    bool hasChildren() const;
    bool ownsHouse() const;
    bool isSmoker() const;
    bool isShareholder() const;
    bool isAvailable() const;

    void setIsMarried(bool val);
    void setHasDrivingLicense(bool val);
    void setIsRemoteWorker(bool val);
    void setHasChildren(bool val);
    void setOwnsHouse(bool val);
    void setIsSmoker(bool val);
    void setIsShareholder(bool val);
    void setIsAvailable(bool val);

private:
    static constexpr uint8_t IsMarriedMask         = 1 << 0;
    static constexpr uint8_t HasDrivingLicenseMask = 1 << 1;
    static constexpr uint8_t IsRemoteWorkerMask    = 1 << 2;
    static constexpr uint8_t HasChildrenMask       = 1 << 3;
    static constexpr uint8_t OwnsHouseMask         = 1 << 4;
    static constexpr uint8_t IsSmokerMask          = 1 << 5;
    static constexpr uint8_t IsShareholderMask     = 1 << 6;
    static constexpr uint8_t IsAvailableMask       = 1 << 7;

    bool getFlag(uint8_t mask) const;
    void setFlag(uint8_t mask, bool val);
    
    uint8_t m_flags;
};

inline bool PersonInfo::getFlag(uint8_t mask) const {
    return m_flags & mask;
}

inline void PersonInfo::setFlag(uint8_t mask, bool val) {
    m_flags = val ? (m_flags | mask) : (m_flags & ~mask);
}

inline bool PersonInfo::isMarried() const { return getFlag(IsMarriedMask); }
inline bool PersonInfo::hasDrivingLicense() const { return getFlag(HasDrivingLicenseMask); }
inline bool PersonInfo::isRemoteWorker() const { return getFlag(IsRemoteWorkerMask); }
inline bool PersonInfo::hasChildren() const { return getFlag(HasChildrenMask); }
inline bool PersonInfo::ownsHouse() const { return getFlag(OwnsHouseMask); }
inline bool PersonInfo::isSmoker() const { return getFlag(IsSmokerMask); }
inline bool PersonInfo::isShareholder() const { return getFlag(IsShareholderMask); }
inline bool PersonInfo::isAvailable() const { return getFlag(IsAvailableMask); }
inline void PersonInfo::setIsMarried(bool val) { return setFlag(IsMarriedMask, val); }

inline void PersonInfo::setHasDrivingLicense(bool val) { return setFlag(HasDrivingLicenseMask, val); }
inline void PersonInfo::setIsRemoteWorker(bool val) { return setFlag(IsRemoteWorkerMask, val); }
inline void PersonInfo::setHasChildren(bool val) { return setFlag(HasChildrenMask, val); }
inline void PersonInfo::setOwnsHouse(bool val) { return setFlag(OwnsHouseMask, val); }
inline void PersonInfo::setIsSmoker(bool val) { return setFlag(IsSmokerMask, val); }
inline void PersonInfo::setIsShareholder(bool val) { return setFlag(IsShareholderMask, val); }
inline void PersonInfo::setIsAvailable(bool val) { return setFlag(IsAvailableMask, val); }

Страшно и некрасиво, конечно. Но период бесплатных и даже дешевых оптимизаций кончился на предыдущих главах. Дальнейшая выжимка структуры требует бóльших жертв и дает меньше выхлопа — такова реальность. Но давайте посмотрим, что мы выиграли:

Нуу, мы конечно сэкономили наши 7 байт, как и планировалось, но к сожалению так и не сократили размер структуры. Она осталась 24 байта, поскольку alignof(PersonInfo) — это минимальная дискретная величина, на которую размер структуры может сократиться.

Хорошая новость заключается в том, что нам достаточно любой самой простейшей дальнейшей оптимизации, чтобы добиться действительного сокращения размеров структуры, поскольку для этого нам не хватает буквально одного байта (<-- пасхалка для людей из мезозоя).

Прячем закладку

Время взглянуть на наш самодельный "tagged union из Rust/Zig":

void* roleData;
RoleType role;

Немного рассуждений:

  • Как подмечалось выше, несмотря на то, что енум RoleType в текущий момент использует 8-битовый underlying type, на деле представляет из себя 4 варианта, которым достаточно всего 2 бита для размещения

  • roleData — это указатель для 64-битной машины. И как говорилось ранее, он занимает 8 байт на любой уважающей себя платформе (мы сидим только на таких!). Это в свою очередь означает, что адрес указателя всегда кратен восьми — ибо выравнивание. Для числа, кратного восьми, справедливо, что его 3 младших бита будут всегда равны нулю. На 32-битной машине, к слову, адрес будет кратен четырем, а потому всегда нулевыми будут 2 младших бита адреса

  • Понимаете к чему я клоню? Даже неважно x86 или x64 — наш enum class RoleType всегда может быть вшит в тело указателя void* roleData, все еще позволяя нам восстановить и оригинальный указатель и значение перечисления. Другими словами, мы можем "растворить" поле role внутри младших бит указателя roleData.

Круто? Круто. Делаем:

struct PersonInfo {
private:
	uintptr_t m_role;

public:
	uint32_t salary;
	uint16_t id;
	uint8_t age;

	RoleType role() const;
	void* roleData() const;
	...

	void setRole(RoleType val);
	void setRoleData(void* val);
	...

private:
	...
	static constexpr uintptr_t RoleMask = 0b11;
	...
	
	uint8_t m_flags;
};

inline RoleType PersonInfo::role() const {
	uint8_t roleTypeRaw = m_role & RoleMask;
	return static_cast<RoleType>(roleTypeRaw);
}

inline void* PersonInfo::roleData() const {
	uintptr_t roleDataRaw = m_role & ~RoleMask;
	return reinterpret_cast<void*>(roleDataRaw);
}

inline void PersonInfo::setRole(RoleType val) {
	uint8_t roleTypeRaw = static_cast<uint8_t>(val);
	uintptr_t roleDataRaw = m_role & ~RoleMask;
	m_role = roleDataRaw | roleTypeRaw;
}

inline void PersonInfo::setRoleData(void* val) {
	assert(val & RoleMask == 0);

	uint8_t roleTypeRaw = m_role & RoleMask;
	uintptr_t roleDataRaw = reinterpret_cast<uintptr_t>(val) & ~RoleMask;
	m_role = roleDataRaw | roleTypeRaw;
}

Структура обезображена. Из смешного — мы все еще должны сохранять порядок полей, чтобы не порушить предыдущую оптимизацию padding'а, и нам пришлось жонглировать спецификаторами доступа: private, public, private. Не забудьте подкупить ревьювера вашей ветки, чтобы он закрыл на это глаза.

UPD: Важное примечание от комментатора @AlexeyMartynov: явное введение разных спецификаторов доступа в вашу структуру или класс теоретически может иметь неожиданный эффект на порядок полей, который может порушить все ваши планы и ожидания. Вооружайтесь дебагом или распечатывайте offsetof(type, member), чтобы знать, что происходит наверняка! В конце статьи я приложу шпаргалку, как это сделать

И вот, в конце концов мы получаем вот это:

Это так красиво, что я не могу выразить словами. Просто проскрольте вверх до самого первого memory layout и узрите, какую работу удалось проделать над структурой. И это при том, что она не потеряла своего функционала.

Но теперь она весит 16 байт вместо 40 байт. Я считаю, что экономия 60% памяти на ровном месте — это достойно. Особенно если учитывать, что нам не пришлось принимать для этого никаких судьбоносных архитектурных решений или переписывать пол-проекта под новую парадигму обращения с данными.

Справедливости ради, эти 60% экономии встали нам в -60% читаемости кода. Но тут уже вопрос приоритетов.

Бонусные уровни

Можно ли придумать что-то еще? Конечно можно! Способов уменьшить вашу память бесконечное, поскольку вы можете применять бесконечное количество эвристик в зависимости от того, что конкретно представляют из себя ваши данные.

На примере со структурой PersonInfo я смог показать лишь ограниченный набор базовых ухищрений. А сейчас мы быстро пробежимся по нескольким техникам, которые тоже заслуживают упоминания

Union

Если у вас есть пересекающиеся данные, которые не могут существовать в один момент времени вместе, вы можете посмотреть в сторону union. Только будьте предельно осторожны и имейте в ввиду, что с union предельно просто наступить на UB-мину.

Вот сферический пример в вакууме, где в один момент времени существует только одна активная группа полей:

enum class CellStatus : uint8_t {
	Metabolic,
    AcidicInhibitor,
    Signaling,
    Energy
};

struct Cell {
    CellStatus status;

    // CellStatus::Metabolic
    float metabolicLevel;
    
    // CellStatus::AcidicInhibitor
    uint16_t inhibitorClearanceTicks;
    uint8_t acidityLevel;
    uint8_t acidityExposure;
    
    // CellStatus::Signaling
    uint8_t signalLevel;
    uint8_t signalLevelPrev;
    
    // CellStatus::Energy
    uint16_t energyReserve;
    
    // ...
    // lots of other fields
    // ...
};

Даже не спрашивайте, что здесь происходит — это science fiction. И пример, конечно, тоже топорный. Обычно структуры не так откровенно кричат, что они — это просто скрытый union.

Простые подсчеты показывают, что в таком плоском виде интересующие поля занимают 4 + 2 + 1 + 1 + 1 + 1 + 2 = 12 байт (не считая padding). Будучи в составе union эти же поля стали бы занимать столько, сколько занимает самая большая группа — в нашем случае их две, обе по 4 байта (поля для Cell::AcidicInhibitor и для Cell::Metabolic). 4 байта вместо 12 — в каком-то случае это очень даже неплохо.

Вопрос лишь в цене переделки:

struct Cell {
    CellStatus status;

    float metabolicLevel() const;
    uint16_t inhibitorClearanceTicks() const;
    uint8_t acidityLevel() const;
    uint8_t acidityExposure() const;
    uint8_t signalLevel() const;
    uint8_t signalLevelPrev() const;
    uint16_t energyReserve() const;

    void setMetabolicLevel(float val);
    void setInhibitorClearanceTicks(uint16_t val);
    void setAcidityLevel(uint8_t val);
    void setAcidityExposure(uint8_t val);
    void setSignalLevel(uint8_t val);
    void setSignalLevelPrev(uint8_t val);
    void setEnergyReserve(uint16_t val);

    // ...
    // lots of other fields
    // ...

private:
    union {
        float m_metabolicLevel;
        struct {
            uint16_t m_inhibitorClearanceTicks;
            uint8_t m_acidityLevel;
            uint8_t m_acidityExposure;
        };
        struct {
            uint8_t m_signalLevel;
            uint8_t m_signalLevelPrev;
        };
        uint16_t m_energyReserve;
    };
};

inline float Cell::metabolicLevel() const {
    assert(status == CellStatus::Metabolic);
    return m_metabolicLevel;
}
inline uint16_t Cell::inhibitorClearanceTicks() const {
    assert(status == CellStatus::AcidicInhibitor);
    return m_inhibitorClearanceTicks;
}
inline uint8_t Cell::acidityLevel() const {
    assert(status == CellStatus::AcidicInhibitor);
    return m_acidityLevel;
}
inline uint8_t Cell::acidityExposure() const {
    assert(status == CellStatus::AcidicInhibitor);
    return m_acidityExposure;
}
inline uint8_t Cell::signalLevel() const {
    assert(status == CellStatus::Signaling);
    return m_signalLevel;
}
inline uint8_t Cell::signalLevelPrev() const {
    assert(status == CellStatus::Signaling);
    return m_signalLevelPrev;
}
inline uint16_t Cell::energyReserve() const {
    assert(status == CellStatus::Energy);
    return m_energyReserve;
}

inline void Cell::setMetabolicLevel(float val) {
    assert(status == CellStatus::Metabolic);
    m_metabolicLevel = val;
}
inline void Cell::setInhibitorClearanceTicks(uint16_t val) {
    assert(status == CellStatus::AcidicInhibitor);
    m_inhibitorClearanceTicks = val;
}
inline void Cell::setAcidityLevel(uint8_t val) {
    assert(status == CellStatus::AcidicInhibitor);
    m_acidityLevel = val;
}
inline void Cell::setAcidityExposure(uint8_t val) {
    assert(status == CellStatus::AcidicInhibitor);
    m_acidityExposure = val;
}
inline void Cell::setSignalLevel(uint8_t val) {
    assert(status == CellStatus::Signaling);
    m_signalLevel = val;
}
inline void Cell::setSignalLevelPrev(uint8_t val) {
    assert(status == CellStatus::Signaling);
    m_signalLevelPrev = val;
}
inline void Cell::setEnergyReserve(uint16_t val) {
    assert(status == CellStatus::Energy);
    m_energyReserve = val;
}

Страшно? Страшно. Но такова цена. Ассертами я попытался оградить от случаев, когда мы читаем или пишем в неактивное поле.

Кто-то мог бы сказать, что в современном мире для этого существует std::variant<>, и я даже попытался им воспользоваться для данной задачи — код стал еще не выносимее — всем спасибо, всех люблю, но я — пас.

Bitfields

Несмотря на то, что в начале статьи я выдал битовым полям красную карточку, они все еще являются валидным инструментом. И кому-то они вполне могут подойти. Например, если вы знаете, что вы всегда на одной платформе, с одним компилятором, вам не нужен стабильный ABI для совместимости с чем-то там, то вы получаете в руки мощный и в своем роде красивый инструмент:

enum class RoleType {
    Employee,
    Student,
    Contractor,
    Retired
};

struct PersonInfo {
    void* roleData;
    uint32_t salary;
    int age;
    uint16_t id;

    RoleType role : 2;
    bool isMarried : 1;
    bool hasDrivingLicense : 1;
    bool isRemoteWorker : 1;
    bool hasChildren : 1;
    bool ownsHouse : 1;
    bool isSmoker : 1;
    bool isShareholder : 1;
    bool isAvailable : 1;
};

Поздравляю, вы справились с задачей упаковки булей куда меньшей кровью. Более того, посмотрите — мы и RoleType role уместили в 2 бита!

Только вот, когда я говорил, что компиляторы творят с битовыми полями непонятно что, я вовсе не шутил — посмотрите, какой memory layout выдает MSVC для приведенного выше кода:

Вы видите эти зияющие дыры, убивающие всю идею битовых полей? Штош. На самом деле я немного смухлевал, объявив енум RoleType без underlying type. Если вернуть enum class RoleType : uint8_t, то получится

Что к чему, неясно, но вам придется самостоятельно по месту выяснять, что придумал для вас компилятор.

Упраздняем вещественные числа

Очень часто в структурах с double- или float-полями за счет жертвы точностью или жертвы диапазоном возможных значений можно выкроить заветное свободное место.

Первое и самое простое, что можно сделать — заменить double на float везде, где вам не нужна точность double. Это сэкономит вам половину места.

Второй трюк, который часто применяют в играх при передаче вещественных чисел по сети — квантизация вещественных чисел. Мы конвертируем наш float в какой-нибудь uint16_t или uint8_t заранее оговаривая, какой у конкретной переменной будет допустимый диапазон значений. Таким образом, регулируя количество бит и диапазон, мы косвенно влияем на результирующую точность числа. Если точность нас не устраивает, увеличиваем размерность.

Я не большой эксперт в таких вычислениях, но идея примерно такая:

template <typename Uint>
Uint pack(float value, float minVal, float maxVal) {
	assert(value >= minVal && value <= maxVal);
	constexpr int bits = sizeof(Uint) * 8;
	
	const float scale = (std::pow(2, bits) - 1) / (maxVal - minVal);
	return static_cast<Uint>(std::round((value - minVal) * scale));
}

template <typename Uint>
float unpack(Uint packed, float minVal, float maxVal) {
	constexpr int bits = sizeof(Uint) * 8;
	
	const float scale = (maxVal - minVal) / (std::pow(2, bits) - 1);
	return static_cast<float>(packed) * scale + minVal;
}

int main()
{
    float val = 10.f;

    uint8_t packed8 = pack<uint8_t>(val, 0.f, 180.f);
    float unpacked8 = unpack(packed8, 0.f, 180.f);

    uint16_t packed16 = pack<uint16_t>(val, 0.f, 180.f);
    float unpacked16 = unpack(packed16, 0.f, 180.f);

    std::cout << "Original: " << val << "\n";
    std::cout << "Packed8: " << +packed8 << ", Unpacked8: " << unpacked8 << "\n";
    std::cout << "Packed16: " << packed16 << ", Unpacked16: " << unpacked16 << "\n";
}

Результат:

Original: 10
Packed8: 14, Unpacked8: 9.88235
Packed16: 3641, Unpacked16: 10.0005

10 градусов превратились в 9.88235 в случае упаковки числа в 1 байт. Для каких-то случаев такая потеря точности более чем приемлема. Зато вместо 4 байт мы имеем 1.

bfloat16

Это мое любимое. Если вы совсем ни во что не ставите точность ваших floatов, вы можете упаковать их в формат bfloat16 . Сделать это очень просто из-за свойств вещественных чисел, которыми их наделил стандарт IEEE-754.

Суть: вы можете превратить 32-битный float в 16-битный, просто пожертвовав младшими битыми мантиссы. По совпадению младшие биты мантиссы — это в принципе младшие биты float-числа. Т.е. простая арифметика сдвигов — и все готово!

class bfloat16 {
public:
    static_assert(sizeof(float) == sizeof(uint32_t));

    explicit bfloat16(float f)
        : m_data(static_cast<uint16_t>(std::bit_cast<uint32_t>(f) >> 16)) { }

    float get() const {
        return std::bit_cast<float>(static_cast<uint32_t>(m_data) << 16);
    }

private:
    uint16_t m_data;
};

int main()
{
    bfloat16 pi(3.14159f);
    std::cout << "bfloated PI: " << pi.get() << "\n";
}

Вывод:

bfloated PI: 3.14062

Не так уж и плохо.

А если вы знаете, что ваши числа могут быть только положительными, мы можем выкроить себе дополнительный бит для точности за счет знакового бита!

class ubfloat16 {
public:
    static_assert(sizeof(float) == sizeof(uint32_t));

    explicit ubfloat16(float f)
        : m_data(static_cast<uint16_t>((std::bit_cast<uint32_t>(f) << 1) >> 16)) { }

    float get() const {
        return std::bit_cast<float>(static_cast<uint32_t>(m_data) << 15);
    }

private:
    uint16_t m_data;
};

Сравним оба класса:

int main()
{
    const float val = 35001.02f;
    bfloat16 bfloat(val);
    ubfloat16 ubfloat(val);

    std::cout << std::setprecision(9) << " original: " << val << "\n";
    std::cout << std::setprecision(9) << " bfloated: " << bfloat.get() << "\n";
    std::cout << std::setprecision(9) << "ubfloated: " << ubfloat.get() << "\n";
}

Вывод:

 original: 35001.0195
 bfloated: 34816
ubfloated: 34944

Чем больше в абсолюте число, тем хуже точность и больше потери.

Подход с bfloat16 в частности на полную применяется при обучении нейросетей. Там и в один байт замечательно умещают их миллиарды вещественных весов, и нейросетям в целом норм.

Appendix I. Узнаем memory layout

Как узнать точный memory layout вашей структуры? Пройдемся по всем известным мне способам

GCC

У gcc есть warning -Wpadded, который подскажет, если у структуры есть padding-дыры. Не дает полной картинки, но что-то

Clang

У clang есть инструмент помощнее:

clang++ -Xclang -fdump-record-layouts file.cpp

Распечатает layout всех классов и структур

Visual Studio

Для MSVC в Visual Studio существует возможность навестись мышью на любой класс или структуру и в всплывшем окошке выбрать Memory Layout, где вы увидете схему подобную тому, что я привел в статье:

static_assert

Всегда можно проверить какие-то размеры прямо из вашего кода через static_assert. Для этого в языке есть такие помощники как sizeof, alignof, offsetof:

static_assert(sizeof(MyStruct) == 32);
static_assert(alignof(MyStruct) == 8);
static_assert(offsetof(MyStruct, field) == 8);

Но такие проверки вы можете проводить, только если у вас уже есть какое-то предположение или чуйка, как оно там располагается в памяти. А это не всегда так. поэтому...

Распечатываем на коленке

Если у вас нет надежного инструмента, чтобы узнать memory layout вашей структуры для вашего конкретного окружения, вы всегда можете воспользоваться все теми же sizeof, alignof, offsetof для того, чтобы реализовать свой небольшой инструмент для распечатки разметки памяти:

#define PRINT_MEMBER(T, member) \
    printMember(#member, offsetof(T, member), sizeof(((T*)0)->member))

template<typename T>
static void printHeader(const char* name)
{
	std::cout << "\n=== " << name << " ===\n";
	std::cout << "sizeof  : " << sizeof(T) << "\n";
	std::cout << "alignof : " << alignof(T) << "\n\n";
}

static const char* PADDING_NAME = "      xxx      ";
static std::size_t previousEnd = 0;

static void printLine(const char* name, std::size_t offset, std::size_t size)
{
	std::cout << "  " << std::setw(17) << std::left << name
		<< " offset=" << std::setw(3) << offset
		<< " size=" << size << "\n";
}

static void printMember(const char* name, std::size_t offset, std::size_t size)
{
	if (offset > previousEnd) {
		printLine(PADDING_NAME, previousEnd, offset - previousEnd);
	}
	printLine(name, offset, size);

	previousEnd = offset + size;
}

template<typename T>
static void printTailPadding()
{
	const size_t align = alignof(T);
	const size_t padding = (align - previousEnd % align) % align;
	if (padding == 0) {
		return;
	}
	printLine(PADDING_NAME, previousEnd, padding);
}

static void inspectPersonInfo()
{
	printHeader<PersonInfo>("PersonInfo");

	PRINT_MEMBER(PersonInfo, id);
	PRINT_MEMBER(PersonInfo, age);
	PRINT_MEMBER(PersonInfo, salary);
	PRINT_MEMBER(PersonInfo, isMarried);
	PRINT_MEMBER(PersonInfo, hasDrivingLicense);
	PRINT_MEMBER(PersonInfo, isRemoteWorker);
	PRINT_MEMBER(PersonInfo, hasChildren);
	PRINT_MEMBER(PersonInfo, ownsHouse);
	PRINT_MEMBER(PersonInfo, isSmoker);
	PRINT_MEMBER(PersonInfo, isShareholder);
	PRINT_MEMBER(PersonInfo, role);
	PRINT_MEMBER(PersonInfo, roleData);
	PRINT_MEMBER(PersonInfo, isAvailable);

	printTailPadding<PersonInfo>();

	std::cout << "\n";
}

int main() {
	inspectPersonInfo();
}

Вывод:

== PersonInfo ===
sizeof  : 40
alignof : 8

  id                offset=0   size=2
        xxx         offset=2   size=2
  age               offset=4   size=4
  salary            offset=8   size=4
  isMarried         offset=12  size=1
  hasDrivingLicense offset=13  size=1
  isRemoteWorker    offset=14  size=1
  hasChildren       offset=15  size=1
  ownsHouse         offset=16  size=1
  isSmoker          offset=17  size=1
  isShareholder     offset=18  size=1
        xxx         offset=19  size=1
  role              offset=20  size=4
  roleData          offset=24  size=8
  isAvailable       offset=32  size=1
        xxx         offset=33  size=7

Compiler Explorer

В дополнение к самописному решению вы можете пойти на сайт Godbolt (он же — Compiler Explorer).

Вставляете туда код с распечаткой разметки памяти для вашей структуры (прямо тот, что я привел выше), настраиваете сервис так, чтобы он не выводил ассемблер, а выполнял программу (да, Compiler Explorer поддерживает и такое) и смотрите распечатку memory layout структуры для любой платформы и любого компилятора.

Не все платформы позволяют адекватный запуск, но на основных компиляторах вы точно сможете все проверить.

Комментарии (32)


  1. AllFiction
    25.02.2026 16:22

    спасибо большое за ваш труд, прочитал статью с огромным удовольствием)

    кстати подобное шаманство работает не только в богоподобном си, но и я в других языках просто нужно знать подробности реализации. В том же Go можно структуру в байты напрямую похожим образом, без лишних телодвижений просто используя сам указатель на структуру и тут то важно правильно расположить что бы работало.


  1. AlexeyMartynov
    25.02.2026 16:22

    Есть тонкий момент: до С++23 аккуратно подложены грабельки:

    Member access specifiers may affect class layout: the addresses of non-static data members are only guaranteed to increase in order of declaration for the members not separated by an access specifier(until C++11)with the same access(since C++11).

    https://en.cppreference.com/w/cpp/language/access.html

    Из этого следует, что гарантия последовательного расположения есть только в рамках одной секции. Стало быть, примеры, начиная с упаковки "bool", следует поправить, увеличив количество геттеров и сеттеров, иначе размер может и не получится.

    Впрочем, я не знаю ни одного компилятора, который решился бы на подобные переупорядочения.


    1. AskePit Автор
      25.02.2026 16:22

      Ухх, какой подлый нюанс :) Он мне был не известен. Выходит, чтобы теоретически обезапаситься, есть два пути:

      • Пойти вашим путем и тотально замести все поля под private

      • Наоборот занести все под public, посыпать "приватные" поля комментариями "НЕ ТРОГАТЬ", а голову посыпать пеплом

      Мне вот интересно, если есть компилятор, который пользуется этой лазейкой, то как мог бы выглядеть порядок полей у класса с public и private секцией? public в памяти четные, private - нечетные? :)


      1. Deosis
        25.02.2026 16:22

        Компилятор вполне мог поменять секции местами, чтобы уменьшить паддинг,
        либо наоборот, чтобы выровнять размер структуры с размером кеш линии.


  1. mmMike
    25.02.2026 16:22

    имеет следующее расположение полей в памяти:
    ..
    Итого — структура занимает 40 байт

    А вот нифига. В некоторых аппаратных платформах структуры выравниваются ВСЕГДА на 4 байта. Включая bool. Так что попытка записать структуру из C/C++ кода на диск (как область памяти по указателю и sizeof(..)) на одной платформе и прочитать ее в память "как есть" (наивный подход) на другой платформе приведет к проблемам. Плавали... знаем. Ща поменьше стало (sparc RIP), но все равно мир не заканчивается x86.

    Так что, упаковка/распаковка данных из локального формата перед запись/чтением куда либо (диск, БД) - это вообще стандарт (должно быть).

    Ладно игра... А когда в WAL Postgre все данные выровнены на границу 4-х байт (понятно конечно откуда ноги растут) и даже на первый взгляд можно получить упаковкой экономию размера файлов от 1-15% (зависит от структуры/формата полей таблиц. Меньше прикладные данные - больше экономия на заголовках в процентах)
    И все ради "а побыстрее обрабатывать" (наверное)?
    Opensource ПО блин. И сейчас на PG огромные системы пытаются переносить. А по факту разработчиков основных PG можно по пальцем рук пересчитать.
    И банально некогда им оптимизировать.


    1. unreal_undead2
      25.02.2026 16:22

      Статья всё таки про эффективное использование памяти и кешей, а не про сериализацию. Так то надо помнить, что размер int не фиксирован, big endian никуда не делся и т.д.


      1. mmMike
        25.02.2026 16:22

        да. Скорее всего ОЗУ. Но это явно нигде не сказано и та же проблема касается сохранения на диске.


    1. ZvoogHub
      25.02.2026 16:22

      Doom из прошлого века, который сейчас запускают даже на принтерах, хранит ресурсы в файле wad. Это zip-подобный архив.

      Почему в архиве? Потому что в прошлом веке жёсткие диски были очень медленные. Операция

      1. прочитать маленький файл с медленного диска

      2. распаковать его в большой в памяти

      была быстрее чем сразу прочитать несжатые ресурсы с диска во время игры.

      Возможно и в задаче из статьи использование любого агоритма сжатия будет гораздо эффективней.


      1. LAutour
        25.02.2026 16:22

        Есть еще причина хранить ресурсы из большой кучи файлов в собственном файлом контейнере: из-за того отдельные файлы занимают на диске объем всегда кратный размеру кластера\сектора файловой системы.


      1. alliumnsk
        25.02.2026 16:22

        Вы этот файл в хекс вьюере открывали хоть раз? Он не сжатый. У версии 1.9 около 11 Мб, zip сжимает до 4.5 Мб. Дум запускался довольно долго. Главный эффект был в экономии места на жестком диске из-за того, что не было в дос файловых систем, эффективно работающих с большим числом мелких файлов (текстуры были 64х64 (!))


    1. AskePit Автор
      25.02.2026 16:22

      Я прикрылся параграфом "Дисклеймеры, оговорки" :) Но в целом согласен - зоопарк возможных платформ бесконечен, поэтому ничего точного в абсолюте быть не может. Но это тем не менее не мешает людям рассуждать о порядке полей, padding и пытаться уменьшить свои структуры.

      Я даже какое-то время поддерживал пропиетарный компилятор, где char был равен машинному слову, потому что почему бы нет, поэтому не понаслышке знаю, что все действительно возможно.

      Ну а читать/писать сырые данные между платформами - это совсем иная история, статья не про это, тут надо писать отдельную. И ее лучше писать вам :) у вас, кажется, обширный опыт в этой теме. Я могу козырнуть лишь #pragma pack, но подозреваю, что это не панацея. Особенно если еще есть разница LE/BE


      1. unreal_undead2
        25.02.2026 16:22

        #pragma pack нужен скорее чтобы иметь стабильное ABI, когда структуры фигурируют в интерфейсе; для записи в файл (особенно переносимый между платформами) всё равно недостаточно.


  1. AskePit Автор
    25.02.2026 16:22

    UPD: я посчитал, что статья была бы неполной, если не привести известные мне способы увидеть глазами memory layout интересующей нас структуры. Заинтересованным читать главу "Appendix I. Узнаем memory layout".

    Буду рад, если вы поделитесь своими способами - хорошими, плохими, злыми - любые подойдут.


  1. VBDUnit
    25.02.2026 16:22

    Получил огромное удовольствие, спасибо!

    Сам сталкивался с подобной проблемой когда в C# делал универсальную структуру для хранения векторов и скаляров. Структура имела размер 64 байта и должна была хранить длину вектора, тип элементов и сами значения. Проблема возникла с типом decmial, который весит 16 байт и в количестве 4 штуки занимает всё место.

    Выкрутился так

    Поля с размером и типом впихнул в 1 байт и разместил его там, где у decimal всегда нули. Да, у этого типа реально некоторые биты ВСЕГДА равны нулю. После этого извратил хранение длины и типа так, чтобы при длине 4 и типе decimal этот байт был всегда равен нулям. Профит: при хранении других типов (которые 8 байт и меньше) данные до туда не доходят из‑за ограничений по длине, а при четырёх decimal хранение данных, длины и типа в одном месте не противоречат друг другу.


    1. AskePit Автор
      25.02.2026 16:22

      достойные извороты) прятать информацию в чужих битах - самое приятное


  1. devoln
    25.02.2026 16:22

    Не понял, откуда гарантия, что младшие биты свободны? Указатель же void*, значит не подразумевает никакого выравнивания данных. По указателю может храниться например строка или бинарный массив, не выровненный на 4 байта. А то, что сам указатель в структуре выравнивается, к этому вообще отношения не имеет.

    По поводу битовых полей не понимаю, зачем их избегать. Вроде они везде одинаково работают кроме big/little-endian. Я проверял в compiler explorer на распространённых платформах и всех основных компиляторах. Кажется, только были какие-то нюансы между big и little-endian, но лучше их учесть, чем городить портянку с private и геттерами-сеттерами.

    А для сериализации можно завести тип с перевёрнутым порядком байт для big-endian архитектур, не переворачивая для little-endian. Назвать типа uint32LE/uint16LE. Тогда можно будет просто писать структуры на диск.


    1. Rio
      25.02.2026 16:22

      >По поводу битовых полей не понимаю, зачем их избегать

      Кроме BE/LE там достаточно своих приколов. Приведу пример из жизни.

      Оказалось, что один проект несколько лет работал неверно, не сообщал об ошибке, когда надо было. Когда это обнаружили, не сразу поняли, в чём прикол.

      #define ERR_CODE_CRITICAL (1 << 3)
      ...
      data.result = ERR_CODE_CRITICAL;

      Вот примерно так в поле структуры записывался код результата. Но внезапно оказывалось, что в поле result после этой записи — 0 (код для ERR_CODE_OK) вместо ожидаемого кода ошибки. Почему?

      А потому что автор изначального кода отвёл на поле result всего один бит, описав его так:

      typedef struct {
          ...
          int32_t result: 1;
      } DATA;

      Кодов тогда было всего два, ноль и один, оно работало как задумывалось.

      А потом в какой-то момент кто-то решил добавить других значений, и всё сломалось.

      Компилятор ошибками не ругался (инт в инт пишется же, всё окей), и никто ничего не заметил.

      И вот сидишь ты в отладке, смотришь на этот код, наводишь курсор на поле result, тебе IDE услужливо подсказывает: тип int32_t, всё окей! Шаг делаешь, туда восьмёрка пишется, а получается 0. Магия! )

      Т.е. чтобы изначально врубиться, что в этой строке может быть косяк, нужно непременно полезть в описание структуры и вручную глазами смотреть, что за поле такое, и как объявлено. Отличная фича для стрельбы себе в ногу, в общем.


      1. Ooaoo
        25.02.2026 16:22

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


        1. Rio
          25.02.2026 16:22

          "Просто ошибка" — это когда можно просто посмотреть на код и увидеть, что там ошибка. А здесь — нельзя просто посмотреть на код и увидеть, что там ошибка. Можно столкнуться случайно, когда уже поздно (что и произошло).


          1. Ooaoo
            25.02.2026 16:22

            Сталкивался я однажды с кодом, где были переменные вида _fpressure, _fPressure,fpressure,fPressure и все 4 они в разные моменты времени могли либо приравниваться между собой либо использованы в промежуточных расчетах. Там так же много лет была ошибка ибо в одном месте стояла не та переменная. Вот вроде можно просто посмотреть и увидеть, а вроде много лет смотрели разные люди этот код и никто не видел, ну плевались что то вроде какой дурак так написал, но никто не переделывал, работает же. Так же и в вашем примере. Один человек выделил 1 бит, другой не посмотрел и вылез за пределы. Но это не означает что битовые поля это неудобно или что они могу работать как то не так. Их можно как то не так использовать, это да.


            1. Rio
              25.02.2026 16:22

              Так никто и не говорит, что битовые поля это неудобно. Удобно. Но там, где нужно максимально надёжный и портабельный код иметь (а проблем с ним — не иметь), лучше их не юзать вовсе. У нас в конторе мы их вообще запретили, из-за специфики. Самолёты прогаем, код должен на куче разных архитектур работать, с разной разрядностью и разным порядком байтов, причём вперемешку (а в протоколах ой как хочется, бывает, битовые поля заюзать). Но многолетнаяя практика показала, что это в итоге выливается в боль. А мы не хотим страдать, хотим новые крутые фичи пилить, а не внезапно всплывающие старые баги отлаживать )

              >а вроде много лет смотрели разные люди этот код и никто не видел

              Судя по описанию, просто не вчитывались, потому что код страшненький был, это обычное дело. А в приведённом мной случае — код простой и читаемый, от которого не ждёшь подвоха.


              1. AskePit Автор
                25.02.2026 16:22

                Мы так же от них отказались - есть опасения за совместимость с версиями игры под консоли


  1. vanxant
    25.02.2026 16:22

    Поддержу комментатора выше, битовые поля для отдельных битов вполне безопасны. Проблемы начинаются с переносом многобитных полей между BE/LE платформами.


  1. ImagineTables
    25.02.2026 16:22

    inline bool PersonInfo::isMarried() const { return getFlag(IsMarriedMask); }
    inline bool PersonInfo::hasDrivingLicense() const { return getFlag(HasDrivingLicenseMask); }
    inline bool PersonInfo::isRemoteWorker() const { return getFlag(IsRemoteWorkerMask); }
    inline bool PersonInfo::hasChildren() const { return getFlag(HasChildrenMask); }
    inline bool PersonInfo::ownsHouse() const { return getFlag(OwnsHouseMask); }
    inline bool PersonInfo::isSmoker() const { return getFlag(IsSmokerMask); }
    inline bool PersonInfo::isShareholder() const { return getFlag(IsShareholderMask); }
    inline bool PersonInfo::isAvailable() const { return getFlag(IsAvailableMask); }
    inline void PersonInfo::setIsMarried(bool val) { return setFlag(IsMarriedMask, val); }
    
    inline void PersonInfo::setHasDrivingLicense(bool val) { return setFlag(HasDrivingLicenseMask, val); }
    inline void PersonInfo::setIsRemoteWorker(bool val) { return setFlag(IsRemoteWorkerMask, val); }
    inline void PersonInfo::setHasChildren(bool val) { return setFlag(HasChildrenMask, val); }
    inline void PersonInfo::setOwnsHouse(bool val) { return setFlag(OwnsHouseMask, val); }
    inline void PersonInfo::setIsSmoker(bool val) { return setFlag(IsSmokerMask, val); }
    inline void PersonInfo::setIsShareholder(bool val) { return setFlag(IsShareholderMask, val); }
    inline void PersonInfo::setIsAvailable(bool val) { return setFlag(IsAvailableMask, val); }
    

    А вот как писали на языке Си 30 или даже 40 лет назад (я так долго на нём не программировал, и не могу точно сказать, когда сформировался этот подход):

    HPERSON hPerson1 = CreatePerson(30, "Jane", PI_MARRIED | PI_SMOKER | PI_OWNS_HOUSE);
    HPERSON hPerson2 = CreatePerson(35, "John", PI_HAS_DRIVING_LICENCE | PI_SHAREHOLDER);
    

    Ну, или более развёрнуто:

    DWORD dwPersonInfo1 = PI_MARRIED | PI_SMOKER | PI_OWNS_HOUSE;
    

    и

    struct tagPerson
    {
    …
        DWORD dwPersonInfo;
    }
    

    Я большУю часть жизни посвятил программированию под WinAPI, которая из такого кода состояла чуть менее, чем полностью, и знаете что? НИ РАЗУ не видел бага, связанного с тем, что значения присваивались в обход сеттеров. Обычно, баги возникали, когда с указателями кто-нибудь напорет, но от этого сеттеры не спасают. Только запрет адресной арифметики.


    1. unC0Rr
      25.02.2026 16:22

      НИ РАЗУ не видел бага, связанного с тем, что значения присваивались в обход сеттеров.

      В Си обычно возникают баги, когда используют похожий по смыслу флаг из другой предметной области, что компилятор не может отловить, т.к. тип у них у всех одинаковый. Использование сеттеров, включающих в сигнатуру тип структуры, в Си или обёртки над флагами в Си++ решают эту проблему


  1. gxcreator
    25.02.2026 16:22

    Для такого есть очень полезная фича в GDB, включая вложенные структуры:

    (gdb) ptype/o struct __locale_data
    /* offset    |  size */  type = struct __locale_data {
    /*    0      |     8 */    const char *name;
    /*    8      |     8 */    const char *filedata;
    /*   16      |     8 */    off_t filesize;
    /*   24      |     4 */    enum {ld_malloced, ld_mapped, ld_archive} alloc;
    /* XXX  4-byte hole  */
    /*   32      |    16 */    struct {
    /*   32      |     8 */        void (*cleanup)(struct __locale_data *);
    /*   40      |     8 */        union {
    /*                 8 */            void *data;
    /*                 8 */            struct lc_time_data *time;
    /*                 8 */            const struct gconv_fcts *ctype;
    
                                       /* total size (bytes):    8 */
                                   };
    
                                   /* total size (bytes):   16 */
                               } private;
    /*   48      |     4 */    unsigned int usage_count;
    /*   52      |     4 */    int use_translit;
    /*   56      |     4 */    unsigned int nstrings;
    /* XXX  4-byte hole  */
    /*   64      |     0 */    union locale_data_value values[];
    
                               /* total size (bytes):   64 */
                             }


    1. AskePit Автор
      25.02.2026 16:22

      шикарно, спасибо!


    1. schulzr
      25.02.2026 16:22

      еще есть pahole, особенно полезен для структур с наследованием.
      https://pramodkumbhar.com/2023/11/pahole-to-analyz-data-structure-memory-layouts-with-ease/


  1. nomhoi
    25.02.2026 16:22

    1. AskePit Автор
      25.02.2026 16:22

      Даже не знаю, как реагировать. Давайте смотреть, что предложил этот ИИ:

      Если вы хотите оставить массив структур (AoS), можно упаковать возраст, роль и ID в одно целое число.

      В итоге в предложенном коде возраст, роль и ID упакованы в одно целое число не были. Уже весело.

      Теперь к заявленным 12 байтам:

      Вместо 8-байтного указателя на roleData (void* / variant), используйте 4-байтный индекс в отдельном массиве с данными ролей

      То есть ИИ унесла часть данных неизвестно куда из структуры и даже не объяснила, куда конкретно, и как это будет работать для каждого сотрудника, и почему суммарная память (вместе с мифическим массивом) будет меньше того, что было

      То, что размер структуры можно уменьшить еще больше - это 100%. Но то, что вы кинули называется AI-слопом.


      1. wslc
        25.02.2026 16:22

        Мне кажется, вы зря так отмахиваетесь: тяжело представить, что ролей больше 4 млрд, если ид меньше 65к. И вполне возможно, что их можно разместить в непрерывно или блоками. Как указали выше, вы тоже делаете предположение, про выравнивание объекта по указателю, что совсем необязательно


      1. nomhoi
        25.02.2026 16:22

        Вот вам еще нейрослопов: https://share.google/aimode/6FVm13aMzW4yhPa8V