Alignment, padding, and cache lines: the memory your struct wastes
Why the hardware demands aligned addresses, where the compiler hides padding bytes inside your structs, and how 64-byte cache lines decide what that layout costs - with the exact rules and knobs in C, C++, HolyC, Zig, Hare, Odin, and Forth.
Declare three fields and you would expect the struct to be three fields big. It almost never is. Between what you wrote and what the CPU sees sit three invisible mechanisms: alignment, a demand the hardware makes; padding, the bytes the compiler quietly inserts to satisfy that demand; and the cache line, the 64-byte unit in which the memory system actually moves your data. Together they decide how big your structs really are, how fast your loops really run, and occasionally whether your program runs at all.
This is the deep dive to go with our broader tour in "The Stack, the Heap, and Data Layout." Here we stay entirely inside the struct: what alignment is, a worked example of padding you can reproduce today, the exact layout rules and control knobs in each of C, C++, HolyC, Zig, Hare, Odin, and Forth - including one genuine surprise from the TempleOS compiler source - and finally what cache lines do to all of it.
What alignment is and why hardware demands it
An address is N-byte aligned when it is a multiple of N. Every scalar type carries an alignment requirement - on a typical 64-bit target, 1 for char, 4 for a 32-bit int, 8 for a double or a pointer - and the requirement is usually equal to the type's size. This is called natural alignment. A struct's alignment is the strictest alignment among its fields.
The demand comes from the memory system, not from taste. Caches, buses, and DRAM move data in fixed-size aligned chunks. A naturally aligned 8-byte load always falls inside one such chunk; a misaligned one can straddle two, and the hardware must either do extra work or refuse:
- x86-64 tolerates misaligned scalar loads and stores, usually with a small penalty that grows sharp when the access crosses a cache-line boundary. But the tolerance has edges: classic SSE instructions like
movapsfault on unaligned addresses, and an atomic read-modify-write that straddles two cache lines becomes a split lock - a bus-locking event so expensive that modern x86 chips and operating systems can be configured to trap or kill the offending process. - Many other CPUs refuse outright. Older ARM cores raised alignment faults (or worse, silently rotated the loaded bytes); modern ARM permits most misaligned scalar accesses but still faults on misaligned exclusive/atomic operations. On some RISC-V implementations a misaligned load traps into firmware and is emulated hundreds of times slower.
- Atomics require alignment essentially everywhere, because a value torn across two cache lines cannot be updated as a single indivisible unit.
The language standards echo the hardware. In C and C++, converting a pointer to a type whose alignment the address does not satisfy is undefined behavior - even before you dereference it. The compiler therefore assumes every int* really is 4-byte aligned, and it earns that assumption by construction: every field of every struct is placed on an offset that satisfies the field's alignment. The instrument for doing so is padding.
The worked example: twelve bytes to store six
The classic demonstration takes one badly ordered struct. Every number below was verified with GCC 14 on x86-64, but any System V platform gives the same answer:
#include <stdio.h>
#include <stddef.h>
struct Wasteful {
char a; /* offset 0 */
/* 3 padding bytes so 'b' lands on offset 4 */
int b; /* offset 4 - int must be 4-byte aligned */
char c; /* offset 8 */
/* 3 tail padding bytes -> size 12 */
};
struct Tight {
int b; /* offset 0 */
char a; /* offset 4 */
char c; /* offset 5 */
/* 2 tail padding bytes -> size 8 */
};
int main(void) {
printf("%zu %zu %zu\n",
sizeof(struct Wasteful), /* 12 */
offsetof(struct Wasteful, b), /* 4 */
sizeof(struct Tight)); /* 8 */
}
Byte by byte, Wasteful looks like this - six bytes of data, six bytes of holes:
offset: 0 1 2 3 4 5 6 7 8 9 10 11
[a ] [pad] [pad] [pad] [b ] [b ] [b ] [b ] [c ] [pad] [pad] [pad]
The first hole is forced by b: a char at offset 0 leaves the next free offset at 1, but an int may only sit at a multiple of 4, so the compiler skips to 4. The second hole is tail padding, and its reason is arrays: sizeof is also the array stride, so the struct's size must be rounded up to a multiple of its own alignment or element number two would start with a misaligned b. Reordering the fields largest-first eliminates the interior hole and shrinks the tail, taking the struct from 12 bytes to 8 - a third smaller, identical data, zero runtime cost.
That is the whole padding story in miniature, and it scales: a struct of mixed char, int, and double fields declared in careless order can easily be half padding. The rule of thumb every systems programmer eventually internalizes: order fields by descending alignment, and the compiler will have nothing to pad but the tail.
C: the standard shrugs, the ABI decides
What ISO C actually guarantees about layout is thin: fields are laid out in declaration order (a compiler may never reorder them), the first field sits at offset 0, and there may be implementation-defined padding between fields and at the end. The concrete numbers - that int aligns to 4, that Wasteful is 12 bytes - come from the platform ABI, such as the System V x86-64 psABI, which pins down layout so that separately compiled code and other languages can agree on it byte for byte.
C11 finally gave the programmer direct instruments:
#include <stdalign.h> /* C11; in C23, alignof and alignas are keywords */
#include <stdlib.h>
struct Node { char tag; double weight; };
_Static_assert(alignof(struct Node) == 8, "align = strictest field");
/* Raise alignment: this object now starts on a 64-byte boundary. */
static _Alignas(64) struct Node hot_node;
void demo(void) {
/* Over-aligned heap memory: malloc only promises max_align_t (16). */
struct Node *n = aligned_alloc(64, 64);
free(n);
}
alignof/_Alignof queries a type's requirement, _Alignas raises (never lowers) an object's alignment, offsetof from <stddef.h> reveals where each field landed, and aligned_alloc covers the heap, since plain malloc only guarantees enough alignment for max_align_t. Packing, by contrast, is not in the standard at all: __attribute__((packed)) and #pragma pack are compiler extensions that delete the padding and hand you the misalignment consequences. Two tools worth knowing: GCC's -Wpadded warns whenever padding is inserted, and pahole (from the dwarves package) prints the exact hole map of every struct in a compiled binary.
C++: the same bones, plus a name for the cache line
C++ inherits C's layout rules for the structs that matter here (standard-layout types) and folds the C11 machinery into the language proper: alignof and alignas are keywords, and like _Alignas, alignas can only strengthen alignment. What C++ adds is vocabulary for the next section of this article - since C++17, <new> exposes the cache-line geometry as constants:
#include <atomic>
#include <new>
// One shard per thread. alignas pads each shard to a full cache line so
// two threads never write the same line - no false sharing.
struct alignas(std::hardware_destructive_interference_size) Shard {
std::atomic<long> hits{0};
};
static_assert(sizeof(Shard) == std::hardware_destructive_interference_size);
hardware_destructive_interference_size is the distance that keeps two objects out of each other's cache line; hardware_constructive_interference_size is the span that keeps two objects inside one line. GCC 14 on x86-64 reports 64 for both, while on some ARM targets the destructive value is larger (GCC reports 256 on a number of AArch64 configurations, because their prefetchers pull adjacent lines). One caution that GCC itself warns about: these constants can differ between compilers and flags, so using them in a public ABI is asking for mismatched layouts - many codebases still write a literal 64 for exactly that reason.
C++ has one more wasted-memory subplot C does not: an empty class member still occupies at least one byte (plus padding). C++20's [[no_unique_address]] lets an empty member - a stateless allocator or comparator, say - overlap with other storage and cost nothing, which is the struct-layout trick hiding inside most modern container implementations.
HolyC: the compiler that refuses to pad
Here is this article's surprise, verified directly against the TempleOS compiler source rather than folklore. In Compiler/PrsVar.HC, where class members are laid out, the offset assignment is two lines with no rounding anywhere in sight:
tmpm->offset=tmpc->size; // member goes exactly where the last one ended
tmpc->size+=i; // class grows by the member's size - that's all
HolyC classes have no padding whatsoever. Members are packed back to back in declaration order, aligned or not:
// HolyC: the compiler inserts no padding inside a class - ever.
class Mixed
{
U8 tag; // offset 0
I64 id; // offset 1 - misaligned, and TempleOS does not care
U8 flag; // offset 9
};
Print("%d %d %d\n", sizeof(Mixed),
offset(Mixed.id), offset(Mixed.flag)); // 10 1 9
The same struct that C pads to 24 bytes is 10 bytes in HolyC, with an 8-byte I64 sitting at offset 1. Terry Davis could make that choice because TempleOS runs on exactly one architecture, x86-64, in ring 0, where misaligned scalar loads simply work; the portability and ABI concerns that force padding in C do not exist on a machine that only ever talks to itself. The fixed-width type names (U8, I64, F64, and the genuinely zero-sized U0) make the resulting layout arithmetic something you can do in your head, and offset(Class.member) is built into the language for checking it.
The compiler is not ignorant of alignment, though - it is selective. Elsewhere in the same file, stack locals are rounded to 8-, 4-, or 2-byte frame boundaries depending on their size, and every function argument gets a full 8 bytes. Data the CPU touches constantly is aligned; the bytes inside your class are yours, packed exactly as declared.
Zig: three layouts, each with a name
Zig is the one language here that makes the default struct layout none of your business - and says so. For a plain struct, the documentation gives no guarantee about field order or size: the compiler may reorder fields, and in practice it sorts them to minimize padding, doing the largest-first trick for you. (Verified against the current Zig documentation as of this writing; it has been true for years, but since the layout is explicitly unspecified, do not build anything that depends on the particular order the compiler picks.) When layout must be fixed, you say which contract you want:
const std = @import("std");
const Auto = struct { a: u8, b: i32, c: u8 }; // layout unspecified:
// Zig may reorder, and
// will typically pack
// this into 8 bytes
const CLike = extern struct { a: u8, b: i32, c: u8 }; // C ABI: order kept,
// padded like C
const Exact = packed struct { a: u8, b: i32, c: u8 }; // bit-precise: fields
// packed LSB-first into
// one 48-bit integer
comptime {
std.debug.assert(@sizeOf(CLike) == 12); // same 12 as the C example
std.debug.assert(@offsetOf(CLike, "b") == 4);
std.debug.assert(@bitSizeOf(Exact) == 48); // no padding bits at all
}
extern struct is the C-interop layout; packed struct goes further than C's packing extensions by defining layout at the bit level - the whole struct is backed by an integer, fields occupy exactly their bit widths, and a u3 really costs three bits. Alongside @sizeOf, @alignOf, and @offsetOf, any field can carry an explicit align(N) annotation, and the standard library exposes std.atomic.cache_line for the false-sharing padding trick from the C++ section. The philosophy is characteristically Zig: there is no single layout rule, there are three, and the source always says which one you asked for.
Hare: C's layout, written into the spec
Hare does not leave layout to an ABI document; its specification states the algorithm outright. Each field is placed at the lowest properly aligned offset at or after the end of the previous field, fields stay in declaration order, tail padding rounds the size to a multiple of the struct's alignment, and the struct's alignment is the maximum among its fields. That is exactly the C recipe, which is why Hare structs interoperate with C so plainly - but here it is a language guarantee, not a platform convention. The measurement tools are built-in expressions rather than library macros:
type sample = struct {
a: u8,
b: i32,
c: u8,
};
// Same shape as the C example, same numbers: 12 bytes for 6 bytes of data.
static assert(size(sample) == 12);
static assert(align(sample) == 4);
export fn main() void = {
let s = sample { a = 1, b = 2, c = 3 };
assert(offset(s.b) == 4); // offset() takes a field access expression
};
size() and align() take a type; offset() takes a field access on an object. When you need C's packing extension, Hare has it as a standard part of the language - type wire = struct @packed { ... } lays fields end to end with no padding at all, with the spec noting the obvious price: if the result misaligns a field, a conforming implementation is allowed to reject or abort. Small language, complete answer.
Odin: C order by default, a knob for everything else
Odin keeps struct fields in declaration order, padded like C - Bill Hall has said explicitly that Odin orders fields like C rather than reordering to minimize padding, the same call Go made, on the grounds that a systems programmer should be able to read the layout off the source. When the defaults are wrong for your use case, the struct tag system covers the full spectrum:
package main
import "core:fmt"
Wasteful :: struct { a: u8, b: i32, c: u8 } // C layout: 12 bytes
Tight :: struct { b: i32, a: u8, c: u8 } // reordered by YOU: 8
Wire :: struct #packed { a: u8, b: i32, c: u8 } // no padding: 6
Line :: struct #align(64) { hits: int } // one full cache line
Old :: struct #max_field_align(4) { x: f64 } // like #pragma pack(4)
main :: proc() {
fmt.println(size_of(Wasteful), size_of(Tight), size_of(Wire)) // 12 8 6
fmt.println(offset_of(Wasteful, b)) // 4
fmt.println(align_of(Line)) // 64
}
#packed deletes padding, #align(N) raises the whole struct's alignment, #min_field_align/#max_field_align reproduce the #pragma pack family for matching legacy C headers, and #raw_union overlays every field at offset 0. size_of, align_of, and offset_of report the results at compile time.
The tags are only half of Odin's answer, though. The language leans data-oriented: it assumes the shape of your data is a performance decision you make deliberately, and its signature layout feature operates above the single struct. Declare an array as #soa[10_000]Entity and Odin stores it column-wise - every pos contiguous, every vel contiguous - while your code keeps writing e.pos as if nothing changed. That is cache-line thinking promoted to a type constructor, and it gets a full treatment in the data layout article; here the point is simply that in Odin, the padding rules, the packing knobs, and the array-of-structs question are all part of one deliberate story about where bytes go.
Forth: you are the layout engine
Forth has no structs, so it has no padding - there is nothing to pad until you build the record yourself out of raw address arithmetic. What the standard gives you instead is the alignment problem in its purest form, plus tools shaped exactly like the hardware's demand: ALIGNED rounds an address up to cell alignment, ALIGN rounds the dictionary pointer itself, and fetching a cell from an unaligned address is formally an ambiguous condition - meaning on a machine that traps, your program is allowed to die. The Forth-2012 structure words automate the bookkeeping without hiding it:
\ Forth 2012: a struct is just a running offset you build by hand.
BEGIN-STRUCTURE point
CFIELD: p.tag \ 1 char at offset 0 - CFIELD: adds no alignment
FIELD: p.id \ FIELD: ALIGNs the offset first, then adds one cell
FIELD: p.x \ on a 64-bit Forth: tag at 0, id at 8, x at 16
END-STRUCTURE \ 'point' now pushes the total size: 24
CREATE pt point ALLOT \ reserve one point in the dictionary
42 pt p.id ! \ store into the id field
pt p.id @ . \ fetch it back: 42
Look at what FIELD: does: it applies ALIGNED to the running offset before adding a cell. That single word is the entire content of this article in four letters - the padding C inserts silently is the ALIGNED call Forth makes you watch. And the omissions are just as honest: END-STRUCTURE adds no tail padding, so if your record ends on an odd byte and you want an array of them, aligning the stride is your job too. Forth does not solve the alignment problem for you; it hands you the same tools the compiler writers used and assumes you meant it.
Cache lines: what the waste actually costs
Padding wastes space, but the bill arrives through the cache. A CPU never fetches a byte; a miss drags in an entire cache line - 64 bytes on x86-64 and most ARM systems, 128 on Apple's M-series - and evicts a line to make room. Main memory is roughly a hundred times slower than L1, so the performance of any loop over a big data set reduces to one question: of each 64-byte line you paid for, how many bytes did you use?
That question turns this article's small numbers into large ones:
- Shrinking the struct is a cache optimization, not a tidiness exercise. The 12-byte
Wastefulfits 5 to a line; the 8-byteTightfits 8. Same data, same loop, 60% more elements per memory transaction - reordering three fields is often worth more than any micro-optimization inside the loop body. - Split hot from cold. A struct whose hot loop touches 8 bytes but whose
sizeofis 96 wastes seven-eighths of every line it pulls. Move the rarely touched fields into a separate parallel array (or reach for Odin's#soa, which does it wholesale) and the hot traversal suddenly streams pure payload. - Keep writers apart: false sharing. The cache coherence protocol works in whole lines. If two threads increment two different counters that happen to share a line, every write invalidates the other core's copy, and the line ping-pongs between caches as if the threads were fighting over one variable - a slowdown of an order of magnitude with zero visible data race. The fix is the alignment machinery from the sections above:
alignas(std::hardware_destructive_interference_size)in C++,_Alignas(64)in C11,align(64)on a Zig field (orstd.atomic.cache_line),#align(64)on an Odin struct - each pads a per-thread slot out to a private line.
Alignment, in other words, runs on three scales at once: the hardware demands it per scalar, the compiler pads for it per struct, and the sharp systems programmer arranges it per cache line.
The checklist
- Know your target's rules. Natural alignment for scalars; a struct aligns to its strictest field;
sizeofincludes tail padding because it is also the array stride. - Order fields by descending alignment in C, C++, HolyC, Hare, and Odin, where declaration order is layout. In Zig, a plain
structdoes this for you - and in exchange promises nothing about its layout. - Measure, never guess:
offsetof(C/C++),@offsetOf(Zig),offset()(Hare and HolyC),offset_of(Odin),paholeon compiled C, and your own arithmetic in Forth. - Fix layout only at the boundary. ABI structs, wire formats, and hardware registers get
extern struct,packed struct,@packed,#packed, or a hand-built Forth structure; everything else keeps the natural padded layout that makes every access fast. - Remember the one packed heretic: HolyC pads nothing, by design, because TempleOS answers to no ABI but its own.
- Think in lines, not bytes: shrink structs, split hot from cold, and give every thread's mutable state its own 64 bytes.
The struct is the smallest unit of memory design you control completely. Spend the twelve bytes like you mean all twelve.