Game Engine, C 1, P 3, Memory tracker

Memory management basics

Unfortunately, this post won't tell you anything new about smart pointers or custom allocation mechanisms. What it is, however, about, is a way to track memory, that wasn't deallocated correctly. Also, it will be possible to know how many objects and bytes were allocated and deallocated in total.

You can see the full code of the editor up to this post here.

Memory manager implementation

In C++, it is possible to overload the new and delete operators. It is fairly simple to do, but I won't focus on it.

Class features

Let's make a list of what our class should do. It should allow us to create objects, using their constructors. It also should allow us to deallocate created objects. If we can do this, counting memory would be trivial. There are also ways to track virtual and physical memory, taken by the application, but for now I consider it platform-dependent, so it won't be covered here.

Class definition

It is time to actually write some code for the class.

class memory_tracker
  {
  private:
   struct memory_tracker_item
   {
    std::string name;
    std::size_t size;
    std::size_t count;
   };
   std::unordered_map<void*, memory_tracker_item> _allocated;
   std::size_t _count_alloc_objects, _count_free_objects;
   std::size_t _count_alloc_bytes, _count_free_bytes;
  public:
   memory_tracker();
   virtual ~memory_tracker();
   static memory_tracker * get_instance();

   template <typename T, typename ...Ts>
   T * allocate(Ts... args) noexcept;

   template <typename T>
   T * allocate_array(std::size_t count) noexcept;

   template <typename T>
   int free(T * value) noexcept;

   template <typename T>
   int free_array(T * value) noexcept;

   void dump(std::ostream &);
   std::size_t size() const;
   bool empty() const;

   std::size_t get_count_alloc_objects() const;
   std::size_t get_count_free_objects() const;
   std::size_t get_count_alloc_bytes() const;
   std::size_t get_count_free_bytes() const;
  };


The code is fairly straightforward. I decided to make a structure, that contains info about every allocation. Name, number of bytes and number of allocated objects (in case of an array). Then we have a few counters for bytes and objects and an unordered map to store our structures.

The functions are also simple, our class supports a singleton and an instance-based approach, has an allocate and free functions for objects and arrays and a few getters for our counters. Firstly, let me show the code for our templated member functions, as they have to be inside the header because of templates.

   template <typename T, typename ...Ts>
   T * allocate(Ts... args) noexcept
   {
    //T *res = new (std::nothrow) T(args...);
    void *res_raw = std::malloc(sizeof(T));
    if (!res_raw || res_raw == nullptr)
    {
     return nullptr;
    }
    T *res = new (res_raw) T(args...);
    ++_count_alloc_objects;
    _count_alloc_bytes += sizeof(T);
    _allocated[res] = { typeid(T).name(), sizeof(T), 0 };
    return res;
   }

   template <typename T>
   T * allocate_array(std::size_t count) noexcept
   {
    //T *res = new (std::nothrow) T[count];
    void *res_raw = std::malloc(sizeof(T) * count);
    T *res = (T *)res_raw;
    if (!res || res == nullptr)
    {
     return nullptr;
    }
    _count_alloc_objects += count;
    _count_alloc_bytes += sizeof(T) * count;
    _allocated[res] = { typeid(T).name(), sizeof(T), count };
    return res;
   }


The allocate function uses C++11 parameter packing to pass all of the constructor parameters to the allocate method. Then we can do either an in-place new with malloc or a nothrow new to get the constructed object. I chose the second approach to explicitly show what is being done, but left the other version commented. After that the counters are increased and a new structure is written into the unordered map using the pointer address, the typeid name of the class and other info.

