Virtual functions and binary sizes

Sandor Dargo - Feb 8 '23 - - Dev Community

In the previous article of this series on binary sizes, we discussed how special functions' implementations - or their lack of - influence the size of the generated binary.

Our conclusion was that if we could, we should follow the rule of 0. Not only because of simplicity for the reader but also for the compiler. If that's not possible, we should not declare destructors virtual in vain. And if it's possible, we should =default non-virtual destructors in the header both for readability and for binary sizes.

On the other hand, if we must have a non-virtual destructor, we should clearly think about implementing (preferably with =default) the destructor in the .cpp file, in other words, out-of-line. While you might find it strange in terms of readability, it produced a smaller binary.

It's still worth noting that in most circumstances, a class with a virtual destructor contributes to a larger binary file.

But does it matter how many functions are virtual? Given the same amount of methods in a class, is there a difference in terms of binary size whether one or all of them are virtual?

That's the question we are going to discuss in this post.

What does the virtual keyword do?

When any of a class-member function is declared with the virtual keyword, it means that the compiler cannot know during compile-time which implementation of the virtual function will be called.

How could it resolve during runtime which function is to be called?

As it's explained on Johnny's Software Lab, the way to do it is not standardized. Yet, most compilers do it in a similar way.

For each class that has at least one virtual method, the compiler creates a virtual table. It's usually just referred to as a vtable. The vtable of each class contains a pointer to each of their virtual functions. A virtual table might not contain pointers only to the same class. If a derived class doesn't override a method of a base class, then it will point to that base class implementation.

When an object is created at runtime, there is also a virtual pointer (vpointer) created pointing to the right virtual table.

So there is one vpointer for each object created at run-time, but only one vtable per type which is created per compile-time.

I think we can rightly expect that therefore a virtual method - due to the vtable - results in an increased binary size and we could also expect that any additional virtual method will add to the size of the binary as the vtable grows, but only a little bit.

Validate our hypothesis

Now let's create two main.cpp files. In the first one, we are going to have two classes with a virtual destructor and with 3 members each having a non-virtual accessor. In the second example, the same class will have those accessors turned into virtual ones. I know it's not a particularly elaborate example, but it's a start.

// main-single-virtual.cpp
#include <array>

class SingleVirtual {
public:
    SingleVirtual() = default;
    SingleVirtual(int a, int b, int c) : m_a(a), m_b(b), m_c(c) {}
    virtual ~SingleVirtual() = default;

    int getA() const { return m_a; }
    int getB() const { return m_b; }
    int getC() const { return m_c; }

private:
    int m_a = 0;
    int m_b = 0;
    int m_c = 0;
};


std::array<SingleVirtual, 10'000> a;

int main() { }

// main-many-virtuals.cpp
#include <array>

class ManyVirtuals {
public:
    ManyVirtuals() = default;
    ManyVirtuals(int a, int b, int c) : m_a(a), m_b(b), m_c(c) {}
    virtual ~ManyVirtuals() = default;

    virtual int getA() const { return m_a; }
    virtual int getB() const { return m_b; }
    virtual int getC() const { return m_c; }

private:
    int m_a = 0;
    int m_b = 0;
    int m_c = 0;
};

std::array<ManyVirtuals, 10'000> a;

int main() { }
Enter fullscreen mode Exit fullscreen mode

Depending on the optimization level, the version where only the destructor is virtual was compiled into a binary with the size of 281,488/281,520. The fully virtual version had slightly bigger binaries, 281,615/281,647.

We can see that the difference is small. In the previous articles, I used an array of 10,000 objects so that we don't have to look into tiny differences. But in this case, it's worth having a look at the exact size of the difference.

It's less than 200 bytes per 10,000 objects. To be more precise, the difference is 127 bytes, way less than one byte per object. It cannot have anything to do with the number of objects. It is only about the size of the vtable. It's almost negligible.

First, I ran both examples with a much smaller array of only 10 objects. The difference between the two versions was still exactly 127 bytes.

As we start to remove the virtual keywords from the accessors one by one, the difference also shrinks. First by 48 byes to 79 bytes, then by another 32/48 bytes (depending on the optimization level) to 31-47 bytes. And finally, by devirtualizing the third accessor, the difference shrinks by another 48 bytes depending.

Another observation that we can make, is that at the end, ManyVirtuals ended up with a smaller binary. That's because its name is shorter. But that only mattered when the optimization level was -O0

What can we learn from this?

It seems that the size of a vtable entry is about 48 bytes, at least on Apple Clang 15. It might not seem a big deal at first, but if we have 10 classes with 3 virtual methods each that's already more than 1KB. But these numbers can be much higher, especially if we consider a virtual method in a class template. That can quickly explode if we don't pay attention.

At the same time, we can only rest assured that the length of class/function names is not a problem anymore as it was in the past - given that we turn on compiler optimizations.

If we have a look at the assembly code after an unoptimized build (so that it remains somewhat readable), we can see things that seem like a list, probably the virtual table:

// SingleVirtual.s

    .section    __DATA,__const
    .globl  __ZTV13SingleVirtual            ; @_ZTV13SingleVirtual
    .weak_def_can_be_hidden __ZTV13SingleVirtual
    .p2align    3
__ZTV13SingleVirtual:
    .quad   0
    .quad   __ZTI13SingleVirtual
    .quad   __ZN13SingleVirtualD1Ev
    .quad   __ZN13SingleVirtualD0Ev


// ManyVirtuals.s

    .section    __DATA,__const
    .globl  __ZTV12ManyVirtuals             ; @_ZTV12ManyVirtuals
    .weak_def_can_be_hidden __ZTV12ManyVirtuals
    .p2align    3
__ZTV12ManyVirtuals:
    .quad   0
    .quad   __ZTI12ManyVirtuals
    .quad   __ZN12ManyVirtualsD1Ev
    .quad   __ZN12ManyVirtualsD0Ev
    .quad   __ZNK12ManyVirtuals4getAEv
    .quad   __ZNK12ManyVirtuals4getBEv
    .quad   __ZNK12ManyVirtuals4getCEv
Enter fullscreen mode Exit fullscreen mode

For SingleVirtual, we can see an entry for the constructors and the destructor. We can also see that it's in the const DATA section. But there is nothing for the getter methods. On the other hand, those are there in ManyVirtuals. I couldn't figure out why there are entries for the constructors which are not virtual in C++. If you happen to know, please share in the comments or by e-mail.

Conclusion

In this post, we saw how little it matters whether we add a new virtual method to a class that has already a virtual destructor. Having a virtual destructor ensures that everything necessary is instrumented for polymorphic behaviour which can make a huge difference in your binary size - and runtime performance. At the same time, adding another virtual function barely adds to the size of the binary. It'll only mean an extra entry in your vtable.

In the next episode, we'll have a look into two classic design patterns. We'll take a classical reference semantic and a modern value semantic implementation and see how much that matters in terms of binary size.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .