Dynamic Polymorphism Overhead in C Explained
Dynamic polymorphism in the C programming language is achieved manually using mechanisms such as function pointers inside structures, hand-crafted virtual method tables (vtables), or tagged unions. While languages like C++ handle dynamic dispatch through compiler-generated virtual tables and optimized calling conventions, implementing this paradigm manually in C introduces notable performance, memory, and optimization overheads. This article outlines the specific costs associated with manual dynamic polymorphism compared to compiler-managed dispatch mechanisms.
1. Per-Instance Memory Footprint
When implementing dynamic polymorphism naively in C, developers often place function pointers directly inside each structure instance.
- Per-Instance Function Pointers: Storing \(N\) function pointers inside an instance inflates the structure by \(N \times \text{sizeof}(\text{pointer})\) bytes. If a structure has five polymorphic methods on a 64-bit architecture, each instance incurs a 40-byte memory overhead.
- Compiler-Generated vtables: In contrast, a
compiler-managed system (such as C++'s virtual dispatch) places only a
single pointer (the
vptr, typically 8 bytes) inside each instance, referencing a single, shared static vtable per type. - Manual vtable Emulation: A C programmer can mimic this approach by defining an external vtable struct and referencing it via a single pointer. However, this requires manually managing static lifetime definitions, initialization order, and struct definitions, increasing code complexity to achieve parity with compiler defaults.
2. Cache Inefficiency and Memory Locality
The memory layout of manual dynamic dispatch directly impacts CPU cache utilization:
- Data Cache Pollution: Embedding function pointers directly into data structures pollutes the L1/L2 data cache with executable address metadata that remains static throughout an object's lifecycle.
- Indirection Hops: Emulating vtables manually
(
object->vtable->method(object)) requires an additional level of indirection. If the manual vtable is not placed in cache-friendly memory segments, the first call to an object’s method may incur an avoidable L1/L2 data cache miss before the instruction pointer can even be resolved.
3. Optimization and Inlining Barriers
Compilers excel at optimizing code paths they understand implicitly. Manual function pointer manipulation obscures control flow, creating optimization barriers:
- Lack of Devirtualization: Modern optimizing compilers can perform speculative devirtualization on compiler-generated dispatch. If the compiler can deduce the concrete type of an object, it converts the indirect call into a direct call or inlines the function body entirely.
- Opaque Function Pointers: In standard C, function
pointers loaded through memory locations are typically opaque to the
optimizer. Unless aggressive Link-Time Optimization (LTO) can prove the
pointer is invariant across the execution context, the compiler must
emit a generic indirect call (
call *%reg), precluding inlining, loop unrolling across the call site, and constant propagation.
4. Branch Prediction and Pipeline Penalties
Modern CPUs utilize a Branch Target Buffer (BTB) to predict the targets of indirect jumps and calls.
- Indirect branches generated via C function pointers depend heavily on dynamic branch prediction. If an array of polymorphic C objects contains varying types, the target address changes frequently, causing recurrent BTB mispredictions.
- Each misprediction causes the processor pipeline to flush, costing between 10 to 20 clock cycles depending on pipeline depth. While this penalty also applies to compiler-generated vtables, compilers often organize dispatch tables to be contiguous and consistent, allowing more predictable memory access patterns for the hardware prefetcher.
5. Calling Convention and Argument Passing
Compiler-generated dispatch systems frequently utilize optimized
calling conventions tailored for method calls (such as the
thiscall convention on certain x86 ABIs or standard
register-passing rules where the object instance pointer is placed in a
specific register like RDI).
In manual C polymorphism, the instance must be explicitly passed as
the first parameter (e.g., shape->draw(shape)). While
modern calling conventions (like the System V AMD64 ABI) pass the first
few arguments in registers regardless, the manual approach prevents the
compiler from reserving dedicated registers for implicit instance
tracking, resulting in marginal register pressure increases in complex,
deeply nested function call chains.