Как в Rust работают 128-битные целые числа i128 и u128 на 64-битной системе?
Как Rust обрабатывает 128-битные числа на 64-битной архитектуре
Отличный вопрос! Краткий ответ: да, компилятор эмулирует 128-битные операции через пары 64-битных инструкций. Никакой специальной аппаратной поддержки для полных 128-битных операций в современных x86-64 процессорах нет (за исключением некоторых расширений, например, ADX для сложения с переносом).
1. Как это работает на уровне инструкций?
Рассмотрим сложение двух 128-битных чисел. 128-битное число хранится как два 64-битных "слова": младшее (low) и старшее (high). Сложение происходит в два этапа:
// Псевдокод для a + b, где a = (a_low, a_high), b = (b_low, b_high)
result_low = a_low + b_low;
result_high = a_high + b_high + CF; // CF - флаг переноса (carry flag)
В ассемблере x86-64 это выглядит так:
; Сложение двух 128-битных чисел, хранящихся в парах регистров (rsi:rdi) и (rcx:rdx)
; Результат в (r8:r9)
add rdi, rdx ; складываем младшие 64 бита
adc rsi, rcx ; складываем старшие 64 бита с флагом переноса
mov r9, rdi ; сохраняем младшую часть результата
mov r8, rsi ; сохраняем старшую часть результата
Ключевые инструкции: ADD (сложение без переноса) и ADC (сложение с переносом). Для вычитания используются SUB и SBB. Умножение сложнее — оно требует до 4 умножений 64-битных частей и сборки результата.
2. Есть ли потеря производительности?
Да, и она может быть значительной. Вот наглядное сравнение:
use std::time::Instant;
fn main() {
let n = 100_000_000;
// Тест для i64
let start = Instant::now();
let mut sum_i64: i64 = 0;
for i in 0..n {
sum_i64 = sum_i64.wrapping_add(i);
}
println!("i64: {:?}", start.elapsed());
// Тест для i128
let start = Instant::now();
let mut sum_i128: i128 = 0;
for i in 0..n {
sum_i128 = sum_i128.wrapping_add(i as i128);
}
println!("i128: {:?}", start.elapsed());
}
Типичные результаты (на Intel Core i7-10750H):
i64: ~45 мсi128: ~95 мс
То есть 128-битные операции примерно в 2 раза медленнее для сложения/вычитания и в 3-4 раза медленнее для умножения и деления. Это логично: компилятор генерирует в 2-4 раза больше инструкций.
3. Когда использовать i128?
- Используйте, когда нужен точный диапазон: криптография (RSA, DSA), большие временные метки (наносекунды с 1970 года), финансовые расчёты с фиксированной точностью.
- Избегайте в горячих циклах: если производительность критична, попробуйте разбить задачу на 64-битные части или используйте специализированные крейты (например,
num-bigintдля чисел произвольной точности). - Помните про LLVM: Rust использует LLVM как бэкенд. LLVM умеет оптимизировать 128-битные операции, например, сворачивать их в константы на этапе компиляции или использовать векторные инструкции (SSE/AVX) где возможно.
Важный нюанс: выравнивание в памяти
128-битные числа требуют 16-байтового выравнивания в памяти для максимальной производительности. Rust автоматически выравнивает поля структур и локальные переменные, но при работе с сырыми указателями или упакованными структурами (#[repr(packed)]) могут возникнуть проблемы с производительностью и даже паники на некоторых архитектурах (например, ARM).
// Правильно: компилятор выровняет поле
struct Aligned {
x: i128, // будет выровнено на 16 байт
}
// Потенциально медленно: упакованная структура
#[repr(packed)]
struct Packed {
x: i128, // может быть не выровнено
}
В итоге: i128 — это мощный, но не "бесплатный" инструмент. Используйте его по назначению, и Rust + LLVM сделают всё возможное для оптимальной эмуляции.
Альтернативный взгляд: когда i128 незаменим
Коллега выше отлично объяснил механику работы, но хочу добавить важный практический аспект: в некоторых задачах i128 — единственный разумный выбор, несмотря на накладные расходы.
Пример: уникальные идентификаторы
Представьте, что вы пишете распределённую систему, где нужно генерировать глобально уникальные ID (как UUID, но компактнее). 128 бит дают 3.4 × 10^38 возможных значений — достаточно, чтобы каждый процесс в мире генерировал миллиарды ID в секунду в течение миллиардов лет без коллизий.
use std::time::{SystemTime, UNIX_EPOCH};
fn generate_id(server_id: u16) -> i128 {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos(); // u128 уже!
// Упаковываем: 64 бита timestamp + 48 бит счётчик + 16 бит ID сервера
let counter = 0u64; // в реальности атомарный счётчик
let id = (timestamp << 64) | ((counter as i128) << 16) | (server_id as i128);
id
}
Совет по производительности: используйте u128 вместо i128
Если знак не нужен, используйте u128. Это может дать небольшой выигрыш в некоторых операциях (например, делении), так как компилятору не нужно обрабатывать знаковое расширение.
Также помните про крейт num::BigInt — если вам нужно больше 128 бит, он использует динамическое выделение памяти и может быть медленнее, но даёт неограниченную точность.