The Secret of i128 on 64-bit Systems: How Rust Works with Giant Numbers

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Introduction: Big Numbers in a Small World



Imagine: you're writing a cryptographic algorithm, simulating physical processes, or simply calculating the distance to the nearest galaxy in nanometers. And suddenly you realize — the standard i64 (from -9 quintillion to 9 quintillion) is catastrophically insufficient. Rust has a lifeline — the i128 type. But how does it even work on a processor that physically cannot handle 128-bit numbers? Is it magic?



Spoiler: there is no magic, there is brilliant engineering. In this article, we will break down how Rust (or rather, LLVM under the hood) emulates i128 on a 64-bit architecture. You will learn why it's not as slow as it seems, and how to write efficient code with these giant numbers. This is not dry theory — we will go from the question to real-world application with examples.



1. Hardware Reality: Why 128 Bits is a Challenge



Modern 64-bit processors (x86-64, ARM64) have general-purpose registers that are 64 bits in size. This is their "native" word size. To work with a 128-bit number, the processor must perform several operations on two 64-bit halves: the low part and the high part.



In Rust, the i128 type (and its unsigned sibling u128) is not a hardware type, but an aggregate type. It is stored in memory as two adjacent 64-bit words. The compiler (via LLVM) generates code that processes these words as a single unit.



A simple example:



fn main() {    let big: i128 = 123456789012345678901234567890;    println!("{}", big);}


At the assembly level, this will turn into a sequence of instructions working with two 64-bit registers (e.g., rax and rdx). LLVM is very efficient at "gluing" such operations together.



2. Anatomy of Addition: How Rust Adds i128



Let's look at the most basic operation — adding two i128 values. We'll write a simple function and peek under the hood using cargo-show-asm or Compiler Explorer.



fn add_big(a: i128, b: i128) -> i128 {    a + b}


Compile it for x86-64 (without optimizations, for clarity). LLVM will generate pseudocode roughly like this:



  1. Load the lower 64 bits of a into register rax, the upper bits into rdx.
  2. Load the lower 64 bits of b into rbx, the upper bits into rcx.
  3. Add the lower parts: add rax, rbx — result in rax, the carry flag (CF) is set if there was an overflow.
  4. Add the upper parts with carry: adc rdx, rcx — adc (add with carry) adds 1 if the CF flag was set.
  5. Result: lower 64 bits in rax, upper 64 bits in rdx.


This is emulation. Two instructions instead of one. For multiplication and division, the code will be more complex, but the principle is the same — splitting into 64-bit operations.



3. What About Performance? Myths and Reality



Many people think that i128 is twice as slow as i64. This is not entirely true. Yes, each operation turns into 2-3 instructions instead of one. But modern processors have:



  • Pipelines and Superscalar Execution — can execute multiple instructions per clock cycle.
  • Branch Prediction — especially important for comparisons.
  • Large Caches — memory operations don't suffer, as i128 takes up only 16 bytes.


Practical test (Rust, simple arithmetic in a loop):



use std::time::Instant;

fn main() { let n = 100_000_000; // i64 let start = Instant::now(); let m

Blogs

Book Recommendations