• entries
    627
  • comments
    1447
  • views
    1008380

Memory Management Part II

Sign in to follow this  

270 views

Managing Your Memory - Part II
Capturing Allocation Information From Your Program

In the last post I discussed the rationale and general design of a memory management system for C++ programs. This time around, we'll talk about how to actually collect data on your memory usage from a running program.

The method I present will work for all memory allocated via new, new[], and STL containers. Unfortunately there are a few ways that memory can slip past this mechanism:
  • Directly requesting memory from the OS, such as with HeapAlloc or VirtualAlloc

  • Using C memory management functions (malloc, calloc, etc.)

  • Third party libraries that are linked externally


The first two are easy enough to solve by doing a quick global search of the code base, and making sure that any calls are tracked using the memory tracker interface (which we will discuss shortly). The best way to do this is to write wrapper functions which do the tracking, and then directly use the external memory allocation mechanism.

The third problem is very tricky to solve. However, we will consider two possible options for dealing with it. The first option is to manually register any memory usage with the tracker interface; this is usually possible by querying the library for its usage information. Many libraries offer this functionality, so if you are working with such a library, there's really no problem.

The second option is to query the operating system itself directly, using functions like HeapWalk on Windows. This has the advantage of reporting all memory that your process uses, regardless of how it was allocated. However, there are two caveats: you can't see who allocated the memory, and the allocations may not match those tracked internally by our memory tracker code.

Sidebar: Why HeapWalk may disagree with our tracker
As mentioned, it is possible that you will see a totally different set of allocations at the OS-level than at the application level. This depends entirely on the implementation of the allocation functions (which are provided as part of the standard library) as well as any custom allocators you might use.

For example, the application-level allocator may allocate a large block from the OS and then parcel it out as memory is requested by the application. From the application's point of view (and from the perspective of our memory tracker), there will be many smaller allocations; from the OS perspective, there is simply one large allocation. This is especially common with pool allocators and garbage collector implementations.

Because of this it is not always feasible to compare the results of an OS-level memory query with the data stored in our memory tracker.



Defining Memory Types
Before we can track memory, we have to have some method of identifying where memory is being used, and what it is used for. Write down a list of areas that you want to track, and then create an enumeration for them, as shown in the following code snippet.

Note that we use a macro here instead of a plain enum, because we're going to do some magic with the macro a little bit later.

#define MEMTYPEENUM \
MEMTYPEMACRO(MEMTYPE_GENERAL, "General") \
MEMTYPEMACRO(MEMTYPE_GENERALARRAY, "General array") \
MEMTYPEMACRO(MEMTYPE_STRINGS, "Strings") \
MEMTYPEMACRO(MEMTYPE_GAMEGRAPH, "Gamegraph") \
MEMTYPEMACRO(MEMTYPE_UI, "UI") \
MEMTYPEMACRO(MEMTYPE_GFX, "Misc. Graphics") \
MEMTYPEMACRO(MEMTYPE_MESHES, "Meshes") \
MEMTYPEMACRO(MEMTYPE_TEXTURES, "Textures") \
MEMTYPEMACRO(MEMTYPE_SHADERS, "Shaders") \
MEMTYPEMACRO(MEMTYPE_AUDIO, "Audio") \
MEMTYPEMACRO(MEMTYPE_PHYSICS, "Physics") \
MEMTYPEMACRO(MEMTYPE_AI, "AI") \
MEMTYPEMACRO(MEMTYPE_SCRIPTS, "Scripts") \
MEMTYPEMACRO(MEMTYPE_CUTSCENES, "Cutscenes") \
MEMTYPEMACRO(MEMTYPE_XML, "XML") \
MEMTYPEMACRO(MEMTYPE_FS, "Filesystem") \
MEMTYPEMACRO(MEMTYPE_LOOKUPS, "Lookups") \
MEMTYPEMACRO(MEMTYPE_FILEIO, "File IO") \
\\

MEMTYPEMACRO(MEMTYPE_ENUMSIZE, "ERROR")

// This is where the actual enumeration is defined
#define MEMTYPEMACRO(typenumber, typestring) typenumber,
enum MemType
{
MEMTYPEENUM
};
#undef MEMTYPEMACRO



