<- Back to the main page

Should you worry about struct member order in C?


Short answer

Probably, yes

Longer answer

System programming languages, such as C and C++, give developers a lot of freedom in how their program works in both algorithms - what the program does, and data layout - what kind of data is used in the program and how it is stored in memory. In this blog post I want to focus on a very specific topic - how the order of members in a struct affects program behavior.

Different languages treat struct member order differently. For instance, C language gives a developer full responsibility over how the data is structured. At the same time Rust allows a developer to either have full control over structure layout (when #[repr(C)] is used), or let the compiler try to reorder members on its own to have a smaller struct size (when #[repr(Rust)] is used). This article will focus on manual structure layout.

Also, some important topics, such as CPU cache locality, or packing structures, are not covered in this post.

What happens when you reorder members in a struct

Let's consider the following struct type defined in a program targeting x86_64 platform:

typedef struct {
    uint64_t a;
    uint32_t b;
    uint16_t c;
    uint8_t  d;
} foo_t;

The total amount of data that can be stored in the structure is 8 + 4 + 2 + 1 = 15 bytes. However, if we create a simple program to print the actual size of the structure using printf("size of foo_t: %zu\n", sizeof(foo_t)), we will get a different result: size of foo_t: 16. Let's take a closer look at the actual alignment of data inside the struct using a Linux tool called pahole:

pahole output:
typedef struct {
	uint64_t           a;                    /*     0     8 */
	uint32_t           b;                    /*     8     4 */
	uint16_t           c;                    /*    12     2 */
	uint8_t            d;                    /*    14     1 */
	/* padding: 1 */
} foo_t;

For each of the struct members pahole printed two values: offset from the start of the structure, and the member length.

It also printed a comment /* padding: 1 */ at the end of the structure, meaning the compiler added an additional empty byte at the end of the structure which doesn't belong to any of the members, but which is required to fulfill structure alignment requirements.

In simplified form, the rules C compilers apply for the data structure are the following:

  1. Each member of a struct has an alignment requirement determined by its type. The compiler may insert padding bytes between members so that each member is placed at an offset satisfying its alignment requirement.
  2. The alignment requirement of a struct is the maximum alignment requirement among its members. The compiler may add trailing padding bytes so that sizeof(struct S) is a multiple of _Alignof(struct S). This guarantees that elements in an array of such structures are correctly aligned. For the example provided above, _Alignof(foo_t) = 8 because of alignment requirements of uint64_t a, and the sizeof(foo_t) = 16

You can read more about alignment rules in C here.

Memory usage

Reordering structure members leads to a different total size of a structure, and, consequently, a different number of padding bytes added between members and at the end of a structure. Using the interactive tool below, you can find that the structure type foo_t can have 24 distinct orders of members, with the worst total size of 24 bytes (15 bytes of data + 9 padding bytes).

Hint: in order to minimize RAM usage of the structure, consider ordering members from the most strictly aligned to the least strictly aligned to reduce padding bytes.

Performance

In addition to memory usage penalties, changing struct member order can affect program performance. Interestingly enough, changing the memory layout of a structure without changing program source code can lead to different compilation results.

Let's consider the following code

#include <stdint.h>

typedef struct {
    uint8_t a;
    uint8_t b;
    uint8_t c;
    uint8_t d;
} struct_example_t;

uint32_t foo(const struct_example_t *a) {
    return a->a | (a->b << 8) | (a->c << 16) | (a->d << 24);
}

foo() builds a uint32_t value based on 4 uint8_t values provided in a struct. Compiling the function with gcc -O3 for x86_64 we can get the following result.

0000000000000000 <foo>:
   0:	8b 07                	mov    (%rdi),%eax
   2:	c3                   	ret

As you can see, the compiler managed to compile the function to just 2 assembly instructions, because of the convenient memory layout of struct_example_t: it doesn't need to perform any bit shifts or bitwise OR operations, all the data provided by a caller is already ordered in a way which can be packed into uint32_t.

However, we can easily find that changing struct_example_t member order without changing foo() implementation can produce much worse disassembly. Use the tool below to see the result of compilation of different type definitions.

As you can see, in the worst case the function compiles to 11 assembly instructions instead of just 2 we had initially. Imagine how big of an impact it can have if foo() is being called in a hot path millions of times per second.

Conclusion

Famous quote of Donald Knuth states:

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.

Most of the time, optimizing the critical 3% involves higher level improvements, such as choosing the right data structure or implementing an algorithm with lower complexity. However, sometimes, when all the proper optimizations are already implemented, but the hot path still runs too slowly, it might be a good idea to try to rearrange memory layout to help the CPU digest the data more efficiently.