Недавно на работе мы столкнулись с большим потреблением памяти в игре. Связано оно было с хранением большого количества информации о террейне и свойствах его клеток, которое нужно игре для разных геймплейных расчетов. Проблема насущная, особенно остро проявляется как раз в больших массивах или матрицах с большими размерностями.
Довольно типичный случай — у вас есть вполне себе нормальная структура, которая хранит информацию об одном объекте. Но самих объектов очень и очень много. Скажем, у вас 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 enum на коленке. Для ролиRoleType::Employeeзаvoid*будет прятатьсяEmployeeData*, дляRoleType::Student—StudentData*и т.д.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. Не забудьте подкупить ревьювера вашей ветки, чтобы он закрыл на это глаза.
И вот, в конце концов мы получаем вот это:

Это так красиво, что я не могу выразить словами. Просто проскрольте вверх до самого первого 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; } void Cell::setMetabolicLevel(float val) { assert(status == CellStatus::Metabolic); m_metabolicLevel = val; } void Cell::setInhibitorClearanceTicks(uint16_t val) { assert(status == CellStatus::AcidicInhibitor); m_inhibitorClearanceTicks = val; } void Cell::setAcidityLevel(uint8_t val) { assert(status == CellStatus::AcidicInhibitor); m_acidityLevel = val; } void Cell::setAcidityExposure(uint8_t val) { assert(status == CellStatus::AcidicInhibitor); m_acidityExposure = val; } void Cell::setSignalLevel(uint8_t val) { assert(status == CellStatus::Signaling); m_signalLevel = val; } void Cell::setSignalLevelPrev(uint8_t val) { assert(status == CellStatus::Signaling); m_signalLevelPrev = val; } 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 в частности на полную применяется при обучении нейросетей. Там и в один байт замечательно умещают их миллиарды вещественных весов, и нейросетям в целом норм.
