Секрет i128 на 64-битных системах: как Rust работает с гигантскими числами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Большие числа в маленьком мире

Представьте: вы пишете криптографический алгоритм, симуляцию физических процессов или просто считаете расстояние до ближайшей галактики в нанометрах. И вдруг понимаете — стандартного 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 сгенерирует примерно такой псевдокод:

  1. Загрузить младшие 64 бита a в регистр rax, старшие — в rdx.
  2. Загрузить младшие 64 бита b в rbx, старшие — в rcx.
  3. Сложить младшие части: add rax, rbx — результат в rax, флаг переноса (CF) устанавливается, если был переполнение.
  4. Сложить старшие части с учетом переноса: adc rdx, rcx — adc (add with carry) прибавляет 1, если флаг CF был установлен.
  5. Результат: младшие 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                

Блоги

Рекомендаций по книгам