Be sure to have the MEMTYPE_ENUMSIZE constant as the last entry in the list, so we can use it to count how many memory types are used. This will come in handy a little bit later on.

These memory types should be fairly self-explanatory. Some areas may not interest you, so you can roll them into the MEMTYPE_GENERAL type. Or you might have additional areas that need their own added memory type. Customize the list to suit your needs; when you're done, we'll move on to capturing memory allocations.


Capturing Memory Allocations and Deallocations
Our weapon of choice will be an overloaded set of operators, as follows:
void* operator new(size_t size);
void* operator new[](size_t size);
void operator delete(void* ptr);
void operator delete[](void* ptr);

void* operator new(size_t size, MemType type);
void* operator new[](size_t size, MemType type);
void operator delete(void* ptr, MemType type);
void operator delete[](void* ptr, MemType type);



Notice the second set of operators: these are provided so we can specifically allocate memory in certain memory types. If we use a regular old new, the memory will go to MEMTYPE_GENERAL.

Place these operators in a file that is included before all other headers. If you are using precompiled headers, placing this #include at the top of your precompiled header file is a good choice. Otherwise, well... use precompiled headers! They're your friends and have many good uses.

Once the operators are declared, we need to define them. The implementation of these operators is pretty simple:
void* operator new(size_t size)			{ return Mem::Allocate(size, MEMTYPE_GENERAL); }
void* operator new[](size_t size) { return Mem::Allocate(size, MEMTYPE_GENERALARRAY); }
void operator delete(void* ptr) { Mem::Free(ptr); }
void operator delete[](void* ptr) { Mem::Free(ptr); }

void* operator new(size_t size, MemType type) { return Mem::Allocate(size, type); }
void* operator new[](size_t size, MemType type) { return Mem::Allocate(size, type); }
void operator delete(void* ptr, MemType type) { Mem::Free(ptr); }
void operator delete[](void* ptr, MemType type) { Mem::Free(ptr); }



I personally split up these two bits of code into memory.h and memory.cpp, respectively.


Tracking Memory Used by Classes
The next trick we have is pretty nifty. Right now, if we have a class Foo and we create a new Foo(), the memory will go to MEMTYPE_GENERAL. This is annoying! We want Foo to belong to a specific memory type, MEMTYPE_BAZ.

The solution is easy. In memory.h (or wherever you placed the overloaded new/delete declarations) add the following macro:

#define TRACK_MEMORY(memtype) \
public: \
static void* operator new(size_t size) { return Mem::Allocate(size, memtype); } \
static void* operator new[](size_t size) { return Mem::Allocate(size, memtype); } \
static void* operator new(size_t size, MemType type) { return Mem::Allocate(size, type); } \
static void* operator new[](size_t size, MemType type) { return Mem::Allocate(size, type); } \
static void operator delete(void* ptr) { return Mem::Free(ptr); } \
static void operator delete[](void* ptr) { return Mem::Free(ptr); } \
static void operator delete(void* ptr, MemType type) { return Mem::Free(ptr); } \
static void operator delete[](void* ptr, MemType type) { return Mem::Free(ptr); } \
private:



Now we just need to make a small addition to Foo's class declaration. Insert the line TRACK_MEMORY(MEMTYPE_BAZ) into the class declaration, at the very top. (You can technically place it anywhere, but I prefer the top for easy access. Also, the macro is set up assuming you place it at the top; hence the trailing protected access specifier, so you don't accidentally make any members public by using the macro.)

That's it! Any time we get a new Foo() we'll magically see the memory usage show up as MEMTYPE_BAZ. This will work regardless of how we use the class.

Suppose we have a Foo that really ought to belong to MEMTYPE_QUUX instead, for some technical reason. (A good example is a generic class that is used by several different modules.) Instead of using the default syntax, we make one small tweak:

Foo* thefoo = new (MEMTYPE_QUUX) Foo();

And now that particular Foo shows up under the MEMTYPE_QUUX memory type instead. This gives us a very fine-grained control over what memory type is used for any given object. This works on built-in types as well as classes, since we defined an extra new overload earlier. Of course it also works for structs.

Note that if Foo itself allocates memory, you'll need to specifically track that memory, too.


