Learn systems programming as a web dev - Part #1: The humble integer vol1
Learn systems programming as a web dev
Talking to students and friends I think one of the most common things I see is people wanting to break free from the very narrow conception that they have of programming, but them not even knowing where to start. This is my intention with this series of articles, to not only provide a concrete path for those looking to expand to new fields, but also change people’s perspective on programming, once you think like a systems engineer, you can’t go back, these learnings will carry on to your next projects no matter the tech stack.
Choosing a language
There are many roads we can take from here, but it’s really important to emphasize something here, a language is nothing more than a tool, this is the same reason why the concepts you’ll see throughout this series is widely applicable to other languages.
Having said that, I’ll choose C for the concrete examples on this series, you can follow along in any of the other languages I’ll list below and I will do my best to provide equivalent function names and conventions as we go forward.
- Odin
- Zig
- C++
(Rust is purposefully excluded from this list since you should already have some knowledge about memory ownership models before you learn it)
Setting up
(If you just want to get to the code you can download this installation script and it’ll walk you through an easy setup designed by me!)
This will look different depending on the operating system you are running.
All operating systems support the clang compiler, so that’s what we’ll be using. The second most supported compiler is GCC, so all examples will be geared towards them.
Here’s a more concrete table of tools that you might find useful throughout this journey.
| OS | Compilers | Debugger |
|---|---|---|
| Windows | MSVC (Installed with Visual Studio) | RADDebugger |
| Windows (WSL) | GCC | GDB |
| Linux | GCC | GDB |
| MacOS | AppleClang | LLDB |
Part 1. The Humble Integer
Integer types
If you come from a language like JS or Python you may only be familiar with the default number or inttypes to represent integers, but in other languages and tech stacks we have different types for integers which change in bit size and their binary interpretation (signed/unsigned).
C provides fixed-width integer types through <stdint.h>. Here’s a reference of all of them alongside their equivalents in the other languages of this series (C++ uses the same names via <cstdint>):
C (stdint.h) | Min | Max | C++ (cstdint) | Odin | Zig |
|---|---|---|---|---|---|
int8_t | -128 | 127 | int8_t | i8 | i8 |
int16_t | -32,768 | 32,767 | int16_t | i16 | i16 |
int32_t | -2,147,483,648 | 2,147,483,647 | int32_t | i32 | i32 |
int64_t | -9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 | int64_t | i64 | i64 |
uint8_t | 0 | 255 | uint8_t | u8 | u8 |
uint16_t | 0 | 65,535 | uint16_t | u16 | u16 |
uint32_t | 0 | 4,294,967,295 | uint32_t | u32 | u32 |
uint64_t | 0 | 18,446,744,073,709,551,615 | uint64_t | u64 | u64 |
These numbers are not arbitrary, they cover the range of possible decimal integers you can represent using their specified amount of bytes, let’s do the math ourselves so we fully understand it.
Take the uint8_t.
As its name states, we have 8 bits to work with, let’s bring up our trusty binary table:
| 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 |
If we decide to turn on any one of those bytes, we add the value above the slot.
| 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 1 |
Becomes => 1
| 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 1 | 0 | 1 |
Becomes => 5
The maximum number we can represent if we turn on ALL the slots:
| 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 | 1 | 1 |
64 + 32 + 16 + 8 + 4 + 2 + 1 = 255
This, of course only covers positive numbers, that’s what the u in uint8_t stands for: unsigned, if we want to represent negative numbers with the same amount of bytes, we need to apply something called Two’s complement, we won’t really get into that right now, but TLDR; it cuts the representable numbers in half (and one extra position for +0 and -0 respectively).
Number limits
For any number of bits (N) in an integer variable, we can quickly find out the limits by doing MAX_NUM = 2^N if the type is unsigned, and MAX_NUM = [(2^N) / 2] - 1 if it’s signed
Optimizing with integers (understand the problem to understand the data)
But… Why is this information useful to us???
Well, knowing the ins and outs of integers and how they are represented can be very beneficial in low level programming, most notably it lets us do memory layout optimizations if we know the specific usage of those number, let’s quickly look at an example:
You are tasked with building a game with A LOT of enemies, we’re talking thousands on screen at any given point, the enemy struct currently looks like this:
typedef struct Enemy {
Vector3 position;
int damage;
int health;
int maxHealth;
int armor;
int level;
} Enemy;
All the fields are self explanatory, as a small note, a Vector3 is 12 bytes in size, so, knowing this, let’s calculate the size of our enemy struct, by default, in most modern C compilers, a raw int is 4 bytes (essentially an int32_t), so
typedef struct Enemy {
Vector3 position; // 12
int damage; // 12 + 4 = 16
int health; // 16 + 4 = 20
int maxHealth; // 20 + 4 = 24
int armor; // 24 + 4 = 28
int level; // 28 + 4 = 32
} Enemy;
So, our enemy is 32 bytes in memory, and remember how I mentioned we’ll have thousands of them on-screen at any given point?
Let’s allocate that in an array:
#define MAX_ON_SCREEN_ENEMIES 2000
Enemy* enemies = malloc(sizeof(Enemy) * MAX_ON_SCREEN_ENEMIES); // 32 * 2000 = 64000 = 62.5 kB
The enemies array memory footprint is 62.5kB, which might not sound like much, but things do add up with time, and if the enemies become more complex throughout development, this will get worse with time, so, how do we optimize things here? By understanding the problem, and modeling the data after it. By not knowing anything specific about our game we can’t really make decisions about what to do to reduce memory usage, but, our imaginary game designer has stipulated that
- Any unit’s health falls between the range of 0 - 100, this can be suported fully by
int8_t(-127, 127) - Given this previous information, the maximum amount of damage we can ever hope to deal is also within 0 and 100, we cover the two extreme cases of not dealing any damage at all, and potentially one-shotting the player, therefore, an
int8_tshould be sufficient here as well. - maxhealth is simply this entity’s original health at creation, so, we mirror the health’s type (
int8_t). - Armor is a factor in a formula the designer came up with that feels pretty good
damageTaken = ((incomingDamage)/(1 + ((armor)/(5))))(based on an actual implementation of armor in League of Legends). You graphed it and this is the result
… It basically plateus at about 180 armor, reducing the damage by about 97%, that no longer falls in the ranged of a signed 8 bit integer, but there’s also no need to increase the bit size of our integer, just to get rid of the sign to go from a maximum of 127 to 255, which is more than enough to cover our most extreme case of super tanky enemies, so we turn this into a uint8_t.
- Levels go from 1 to 18 (did you catch the LoL inspiration again?), the range of possible values is really small again, so, we’ll just do another
int8_t
With this, our struct now looks like this:
typedef struct Enemy {
Vector3 position; // 12
int8_t damage; // 12 + 1 = 13
int8_t health; // 13 + 1 = 14
int8_t maxHealth; // 14 + 1 = 15
uint8_t armor; // 15 + 1 = 16
int8_t level; // 16 + 1 = 17
} Enemy;
Now, due to alignment rules, the compiler will insert extra padding bytes to make sure the struct’s size falls to the next nearest power of 2, in this case it’s 20, even then, we reduced our memory footprint significantly; if we spawn the same 2000 enemies, we have 2000 * 20 = 40000 ~= 39.06kB, that’s a reduction of about 37% of memory usage, just by knowing what our data will be used for and optimizing for that scenario.
This optimization encapsulates pretty well the mindset behind systems programming, data oriented approches, “To understand the problem is to understand the data, if you don’t understand the data, you can’t solve the problem”.
Encoding flags
Now, let’s look at another example that comes up in various types of applications, a role based access system, and we will check how the humble integer can help us make things simpler. I’ve seen approaches similar to this in many codebases:
Data from a DTO passed to a Database
"user": {
"name": "Jon B",
"age": 25,
"admin": true,
"vendor": false,
"manager": true,
"storeOwner": false,
"districtManager": false,
"executive": false,
"stakeholder": false
}
At a glance this can look like a roboust way of handling permissions, after all, logic is gated behind these flags, it’s not bad to be honest, but it is really, REALLY wasteful in many ways, it’s not only what’s in RAM when we create these users, but also in the response/request data over the network, and most importantly, in database itself, every record will have these fields which, at scale can really add up…
Let’s assume our user is represented by this struct.
struct User {
char name[64];
uint32_t age;
bool isAdmin;
bool isVendor;
bool isManager;
bool isStoreOwner;
bool isDistrictManager;
bool isExecutive;
bool isStakeHolder;
}
This comes to a size of 76 bytes per user, let’s just talk about the user data itself, obviously any Database implementation will most likely add metadata on top of it causing way more overhead, assuming our DB holds the data for 100,000 users, that’s about 7.24 MB of data in our hard drive.
Now, let us introduce our next optimization:
BitFlags
Bitflags are simple, yet powerful, they rely on the internal binary representation of numbers we just saw in this section, if we mentally replace each slot in the binary table for a logic role of our app:
| StakeHolder | Executive | DistrictManager | StoreOwner | Manager | Vendor | Admin |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 |