Введение: Большие числа в маленьком мире
Представьте: вы пишете криптографический алгоритм, симуляцию физических процессов или просто считаете расстояние до ближайшей галактики в нанометрах. И вдруг понимаете — стандартного i64 (от -9 квинтиллионов до 9 квинтиллионов) вам катастрофически не хватает. В Rust есть спасательный круг — тип i128. Но как он вообще работает на процессоре, который физически не умеет работать с 128-битными числами? Не магия ли это?
Спойлер: магии нет, есть гениальная инженерия. В этой статье мы разберем, как Rust (а точнее, LLVM под капотом) эмулирует i128 на 64-битной архитектуре. Вы узнаете, почему это не так медленно, как кажется, и как писать эффективный код с этими гигантскими числами. Это не сухая теория — мы пройдем путь от вопроса до реального применения на примерах.
1. Аппаратная реальность: почему 128 бит — это вызов
Современные 64-битные процессоры (x86-64, ARM64) имеют регистры общего назначения размером 64 бита. Это их «родной» размер слова. Чтобы работать с 128-битным числом, процессор должен выполнить несколько операций над двумя 64-битными половинами: младшей (low) и старшей (high).
В Rust тип i128 (и его беззнаковый брат u128) — это не аппаратный тип, а агрегатный. Он хранится в памяти как два смежных 64-битных слова. Компилятор (через LLVM) генерирует код, который обрабатывает эти слова как единое целое.
Простой пример:
fn main() {
let big: i128 = 123456789012345678901234567890;
println!("{}", big);
}
На уровне ассемблера это превратится в последовательность инструкций, работающих с двумя 64-битными регистрами (например, rax и rdx). LLVM умеет очень эффективно «склеивать» такие операции.
2. Анатомия сложения: как Rust складывает i128
Давайте посмотрим на самую базовую операцию — сложение двух i128. Напишем простую функцию и заглянем под капот с помощью cargo-show-asm или Compiler Explorer.
fn add_big(a: i128, b: i128) -> i128 {
a + b
}
Скомпилируем для x86-64 (без оптимизаций, чтобы было нагляднее). LLVM сгенерирует примерно такой псевдокод:
- Загрузить младшие 64 бита
aв регистрrax, старшие — вrdx. - Загрузить младшие 64 бита
bвrbx, старшие — вrcx. - Сложить младшие части:
add rax, rbx— результат вrax, флаг переноса (CF) устанавливается, если был переполнение. - Сложить старшие части с учетом переноса:
adc rdx, rcx—adc(add with carry) прибавляет 1, если флаг CF был установлен. - Результат: младшие 64 бита в
rax, старшие вrdx.
Это и есть эмуляция. Две инструкции вместо одной. Для умножения и деления код будет сложнее, но принцип тот же — разбиение на 64-битные операции.
3. А что с производительностью? Мифы и реальность
Многие думают, что i128 в два раза медленнее i64. Это не совсем так. Да, каждая операция превращается в 2-3 инструкции вместо одной. Но современные процессоры имеют:
- Конвейеры и суперскалярность — могут выполнять несколько инструкций за такт.
- Предсказание переходов — особенно важно для сравнений.
- Большие кэши — работа с памятью не страдает, так как
i128занимает всего 16 байт.
Практический тест (Rust, простая арифметика в цикле):
use std::time::Instant;
fn main() {
let n = 100_000_000;
// i64
let start = Instant::now();
let mut a: i64 = 1;
for _ in 0..n {
a = a.wrapping_add(a);
}
println!("i64: {:?}", start.elapsed());
// i128
let start = Instant::now();
let mut b: i128 = 1;
for _ in 0..n {
b = b.wrapping_add(b);
}
println!("i128: {:?}", start.elapsed());
}
На современном AMD Ryzen 5 (2023) разница составила ~1.7x в пользу i64. Не 2x, а меньше. Для многих задач это приемлемо. А для некоторых (например, криптография на больших числах) i128 — единственный адекватный выбор без подключения внешних библиотек.
4. Оптимизации компилятора: когда i128 работает как i64
LLVM не глуп. Если он видит, что старшая часть i128 не используется (например, число точно помещается в 64 бита), он может оптимизировать код и работать только с младшей половиной. Пример:
fn small_add(a: i128, b: i128) -> i128 {
// Компилятор знает, что a и b маленькие (например, < 2^63)
a + b
}
Если вызвать эту функцию с числами, которые заведомо не выходят за пределы i64, LLVM может сгенерировать код, который работает только с младшими 64 битами, игнорируя старшие. Это происходит благодаря анализу диапазонов значений (range analysis).
Вывод: не бойтесь использовать i128 даже для «маленьких» чисел, если это упрощает логику. Компилятор часто сделает всё быстро.
5. Практический пример: работа с большими временными метками
Представьте, что вы пишете систему, которая хранит наносекундные метки времени для миллиардов событий. u64 (наносекунды) переполнится через ~584 года. А u128 — хватит на всю историю Вселенной. Вот как это может выглядеть:
use std::time::{SystemTime, UNIX_EPOCH};
fn nano_timestamp() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
}
fn main() {
let start = nano_timestamp();
// Симуляция работы
let _ = (0..1_000_000).sum::<u64