Wrapping Up
We now have all the tools we need to track the memory used by our program. In the next installment of the series, we'll look at how to store the memory tracking information, and provide some definition for those mysterious Mem::Allocate and Mem::Free functions.

Stay tuned - the best is yet to come!
Sign in to follow this  


5 Comments


Recommended Comments

Got to plug the NT memory manager tag system again. It's equivalent to your MEMTYPE stuff is "tags", that in general are arbitrary 32-bit integers but in practice are arbitrary multi-byte character contants. The advantages are that they're not as in-your-face as the MEMTYPE macros/enums, making things easier to read, you don't have to have a centrally defined list of tags so there's no reluctance to make new ones, and since the character/tag value is written to the memory block it's easier to see what's going on in a hex dump. e.g.:


00123450 51 75 65 75 e3 98 a8 3f 00 00 00 00 ff ff ff ff Queu...?........
00123460 a9 01 23 93 51 72 75 64 11 22 33 44 55 66 77 88 ....Qrud.'3DUfw.

You can see pretty easily that you're looking at a 'Queu' object and a 'Qrud' object.

Of course you could get the hexdump stuff back by having explicit values for your enums.

Share this comment


Link to comment
void operator delete(void* ptr, MemType type) { Mem::Free(ptr); }
void operator delete[](void* ptr, MemType type) { Mem::Free(ptr); }

I'd just like to point out that the only time an overloaded version of delete is called is when an exception is thrown in the constructor of
the object being constructed.

i.e. you can't use these functions yourself, luckily they are functionally equivalent of your overloaded global delete operator.

Share this comment


Link to comment
Quote:
Original post by Anon Mike
Got to plug the NT memory manager tag system again. It's equivalent to your MEMTYPE stuff is "tags", that in general are arbitrary 32-bit integers but in practice are arbitrary multi-byte character contants. The advantages are that they're not as in-your-face as the MEMTYPE macros/enums, making things easier to read, you don't have to have a centrally defined list of tags so there's no reluctance to make new ones, and since the character/tag value is written to the memory block it's easier to see what's going on in a hex dump. e.g.:


00123450 51 75 65 75 e3 98 a8 3f 00 00 00 00 ff ff ff ff Queu...?........
00123460 a9 01 23 93 51 72 75 64 11 22 33 44 55 66 77 88 ....Qrud.'3DUfw.

You can see pretty easily that you're looking at a 'Queu' object and a 'Qrud' object.

Of course you could get the hexdump stuff back by having explicit values for your enums.



One of the advantages of my system is portability. I really like the NT tag system, but it isn't always available in places where a general memory tracking solution are wanted. Thanks for the plug, though [wink]


Quote:
Original post by Moomin
void operator delete(void* ptr, MemType type) { Mem::Free(ptr); }
void operator delete[](void* ptr, MemType type) { Mem::Free(ptr); }

I'd just like to point out that the only time an overloaded version of delete is called is when an exception is thrown in the constructor of
the object being constructed.

i.e. you can't use these functions yourself, luckily they are functionally equivalent of your overloaded global delete operator.


You are correct. However, in order to be exception-safe, and to satisfy certain compiler warnings, it is important to have these functions even though they can't be called.

(However, they can be called if you treat them similarly to placement new - specifically: FooObject->~FooObject(); operator delete (MEMTYPE_FOO) FooObject; At least, this works on VS2005. I don't recommend using it, though [smile])

Share this comment


Link to comment
Great series !

Looking forward to the future postings in this series. Memory management is always something I'm looking to strive better at in my works.

-Brandon R.

Share this comment


Link to comment
Quote:

void* operator new(size_t size);
void* operator new[](size_t size);
void operator delete(void* ptr);
void operator delete[](void* ptr);

void* operator new(size_t size, MemType type);
void* operator new[](size_t size, MemType type);
void operator delete(void* ptr, MemType type);
void operator delete[](void* ptr, MemType type);


What happens when an allocation fails in these functions?
They are not defined with an empty throw() or have a std::nothrow parameter so the should not return a NULL pointer and they do not define which exception they can throw even thought they can only throw std::bad_alloc or a class derived from it.
In addition if these are the throwing operators where are the nothrow versions?

Share this comment


Link to comment

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now