The free function is even simpler.

   template <typename T>
   int free(T * value) noexcept
   {
    if (!value || value == nullptr)
    {
     return (int)vivid_core::utility::error::MEMORY_CORRUPT;
    }
    auto value_iter = _allocated.find(value);
    if (value_iter == _allocated.end())
    {
     return (int)vivid_core::utility::error::OBJECT_NOT_FOUND;
    }
    _allocated.erase(value_iter);
    if constexpr (std::is_nothrow_destructible<T>::value)
    {
     value->~T();
    }
    ++_count_free_objects;
    _count_free_bytes += sizeof(T);
    std::free(value);
    //noexcept(delete value);
    return (int)vivid_core::utility::error::SUCCESS;
   }

   template <typename T>
   int free_array(T * value) noexcept
   {
    if (!value || value == nullptr)
    {
     return (int)vivid_core::utility::error::MEMORY_CORRUPT;
    }
    auto value_iter = _allocated.find(value);
    if (value_iter == _allocated.end())
    {
     return (int)vivid_core::utility::error::OBJECT_NOT_FOUND;
    }
    ++_count_free_objects;
    _count_free_bytes += sizeof(T) * value_iter->second.count;
    _allocated.erase(value_iter);
    std::free(value);
    //noexcept(delete[] value);
    return (int)vivid_core::utility::error::SUCCESS;
   }


It uses an enum I made to display errors. You can see it on GitHub. Since this engine uses no exceptions, I have to rely on error codes to check for correct usage. Besides, memory management should be as fast as possible, as it is used all the time. After the counters are updated, the pointer is freed and a success code is returned.

Other methods are trivial.

static memory_tracker * tracker = nullptr;

memory_tracker::memory_tracker() : _count_alloc_objects(0), _count_free_objects(0), _count_alloc_bytes(0), _count_free_bytes(0)
{
}

memory_tracker::~memory_tracker()
{
 if (tracker != nullptr)
 {
  delete tracker;
  tracker = nullptr;
 }
}

memory_tracker * memory_tracker::get_instance()
{
 if (tracker == nullptr)
 {
  tracker = new memory_tracker();
 }
 return tracker;
}

void memory_tracker::dump(std::ostream &stream)
{
 stream << "alloc: " << _count_alloc_bytes << " free: " << _count_free_bytes << std::endl;
 if (_allocated.empty()) return;
 for (auto& it : _allocated) {
  stream << it.first << " " << it.second.name << "(" << it.second.size << ")";
  if (it.second.count != 0)
  {
   stream << "[" << it.second.count << "]";
  }
  stream << std::endl;
 }
}

std::size_t memory_tracker::size() const
{
 return _allocated.size();
}

bool memory_tracker::empty() const
{
 return _allocated.empty();
}

std::size_t memory_tracker::get_count_alloc_objects() const
{
 return _count_alloc_objects;
}

std::size_t memory_tracker::get_count_free_objects() const
{
 return _count_free_objects;
}

std::size_t memory_tracker::get_count_alloc_bytes() const
{
 return _count_alloc_bytes;
}

std::size_t memory_tracker::get_count_free_bytes() const
{
 return _count_free_bytes;
}


The dump function is simply used for debugging, it displays everything, that was allocated. Since it uses a stream, any stream or wrapper can be used to dump the info. For example, spdlog, which will be extensively used in the engine.

Conclusion

Is this a good solution for memory management inside a game engine? Yes and no. Here are the pros:
  • Easy to use
  • Has info about all objects, allocated through it
  • Has info about used, allocated and freed memory
  • Has a dump method
  • Can be used both as a singleton for engine-wide use or as an instance for small use
And the cons:
  • Stores info about all allocated objects, so takes up memory itself
  • Stores names as strings, has many copies of same name for each object
  • Uses heap for object management, doesn't support custom allocators (although they can be written using the memory manager)
 As you can see, it is not clear, if this is a good solution. As of this post, it can be used to debug different program units or enclosed pieces of code. However, I wouldn't recommend using it engine-wide. Here are a few ways to work around the cons:
  • Make a version without an object array (but multiple versions are not good to use with the singleton approach)
  • Store a separate map of names and point to it
  • Make a version with custom allocator support (again, bad for singletons)
With the comments in brackets, these work-arounds don't seem like the best idea. That is why I decided to leave the manager as-is for now and maybe come back to it later. It is certainly not ready for production use, but can be used as a small convenient debugging tool from time to time.

My next post will cover window setup with GLFW and Vulkan. We will also take a look at [Boost].DI and where to use it. Go there?

Comments