Contents
Most of the time we Swift programmers don’t really think about how our types – structs, classes, actors, enums, etc – are represented in memory. We just deal with them abstractly, knowing that somehow the compiler boils them down to a bunch of bytes, handling all the complexity of wrangling those bytes for us.
However, whether we realise it or not, we humans do actually determine how our types are laid out in memory, and how much memory they use – and in more subtle ways than you might realise. We can have a significant impact on how efficient our memory usage is – and consequently runtime performance – based on things as innocuous as in what order we declare stored properties.
I’m going to start at the beginning, but if you find the foundations & theory uninteresting, at least read the subsequent section headings for the key takeaways.
Determining how Swift lays out a type
The Swift standard library includes a handy utility, MemoryLayout
. It reports three attributes regarding how a target type is actually laid out in memory by the compiler:
- “Size” – the number of contiguous bytes required to store an instance of the type. This is not necessarily the optimal size (as we’ll see later) nor the actual size, so it’s not terribly useful other than as a way to infer other, more interesting attributes.
- Stride – the actual number of bytes required to store an instance of the type1, taking into account its alignment requirements.
- Alignment – the address of any instance of the type must be evenly divisible by this power of two.
Alignment is a tricky concept and pivotal to how & why data types are laid out the way they are (not just in Swift, but in practically all programming languages). Thankfully, you don’t really need to understand alignment for the purposes of this post – just consider it a “magic” number dictated by hardware and compilers for performance and portability. Or see e.g. Wikipedia if you really want to dig into it.
Using Mirror
, it’s also possible to sum up the size of the stored properties for structs, classes, etc. That tells you the nominal size, if alignment weren’t an issue.
The difference between the nominal size and stride (if any) is the padding. Padding bytes aren’t actually used to store anything. They are wasted space. Minimising padding is generally good, especially if doing so doesn’t require breaking any alignment requirements. Padding can be internal (between stored properties within the type) or trailing (after all stored properties within the type) – we’ll dig into the practical difference later.
So, armed will this knowledge and tools, let’s interrogate some types.
Scalars tend to be pleasantly boring
In general, scalars – individual numbers & booleans – tend to have no padding and be aligned to their full size (known as natural alignment). e.g.:
Type | Nominal Size | Contiguous Size | Alignment | Actual Size (Stride) | Internal Padding | Trailing Padding |
---|---|---|---|---|---|---|
Bool | 1 | 1 | 1 | 1 | 0 | 0 |
Int8 / UInt8 | 1 | 1 | 1 | 1 | 0 | 0 |
Int16 / UInt16 | 2 | 2 | 2 | 2 | 0 | 0 |
Int32 / UInt32 | 4 | 4 | 4 | 4 | 0 | 0 |
Int64 / UInt64 | 8 | 8 | 8 | 8 | 0 | 0 |
Float | 4 | 4 | 4 | 4 | 0 | 0 |
Double | 8 | 8 | 8 | 8 | 0 | 0 |
☝️ Int
& UInt
vary in size across CPU ISAs, following the natural word size. So, on 64-bit ISAs they are equivalent to Int64
/ UInt64
, while on 32-bit ISAs they are equivalent to Int32
/ UInt32
.
Characters are expensive
String
is a surprisingly and extremely complicated type, which is largely a topic for another time. However, one important thing to note is its relationship to Character
:
Type | Nominal Size | Contiguous Size | Alignment | Actual Size (Stride) | Internal Padding | Trailing Padding |
---|---|---|---|---|---|---|
Character | 16 | 16 | 8 | 16 | 0 | 0 |
String | 16 | 16 | 8 | 16 | 0 | 0 |
They’re the same! Character
is a lie – it’s actually a String
!
There is of course more to Character
– although it is a string under the covers, its API enforces that it can only ever contain a single character. But that’s about it.
The reason is that a single character has no upper bound on its byte size – these are Unicode characters (formally known as extended grapheme clusters), not ASCII characters that fit trivially into a single byte. Unicode characters can be composed of multiple Unicode scalars (a naive notion of a Unicode ‘character’, that is Unicode’s basic building block) – and that’s just scratching the surface. Andy Bargh has written a nice Swift-centric introduction to the fun of Unicode, if you want to pull on that thread.
So, rather than reimplementing a tremendous amount of complex functionality, Character
just uses String
to do its dirty work. It’s smart, but it means that Character
s are as expensive as String
s2.
Swift has no direct equivalent to the char
type that many other languages have (i.e. an ASCII character; a byte). The closest thing is UInt8
(which is why you’ll often see [UInt8]
or UnsafeMutableBufferPointer<UInt8>
or similar when dealing with raw memory or C/C++ APIs).
The takeaway is: don’t treat Character
like char
. They are very different. Character
is much more expensive, in both memory and CPU usage.
⚠️ Note that the quoted size of 16 bytes is not necessarily the full size of a Character
or String
. That includes a small subsection available for storing the actual string data, if that data happens to be very small. If it’s not, then String
makes a separate memory allocation for it. That second allocation is always at least 16 bytes too.
The situation is even more complicated for NSString
s bridged from Objective-C.
How types compose into structs, classes, and actors
Generally, the rules are:
- Alignment is the greatest of the components’ alignments.
- Stride is the [contiguous] size padded up – if necessary – to satisfy the alignment.
- Size is complicated. 😝
Let’s start with a simple example:
struct Composite {
let a: Int64
let b: Int32
}
This Composite
struct has a nominal size of twelve bytes – just the sum of its components.
Its components have alignment requirements of eight and four, respectively. The greatest of those is eight, so that is the overall struct’s alignment.
Twelve is not a multiple of eight, so four bytes of padding are added to fix that, making the stride (the effective size) sixteen bytes.
But what happens if we change the order of the stored properties?
Contiguous Size depends on stored property order
struct CompositeB {
let b: Int32
let a: Int64
}
You might think this has no effect – after all, the struct is still storing the same things; what difference does the order make?
Type | Nominal Size | Contiguous Size | Alignment | Actual Size (Stride) | Internal Padding | Trailing Padding |
---|---|---|---|---|---|---|
Composite | 12 | 12 | 8 | 16 | 0 | 4 |
CompositeB | 12 | 16 | 8 | 16 | 4 | 0 |
For better or worse, Swift uses declaration order for the in-memory order of stored properties3. And that can influence where padding goes.
In the case of our modified struct, CompositeB
, the memory layout procedure is basically:
- Place the first item. That’s an
Int32
, so it takes four bytes. It requires four byte alignment, so the struct now requires [at least] four byte alignment. - Place the second item. That’s an
Int64
. It takes eight bytes. It requires eight byte alignment. So it cannot be placed immediately after the first item, as that would be an offset of and therefore alignment of four, which is not a multiple of eight. So four bytes of padding are added, in-between the two items.
And the alignment requirement of the overall struct bumps up to eight, as well.
The net result is that while the effective size is unchanged (at sixteen bytes), the Contiguous Size has changed – before it was technically just twelve bytes, but now it’s the full sixteen. There’s still four bytes of padding in there that technically don’t matter and aren’t used, but because they’re now in the middle instead of at the end, they’re harder for the compiler to ignore.
Trailing padding is better than internal padding
In this specific example, this change doesn’t really matter. The code generated for copying the struct will probably just use two load and two store instructions anyway, one for each stored property, so it doesn’t really matter where the padding is. However, if the struct were bigger – more and/or larger stored properties – then copy might be implement as a call to memcpy
4. That call won’t bother including any trailing padding because that’s easy to omit – just stop copying early – but it’ll have to copy the internal padding bytes even though their contents are irrelevant. So it wastes time.
How often this manifests as a noticeable performance difference is much harder to say. Probably you shouldn’t stress too much about this. However, that doesn’t mean you can ignore stored property ordering, because…
Actual Size (Stride) also depends on stored property order
struct Composite2 {
let a: Int64
let b: Int32
let c: Int32
}
struct Composite2B {
let b: Int32
let a: Int64
let c: Int32
}
Type | Nominal Size | Contiguous Size | Alignment | Actual Size (Stride) | Internal Padding | Trailing Padding |
---|---|---|---|---|---|---|
Composite2 | 16 | 16 | 8 | 16 | 0 | 0 |
Composite2B | 16 | 20 | 8 | 24 | 4 | 4 |
Oh no! Storing the exact same data in the ‘wrong’ order has increased the actual memory usage by 50%!
It’s the same reason as for the simpler case covered earlier – every individual stored property must be stored with correct alignment, which means you can’t put an Int64
immediately after a single Int32
. Having to put four bytes of padding in-between means your overall size (twenty bytes) isn’t a multiple of the overall alignment requirement (eight) so another four bytes of padding have to be added at the end.
Now, this doesn’t always matter. If you only have a handful of instances of Composite2B
in your program at any one time, the wasted memory will be insignificant. But if you have many then it can add up to a significant cost. So be on the lookout for this problem for any data types you use in large numbers.
Fortunately, changing the order of stored properties is always source-compatible, and is binary-compatible too (a.k.a. “ABI-compatible”) for classes & actors, as well as non-frozen structs. So even if you don’t catch the problem immediately, you might still be able to fix it.
☝️ Although we’ve been looking at structs here, know that this all applies similarly to classes & actors. The main difference is that every class or actor – in fact, any reference type – is at least sixteen bytes irrespective of what stored properties it has. Its alignment is likewise always at least sixteen bytes5. So the padding problems, and wasted space that result from them, tend to be even worse with classes & actors.
Bools are bytes, not bits
In principle a boolean uses just a single bit of memory – true or false, 1 or 0. Unfortunately, high-level languages like Swift tend to think of everything as bytes, not bits, and the smallest number of bytes is one – eight bits.
We saw this in the beginning, with Bool
taking a whole byte even though it only uses an eighth of that memory.
Unfortunately, this carries over even to collections of Bool
s.
struct ManyBooleans {
let a: Bool
let b: Bool
let c: Bool
let d: Bool
let e: Bool
let f: Bool
let g: Bool
let h: Bool
}
In some languages, ManyBooleans
would take up just one byte. It has eight booleans, which need eight bits, which can be packed together into a single byte. Perfect!
Unfortunately, Swift does not do that currently6. In Swift, ManyBooleans
is eight bytes. It has an alignment of just one, so at least it never wastes any space with padding. Nonetheless, it still takes up eight times as much memory as it needs to. 😢
This applies similarly to collections of Bool
s, like Array
s. A [Bool]
takes up one byte per entry, not one bit. There is no native equivalent to C++’s vector<bool>
in Swift (Swift does not support template specialisation in that sense), although swift-collections does contain BitArray
(and BitSet
) collections that you can use manually (but you have to remember to use them!).
🎉 I want to call out that these bit-efficient collections were added through a Google Summer of Code project, by Mahanaz Atiqullah with mentoring by David Smith, Karoy Lorentey, and Kyle Macomber. See her project presentation for details and some benchmark numbers showing that these specialised collections are not just much more memory efficient but much faster as well.
…except when they’re not
Type | Nominal Size | Contiguous Size | Alignment | Actual Size (Stride) | Internal Padding | Trailing Padding |
---|---|---|---|---|---|---|
Bool | 1 | 1 | 1 | 1 | 0 | 0 |
Optional<Bool> | 1 | 1 | 1 | 1 | 0 | 0 |
What witchcraft is this?! We just saw that two Bool
s do not share a byte in Swift, yet here we have conceptually two Bool
s, and they’re doing exactly that!
This is because enums, basically. They are a whole other kettle of fish. I plan to do a follow-up post on their unique behaviour when it comes to memory layout.
- For most purposes in memory. It may be possible to pack the type into a smaller space (even without using formal compression) such as for serialisation into a file or to send over a network, but in general you can’t use the type in a Swift program if it’s not properly padded to its stride and correctly aligned. ↩︎
- Potentially even more expensive due to the indirection, although most
Character
APIs are inlinable and so tend to get compiled out by the optimiser. ↩︎ - It generally doesn’t have to though, which opens up the possibility that Swift will improve this behaviour in future. ↩︎
- The reasoning is complicated and subject to the whims of the compiler’s optimiser, but it factors in considerations such as the code size (of a single function call to
memcpy
vs potentially many separate load & store instructions) and performance (at some point it becomes faster to just copy all the bytes together than copy every stored property’s bytes individually). ↩︎ - This is actually a consequence of how malloc is implemented on virtually all platforms – and certainly all of Apple’s – which is to return memory allocations that are at least sixteen bytes in size, and usually a power of two (so 16, 32, 64, 128, etc). So your class might nominally only need 65 bytes of memory per instance, but it might end up using 128. Which can make classes & actors even more problematic regarding memory size and waste, than structs & enums – a topic for a follow-up post, perhaps. ↩︎
- It could, though, in future. ↩︎
Appendices
Helper functions
This post used the following core code for the data presented in the tables. It’s pretty hacky – it doesn’t work correctly for all types, as the inline comments note, but it is sufficient for the relatively simple examples shown in this post.
// Table header
print("Type",
"Nominal Size",
"Contiguous Size",
"Alignment",
"Actual Size (Stride)",
"Internal padding",
"Trailing Padding",
separator: "\t")
// Table row
func sizeOf<T>(_ value: T) -> Int {
MemoryLayout<T>.size
}
func printLayout<T>(of type: T.Type,
example: T? = nil) {
let nominalSize = if let example {
// Note: doesn't handle recursive types (nested structs, classes, actors, enums, etc).
// Also not correct for primitive types (e.g. integers).
Mirror(reflecting: example)
.children
.lazy
// Have to force open the existential because Swift won't implicitly open Any in Swift 5 mode.
// https://forums.swift.org/t/getting-the-size-of-any-value/62843/4
// https://github.com/apple/swift-evolution/blob/main/proposals/0352-implicit-open-existentials.md#avoid-opening-when-the-existential-type-satisfies-requirements-in-swift-5
.map { _openExistential($0.value, do: sizeOf) }
.reduce(into: 0, +=)
} else {
MemoryLayout<T>.size
}
print("\(type):",
nominalSize,
MemoryLayout<T>.size,
MemoryLayout<T>.alignment,
MemoryLayout<T>.stride,
MemoryLayout<T>.size - nominalSize,
MemoryLayout<T>.stride - MemoryLayout<T>.size,
separator: "\t")
}
An
OptionSet
can be a useful replacement forvector
in many cases, can’t it?Some cases, perhaps. An
OptionSet
is somewhat like an array, although not completely – e.g.OptionSet
values can overlap (e.g. 0x1 & 0x3).