perf
, flamegraph
criterion
.sort_unstable()
.sort_by()
criterion
perf
, flamegraph
Добро пожаловать в раздел, посвященный анализу производительности Rust-приложений с использованием инструментов perf
и flamegraph
. Этот раздел представляет собой глубокое погружение в мир профилирования, где мы разберем, как выявлять узкие места в коде, понимать, где программа тратит время, и визуализировать результаты для дальнейшей оптимизации. Мы будем двигаться от базовых понятий к практическим примерам, подробно объясняя каждый шаг, чтобы вы могли уверенно применять эти инструменты в своих проектах. Лекция ориентирована как на новичков, так и на опытных разработчиков, поэтому мы не пропустим ни одной важной детали.
Производительность — это краеугольный камень разработки программного обеспечения, особенно в системном программировании, где Rust занимает особое место благодаря своей скорости и безопасности. Но даже самый безопасный и элегантный код может работать медленно, если не уделить внимание оптимизации. Анализ производительности помогает нам:
Для анализа производительности Rust-приложений существует множество инструментов, но в этом разделе мы сосредоточимся на двух мощных и широко используемых: perf
и flamegraph
. Эти инструменты прекрасно дополняют друг друга: perf
собирает данные о производительности на низком уровне, а flamegraph
визуализирует их в удобной форме. Давайте разберем их по порядку.
perf
: Мощный инструмент для профилированияperf
— это утилита для профилирования производительности, встроенная в ядро Linux. Она позволяет собирать детализированные данные о поведении программы на уровне системы: использование процессора, кэшей, памяти, системных вызовов и даже аппаратных событий. Для Rust perf
особенно удобен, так как работает с скомпилированными бинарными файлами и не требует специальных изменений в коде или компиляторе.
perf
Прежде чем начать, убедитесь, что perf
установлен на вашей системе. В большинстве дистрибутивов Linux он доступен через пакетный менеджер. Например, в Ubuntu выполните:
sudo apt-get update
sudo apt-get install linux-tools-common linux-tools-$(uname -r)
Здесь $(uname -r)
подставляет версию ядра, чтобы установить совместимую версию perf
. Проверьте установку командой:
perf --version
Если команда не работает или выдает ошибку, возможно, нужно обновить ядро или проверить права доступа (некоторые функции perf
требуют привилегий root).
perf
perf
предоставляет набор подкоманд для разных задач профилирования. Вот основные, с которых стоит начать:
perf stat
: Собирает общую статистику производительности программы.perf record
: Записывает данные профилирования для детального анализа.perf report
: Отображает результаты анализа в текстовом виде.perf annotate
: Показывает аннотированный ассемблерный код с метриками производительности.Давайте разберем их на практике с примерами.
perf stat
Рассмотрим простое Rust-приложение, которое суммирует числа в цикле:
fn main() {
let mut sum = 0;
for i in 0..100_000_000 {
sum += i;
}
println!("Sum: {}", sum);
}
Скомпилируем его в режиме релиза для максимальной производительности:
cargo build --release
Теперь запустим perf stat
для сбора статистики:
perf stat ./target/release/my_app
После выполнения вы увидите отчет, например:
Performance counter stats for './target/release/my_app':
1,234.56 msec task-clock # 0.999 CPUs utilized
0 context-switches # 0.000 K/sec
0 cpu-migrations # 0.000 K/sec
123 page-faults # 0.100 K/sec
3,456,789,012 cycles # 2.800 GHz
1,234,567,890 instructions # 0.36 insn per cycle
123,456,789 branches # 100.000 M/sec
1,234,567 branch-misses # 1.00% of all branches
1.234567890 seconds time elapsed
Разберем ключевые метрики:
instructions / cycles
(IPC) показывает, насколько эффективно CPU выполняет код.Этот отчет дает общее представление о поведении программы. Для нашего примера видно, что программа активно использует CPU, но выполняет мало инструкций на цикл (IPC = 0.36), что может быть связано с простотой кода.
perf record
и perf report
Для более глубокого анализа используем perf record
и perf report
. Запустим профилирование:
perf record ./target/release/my_app
Команда создаст файл perf.data
с данными о производительности. Затем откройте отчет:
perf report
Вы увидите интерактивный интерфейс, например:
# Samples: 1K of event 'cycles'
# Event count (approx.): 1234567890
#
# Overhead Command Shared Object Symbol
# ........ ....... ................. .....................................
#
99.99% my_app my_app [.] main
0.01% my_app [kernel.kallsyms] [k] entry_SYSCALL_64_after_hwframe
Здесь показано, что почти все время (99.99%) тратится в функции main
, что логично для нашего примера. Однако отчет не содержит деталей о внутренних вызовах, так как мы не включили символы отладки.
Чтобы увидеть имена функций и строки кода, скомпилируйте приложение с символами отладки. В Cargo.toml
добавьте:
[profile.release]
debug = true
Перекомпилируйте:
cargo build --release
Повторите профилирование:
perf record ./target/release/my_app
perf report
Теперь отчет будет содержать больше информации, например:
# Overhead Command Shared Object Symbol
# ........ ....... ................. .....................................
#
99.99% my_app my_app [.] main::h7f8e9d8c3b2a1c4d
0.01% my_app [kernel.kallsyms] [k] entry_SYSCALL_64_after_hwframe
Для анализа на уровне строк используйте perf annotate
:
perf annotate
Это покажет ассемблерный код с процентами времени, затраченного на каждую инструкцию. Например:
50.00 : mov %rax, %rbx
30.00 : add %rcx, %rbx
20.00 : cmp %rdx, %rbx
Это полезно для понимания, какие инструкции самые "дорогие".
perf
sudo
или настройте /proc/sys/kernel/perf_event_paranoid
:
sudo sysctl -w kernel.perf_event_paranoid=1
perf
может не собрать достаточно данных. Увеличьте нагрузку (например, больше итераций в цикле).perf
профилирует все потоки. Для анализа конкретного потока используйте опцию -t TID
.flamegraph
: Визуализация стека вызововflamegraph
— это инструмент для создания интерактивных SVG-диаграмм, которые визуализируют стек вызовов и время, затраченное в каждой функции. Он строится на основе данных от perf
и помогает быстро понять, где сосредоточена нагрузка.
flamegraph
Установите flamegraph
через Cargo:
cargo install flamegraph
Убедитесь, что perf
уже установлен, так как flamegraph
зависит от него. Также вам понадобится Perl для обработки данных (обычно он предустановлен в Linux).
flamegraph
Для генерации flamegraph выполните:
cargo flamegraph
Команда автоматически:
perf record
.flamegraph.svg
.Откройте flamegraph.svg
в браузере. Вы увидите диаграмму, где:
Наведите курсор на прямоугольник, чтобы увидеть имя функции и процент времени.
Для нашего примера с суммированием flamegraph покажет, что почти вся ширина занята функцией main
. Это ожидаемо, так как у нас нет сложных вложенных вызовов. Но давайте рассмотрим более сложный пример:
fn compute(n: i32) -> i32 {
let mut result = 0;
for i in 0..n {
result += i * i;
}
result
}
fn main() {
let mut total = 0;
for _ in 0..1000 {
total += compute(1000);
}
println!("Total: {}", total);
}
Сгенерируйте flamegraph:
cargo flamegraph
В flamegraph.svg
вы увидите, что время распределяется между main
и compute
. Щелкните на compute
, чтобы увеличить его и изучить детали.
flamegraph
--flamechart-opts "--grep compute"
:
cargo flamegraph --flamechart-opts "--grep compute"
--thread
для анализа отдельных потоков.main::h7f8e9d8c3b2a1c4d
). Включите debug = true
в Cargo.toml
.perf
для низкоуровневого анализа, а flamegraph
для визуализации.Мы изучили, как использовать perf
и flamegraph
для анализа производительности Rust-приложений. Эти инструменты позволяют глубоко заглянуть в работу программы, выявить узкие места и подготовить почву для оптимизации. В следующих разделах мы перейдем к бенчмаркам с criterion
и конкретным техникам оптимизации.
Упражнение: Возьмите свое Rust-приложение (или используйте пример выше), скомпилируйте его с символами отладки, профилируйте с perf record
и сгенерируйте flamegraph
. Определите, какая функция занимает больше всего времени, и подумайте, как ее можно ускорить. Результаты запишите для обсуждения в следующем разделе.
Этот раздел — лишь начало пути в мир оптимизации. Мы заложили фундамент, который поможет вам уверенно двигаться дальше!
criterion
В этом разделе мы подробно разберем процесс бенчмаркинга Rust-кода с использованием библиинки criterion
— одного из самых мощных и популярных инструментов для измерения производительности в экосистеме Rust. Мы рассмотрим, зачем нужен бенчмаркинг, как установить и использовать criterion
, разберем базовые и продвинутые примеры, обсудим лучшие практики, скрытые возможности и потенциальные подводные камни. Лекция будет самодостаточной, с избыточным покрытием темы, чтобы вы могли не только освоить основы, но и глубоко понять нюансы работы с этим инструментом.
Бенчмаркинг — это процесс измерения производительности кода, который позволяет количественно оценить, насколько быстро (или медленно) выполняется та или иная часть программы. Это важный этап в оптимизации, поскольку без точных данных легко попасть в ловушку преждевременной оптимизации — изменения кода, которые усложняют его без реальной пользы. Вот ключевые причины, почему бенчмаркинг необходим:
Без бенчмаркинга оптимизация превращается в "стрельбу в темноте". criterion
делает этот процесс точным, удобным и воспроизводимым.
criterion
?criterion
— это библиотека для Rust, специально разработанная для создания, запуска и анализа бенчмарков. Она выделяется следующими особенностями:
Если вы хотите не просто "прикинуть" производительность, а получить данные, которым можно доверять, criterion
— ваш лучший выбор.
criterion
Для начала работы с criterion
нужно добавить его в зависимости вашего проекта. Поскольку бенчмарки обычно используются только на этапе разработки и тестирования, добавляем его как dev-dependency
в файл Cargo.toml
:
[dev-dependencies]
criterion = "0.5" # Используйте актуальную версию на момент чтения
После этого создайте директорию benches
в корне проекта (если её ещё нет) — это стандартное место для бенчмарков в Rust. Например, создайте файл benches/my_benchmark.rs
. Чтобы Cargo
распознал бенчмарки, убедитесь, что в Cargo.toml
нет конфликтующих настроек (например, отключения бенчмарков через [[bench]]
).
Для запуска бенчмарков используйте команду:
cargo bench
Это скомпилирует и выполнит все бенчмарки, а результаты будут выведены в консоль и сохранены в папке target/criterion
.
Подводный камень: Если вы используете устаревшую версию criterion
(например, 0.3 вместо 0.5), некоторые API могут отличаться. Всегда проверяйте документацию на crates.io или в официальном репозитории.
criterion
Давайте начнем с простого примера: измерим производительность функции, которая суммирует элементы вектора.
Сначала определим функцию в lib.rs
или main.rs
:
pub fn sum_vector(vec: &[i32]) -> i32 {
vec.iter().sum()
}
Теперь создадим бенчмарк в benches/my_benchmark.rs
:
use criterion::{criterion_group, criterion_main, Criterion};
// Предполагается, что sum_vector доступна из корневого модуля
use my_crate::sum_vector;
fn bench_sum_vector(c: &mut Criterion) {
let vec = vec![1; 10_000]; // Вектор из 10_000 единиц
c.bench_function("sum_vector", |b| {
b.iter(|| sum_vector(&vec));
});
}
criterion_group!(benches, bench_sum_vector);
criterion_main!(benches);
Разбор кода:
criterion_group
и criterion_main
:
criterion_group!(benches, bench_sum_vector)
группирует все бенчмарки (в данном случае только один).criterion_main!(benches)
определяет точку входа для запуска бенчмарков.c.bench_function
:
"sum_vector"
) и замыкание, содержащее код для тестирования.b.iter
выполняет код многократно, измеряя время.vec
фиксированного размера вне замыкания, чтобы его создание не влияло на измерения.Запустите cargo bench
, и вы увидите вывод вроде:
sum_vector time: [12.345 µs 12.400 µs 12.460 µs]
Это среднее время выполнения с доверительным интервалом.
Подводный камень: Компилятор Rust может оптимизировать код, убрав вызов sum_vector
, если его результат не используется. Чтобы этого избежать, используйте criterion::black_box
:
fn bench_sum_vector(c: &mut Criterion) {
let vec = vec![1; 10_000];
c.bench_function("sum_vector", |b| {
b.iter(|| criterion::black_box(sum_vector(&vec)));
});
}
black_box
— это "черный ящик", который заставляет компилятор считать, что результат используется, предотвращая нежелательные оптимизации.
Часто нужно тестировать производительность с разными входными данными. criterion
позволяет это делать с помощью групп и BenchmarkId
.
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use my_crate::sum_vector;
fn bench_sum_vector(c: &mut Criterion) {
let mut group = c.benchmark_group("sum_vector");
for size in [1_000, 10_000, 100_000].iter() {
let vec = vec![1; *size];
group.bench_with_input(BenchmarkId::new("sum", size), size, |b, _| {
b.iter(|| sum_vector(criterion::black_box(&vec)));
});
}
group.finish();
}
criterion_group!(benches, bench_sum_vector);
criterion_main!(benches);
Разбор:
benchmark_group
: Создает группу бенчмарков с общим именем "sum_vector"
. Это удобно для логической организации и настройки параметров группы.bench_with_input
: Позволяет передать входные данные (в данном случае size
) и идентификатор BenchmarkId
.finish()
: Завершает группу, фиксируя результаты.Результаты покажут время выполнения для каждого размера вектора, что поможет понять, как производительность зависит от объема данных.
Практический совет: Используйте параметризацию для анализа асимптотической сложности (O(n), O(n²) и т.д.). Это особенно полезно при сравнении алгоритмов.
criterion
criterion
предлагает множество инструментов для сложных сценариев. Рассмотрим их подробнее.
Если создание входных данных занимает много времени, его не стоит включать в измерения. Для этого используйте iter_batched
.
fn expensive_setup() -> Vec {
(0..100_000).map(|x| x % 17).collect() // Имитация сложных вычислений
}
fn bench_with_setup(c: &mut Criterion) {
c.bench_function("sum_with_setup", |b| {
b.iter_batched(
expensive_setup, // Setup-функция, выполняется один раз
|vec| sum_vector(&vec), // Тестируемая функция
criterion::BatchSize::SmallInput, // Размер "батча"
);
});
}
iter_batched
: Выполняет expensive_setup
один раз, а затем многократно использует результат для измерений.BatchSize
: Указывает, сколько данных создается за раз. SmallInput
подходит для небольших структур, а LargeInput
— для больших.Подводный камень: Если BatchSize
выбран неправильно, это может исказить результаты. Например, слишком большой батч может перегрузить кэш процессора.
Допустим, у нас есть два способа суммирования: с использованием итератора и цикла.
fn sum_iter(vec: &[i32]) -> i32 {
vec.iter().sum()
}
fn sum_loop(vec: &[i32]) -> i32 {
let mut sum = 0;
for &x in vec {
sum += x;
}
sum
}
fn bench_compare(c: &mut Criterion) {
let mut group = c.benchmark_group("sum_methods");
let vec = vec![1; 10_000];
group.bench_function("iter", |b| b.iter(|| sum_iter(criterion::black_box(&vec))));
group.bench_function("loop", |b| b.iter(|| sum_loop(criterion::black_box(&vec))));
group.finish();
}
Результаты покажут, какой метод быстрее. criterion
также сгенерирует графики для визуального сравнения.
Вы можете настроить такие параметры, как время разогрева или количество итераций.
use criterion::Criterion;
fn custom_config() -> Criterion {
Criterion::default()
.warm_up_time(std::time::Duration::from_secs(3)) // Время разогрева
.measurement_time(std::time::Duration::from_secs(10)) // Время измерений
.sample_size(200) // Количество выборок
}
fn bench_with_config(c: &mut Criterion) {
let vec = vec![1; 10_000];
c.bench_function("sum_vector", |b| b.iter(|| sum_vector(&vec)));
}
criterion_group! {
name = benches;
config = custom_config();
targets = bench_with_config
}
criterion_main!(benches);
Практический совет: Для быстрых функций увеличьте sample_size
, а для долгих — measurement_time
.
После запуска cargo bench
результаты сохраняются в target/criterion
. Откройте target/criterion/report/index.html
в браузере, чтобы увидеть:
Скрытая возможность: Используйте флаг --plotting-backend gnuplot
для более детальных графиков (требуется установка gnuplot
).
black_box
: Это гарантирует, что компилятор не "вырежет" тестируемый код.Подводный камень: Результаты могут отличаться на разных машинах или при разной нагрузке системы. Для воспроизводимости фиксируйте окружение (например, с помощью Docker).
Рассмотрим более сложный случай — сравнение стандартной сортировки sort
и пользовательской реализации быстрой сортировки.
fn quicksort(arr: &mut [T]) {
if arr.len() <= 1 {
return;
}
let pivot = partition(arr);
quicksort(&mut arr[..pivot]);
quicksort(&mut arr[pivot + 1..]);
}
fn partition(arr: &mut [T]) -> usize {
let pivot = arr.len() - 1;
let mut i = 0;
for j in 0..pivot {
if arr[j] <= arr[pivot] {
arr.swap(i, j);
i += 1;
}
}
arr.swap(i, pivot);
i
}
fn bench_sort(c: &mut Criterion) {
let mut group = c.benchmark_group("sort");
for size in [1_000, 10_000, 100_000].iter() {
let mut vec = (0..*size).map(|_| rand::random::()).collect::>();
let mut vec_clone = vec.clone();
group.bench_with_input(BenchmarkId::new("std::sort", size), &vec, |b, v| {
b.iter(|| {
let mut v = v.clone();
v.sort();
});
});
group.bench_with_input(BenchmarkId::new("quicksort", size), &vec_clone, |b, v| {
b.iter(|| {
let mut v = v.clone();
quicksort(&mut v);
});
});
}
group.finish();
}
Зависимости: Добавьте rand = "0.8"
в Cargo.toml
.
Анализ: Стандартная sort
обычно быстрее благодаря оптимизациям (она использует адаптивный алгоритм introsort), но самописный quicksort
может быть полезен для обучения или специфичных случаев.
criterion
— это незаменимый инструмент для бенчмаркинга в Rust, который сочетает простоту, точность и гибкость. Он позволяет не только измерить производительность, но и сравнить альтернативы, настроить процесс тестирования и визуализировать результаты. Освоив этот инструмент, вы сможете принимать обоснованные решения об оптимизации, избегая предположений и ненужных усложнений кода.
В следующих разделах мы углубимся в другие аспекты оптимизации и профилирования, такие как использование perf
и flamegraph
, инлайнинг, выбор алгоритмов и техники избежания копирования. Но уже сейчас, с criterion
, у вас есть мощный фундамент для анализа производительности вашего кода.
Добро пожаловать в раздел, посвященный оптимизации в Rust с акцентом на такие техники, как инлайнинг функций и выбор подходящих алгоритмов. Мы углубимся в детали того, как Rust позволяет разработчикам управлять производительностью, рассмотрим тонкости компилятора, предоставим примеры кода с пояснениями и разберем подводные камни. Этот раздел будет полезен как новичкам, желающим понять основы, так и опытным разработчикам, стремящимся выжать максимум из своих программ.
Rust — это язык, который изначально проектировался с учетом производительности. Благодаря строгой системе типов, отсутствию сборщика мусора и контролю над памятью на уровне компиляции, он предоставляет мощные инструменты для создания быстрого кода. Однако даже в таком языке оптимизация требует осознанного подхода. В этом разделе мы сосредоточимся на двух ключевых аспектах:
Инлайнинг — это процесс, при котором компилятор заменяет вызов функции на её непосредственное тело в месте вызова. Это устраняет накладные расходы на создание стека вызовов (call stack) и переходы, что может значительно ускорить выполнение программы, особенно для небольших функций, вызываемых часто.
В Rust компилятор (rustc
, использующий LLVM под капотом) автоматически решает, какие функции стоит инлайнить, на основе эвристик и уровня оптимизации (например, -O2
, -O3
). Однако вы можете влиять на этот процесс с помощью атрибутов.
Rust предоставляет три ключевых атрибута:
#[inline]
— подсказывает компилятору, что функция должна быть инлайнена, если это возможно.#[inline(always)]
— заставляет компилятор инлайнить функцию всегда, независимо от эвристик (но не гарантирует этого в некоторых случаях, например, при рекурсии).#[inline(never)]
— запрещает инлайнинг функции, что полезно для профилирования или крупных функций.Рассмотрим простой пример:
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let x = 5;
let y = 10;
let result = add(x, y);
println!("Result: {}", result);
}
Без оптимизации компилятор создаст вызов функции add
. Однако при уровне оптимизации -O2
или выше rustc
скорее всего инлайнит add
, заменив вызов на 5 + 10
. Чтобы явно указать это поведение, добавим атрибут:
#[inline]
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[inline(always)]
Если функция маленькая, но вызывается в горячей точке (hot path) программы, например, в цикле, #[inline(always)]
может быть полезным:
#[inline(always)]
fn square(x: i32) -> i32 {
x * x
}
fn main() {
let mut sum = 0;
for i in 0..1_000_000 {
sum += square(i);
}
println!("Sum: {}", sum);
}
Здесь square
будет встроена в цикл, устраняя накладные расходы на вызов.
#[inline(always)]
игнорируется в некоторых случаях, например, при рекурсии или если функция слишком сложная.Практический совет: Используйте #[inline]
для небольших функций, которые часто вызываются. Применяйте #[inline(always)]
только после профилирования, если вы уверены, что это улучшит производительность. Для отладки сгенерированного кода используйте cargo rustc --release -- --emit asm
и смотрите assembler-вывод.
Даже самый оптимизированный код не спасет, если алгоритм имеет плохую асимптотическую сложность. В Rust выбор алгоритма — это не только вопрос скорости, но и эффективного использования ресурсов (памяти, кэша).
Рассмотрим задачу сортировки массива. Rust предоставляет встроенную функцию sort
, которая реализует Introsort (гибрид быстрой сортировки, пирамидальной сортировки и вставочной сортировки). Но что, если нам нужно что-то специфичное?
sort
fn main() {
let mut numbers = vec![5, 2, 9, 1, 5, 6];
numbers.sort();
println!("{:?}", numbers); // [1, 2, 5, 5, 6, 9]
}
Сложность: O(n log n)
в среднем. Подходит для большинства случаев.
Для маленьких массивов или особых случаев можно написать свою реализацию:
fn bubble_sort(arr: &mut [i32]) {
let len = arr.len();
for i in 0..len {
for j in 0..len - 1 - i {
if arr[j] > arr[j + 1] {
arr.swap(j, j + 1);
}
}
}
}
fn main() {
let mut numbers = vec![5, 2, 9, 1, 5, 6];
bubble_sort(&mut numbers);
println!("{:?}", numbers); // [1, 2, 5, 5, 6, 9]
}
Сложность: O(n²)
. Пузырьковая сортировка ужасна для больших данных, но может быть полезна для массивов размером до 10 элементов, где накладные расходы sort
выше.
Если данные имеют ограниченный диапазон (например, числа от 0 до 100), можно использовать линейный алгоритм:
fn counting_sort(arr: &mut [i32], max_val: i32) {
let mut counts = vec![0; max_val as usize + 1];
for &num in arr.iter() {
counts[num as usize] += 1;
}
let mut pos = 0;
for i in 0..=max_val as usize {
while counts[i] > 0 {
arr[pos] = i as i32;
pos += 1;
counts[i] -= 1;
}
}
}
fn main() {
let mut numbers = vec![5, 2, 2, 1, 5, 0];
counting_sort(&mut numbers, 5);
println!("{:?}", numbers); // [0, 1, 2, 2, 5, 5]
}
Сложность: O(n + k)
, где k
— диапазон значений. Это быстрее O(n log n)
для малых k
.
sort_unstable
быстрее sort
, но не сохраняет порядок равных элементов.criterion
(см. раздел 2) для замеров.Практический совет: Начните с встроенных методов (sort
, sort_unstable
). Для специфичных задач пишите кастомные алгоритмы, но только после профилирования. Используйте cargo bench
для сравнения реализаций.
Инлайнинг и выбор алгоритмов — это мощные инструменты в арсенале Rust-разработчика. Инлайнинг позволяет устранить накладные расходы на вызовы функций, но требует осторожности, чтобы не раздуть бинарник. Выбор алгоритма определяет фундаментальную производительность, и в Rust вы можете как полагаться на стандартную библиотеку, так и писать свои реализации для особых случаев. В следующих разделах мы рассмотрим, как измерять эффект этих оптимизаций с помощью профилирования и бенчмарков.
Если у вас есть вопросы или вы хотите разобрать конкретный пример, дайте знать!
Добро пожаловать в раздел, посвящённый одной из ключевых тем оптимизации в Rust — избежанию ненужного копирования данных. Rust, как язык с акцентом на производительность и безопасность памяти, предоставляет мощные инструменты для минимизации накладных расходов, связанных с копированием. Однако без понимания тонкостей владения (ownership), заимствования (borrowing) и работы с типами вы можете случайно ввести избыточные операции копирования, которые замедлят ваш код. В этом разделе мы разберём, как копирование возникает в Rust, как его избегать, какие инструменты и техники использовать, а также рассмотрим примеры, подводные камни и лучшие практики.
В Rust термин "копирование" может относиться к двум разным концепциям:
.clone()
). Это дорогостоящая операция для сложных структур, таких как Vec
или String
.Copy
, без выделения новой памяти. Это дешёвая операция, но она применима только к простым типам, таким как i32
или bool
.Избегать копирования важно, потому что:
Rust помогает избегать копирования благодаря системе владения и заимствования, но неправильное использование этих механизмов может привести к ненужным .clone()
вызовам или другим проблемам. Давайте разберём это шаг за шагом.
Прежде чем говорить об избежании копирования, важно понять, где оно возникает.
Copy
Типы, реализующие трейт Copy
, копируются автоматически при присваивании или передаче в функцию. Это поведение не требует явного вызова .clone()
. Примеры:
i32
, f64
, bool
.Copy
-типы, если они отмечены как #[derive(Copy, Clone)]
.#[derive(Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
fn print_point(p: Point) {
println!("x: {}, y: {}", p.x, p.y);
}
fn main() {
let p1 = Point { x: 10, y: 20 };
let p2 = p1; // Копирование, так как Point реализует Copy
print_point(p1); // p1 всё ещё доступен
}
Здесь нет накладных расходов, так как копирование — это просто побитовая операция. Однако большинство сложных типов (Vec
, String
и т.д.) не реализуют Copy
, так как их копирование требует выделения памяти.
Типы, не реализующие Copy
, при присваивании или передаче в функцию перемещаются (move). После этого исходная переменная становится недоступной.
fn take_vec(v: Vec<i32>) {
println!("{:?}", v);
}
fn main() {
let v1 = vec![1, 2, 3];
take_vec(v1); // v1 перемещён
// println!("{:?}", v1); // Ошибка: v1 больше не валиден
}
Перемещение само по себе не создаёт копию, но если вы хотите сохранить доступ к данным в исходной переменной, вы можете случайно прибегнуть к .clone()
:
let v1 = vec![1, 2, 3];
take_vec(v1.clone()); // Клонирование!
println!("{:?}", v1); // Теперь работает, но за счёт копии
Вызов .clone()
— это самый очевидный источник копирования. Он используется, когда нужно создать независимую копию данных, но часто это делается из-за лени или непонимания альтернатив.
Теперь, когда мы понимаем, где возникает копирование, давайте разберём техники его избежания.
Заимствование — это краеугольный камень Rust, позволяющий передавать данные без перемещения или копирования. Используйте ссылки (&T
или &mut T
) вместо передачи владения.
fn print_vec(v: &Vec<i32>) { // Заимствуем, а не перемещаем
println!("{:?}", v);
}
fn main() {
let v = vec![1, 2, 3];
print_vec(&v); // Передаём ссылку
println!("{:?}", v); // v всё ещё доступен
}
Подводный камень: Если функция требует владение (fn take_vec(v: Vec<i32>)
), вы не сможете просто передать ссылку. В таком случае нужно либо изменить сигнатуру функции, либо переосмыслить дизайн.
Совет: Всегда проверяйте, можно ли обойтись заимствованием вместо передачи владения. Это требует дисциплины, но окупается в производительности.
Если функция должна "изменить" данные и вернуть их, передавайте владение и возвращайте его обратно, избегая клонирования.
fn append_one(mut v: Vec<i32>) -> Vec<i32> {
v.push(1);
v // Возвращаем владение
}
fn main() {
let v = vec![1, 2, 3];
let v = append_one(v); // v переиспользуется
println!("{:?}", v); // [1, 2, 3, 1]
}
Альтернатива с клонированием выглядела бы так:
fn append_one_bad(v: &Vec<i32>) -> Vec<i32> {
let mut new_v = v.clone(); // Копирование!
new_v.push(1);
new_v
}
Подводный камень: Если функция возвращает новый объект, а не модифицирует старый, это может быть оправданным поводом для копирования. В таком случае ищите другие оптимизации (например, переиспользование буферов).
&mut
для изменения на местеЕсли нужно модифицировать данные, передавайте изменяемую ссылку (&mut T
) вместо создания копии.
fn append_one_in_place(v: &mut Vec<i32>) {
v.push(1);
}
fn main() {
let mut v = vec![1, 2, 3];
append_one_in_place(&mut v);
println!("{:?}", v); // [1, 2, 3, 1]
}
Это особенно полезно для больших структур, таких как Vec
или HashMap
, где клонирование было бы дорогим.
&[T]
) вместо Vec<T>
Если вам нужно передать часть вектора в функцию, используйте срезы вместо создания нового Vec
.
fn sum_slice(slice: &[i32]) -> i32 {
slice.iter().sum()
}
fn main() {
let v = vec![1, 2, 3, 4];
let total = sum_slice(&v[1..3]); // Срез, без копирования
println!("{}", total); // 5 (2 + 3)
}
Подводный камень: Срезы требуют, чтобы исходные данные жили достаточно долго. Убедитесь, что время жизни (lifetime
) соответствует вашим ожиданиям.
Если вы работаете с коллекциями, старайтесь переиспользовать память вместо выделения новой. Например:
fn collect_filtered(v: Vec<i32>) -> Vec<i32> {
let mut result = Vec::with_capacity(v.len()); // Предвыделяем память
for x in v {
if x > 0 {
result.push(x);
}
}
result
}
Ещё лучше — модифицировать вектор на месте:
fn filter_in_place(v: &mut Vec<i32>) {
v.retain(|&x| x > 0); // Удаляем элементы, не создавая новый вектор
}
Итераторы в Rust ленивые и позволяют обрабатывать данные без создания промежуточных копий.
let v = vec![1, 2, 3, 4];
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect(); // Без промежуточных копий
Совет: Изучите методы итераторов (filter
, map
, fold
), чтобы минимизировать ручное копирование.
Допустим, вы хотите извлечь подстроку и передать её в функцию:
fn process_substring(s: String) {
println!("{}", s);
}
fn main() {
let s = String::from("Hello, world");
process_substring(s[0..5].to_string()); // Копирование!
}
Оптимизированная версия:
fn process_substring(s: &str) {
println!("{}", s);
}
fn main() {
let s = String::from("Hello, world");
process_substring(&s[0..5]); // Срез, без копирования
}
Неоптимальный код:
fn merge_vecs(v1: Vec<i32>, v2: Vec<i32>) -> Vec<i32> {
let mut result = v1.clone();
result.extend(v2);
result
}
Оптимизированный код:
fn merge_vecs(mut v1: Vec<i32>, v2: Vec<i32>) -> Vec<i32> {
v1.extend(v2); // Переиспользуем v1
v1
}
.clone()
: Это "аварийный выход", а не решение. Если вы часто используете .clone()
, пересмотрите дизайн.String
в &str
или Vec
в &[T]
часто можно обойти.Избежание копирования в Rust — это искусство использования системы владения и заимствования в свою пользу. Передавайте ссылки, модифицируйте данные на месте, используйте срезы и итераторы, переиспользуйте память — и ваш код станет быстрее без потери безопасности. В следующих разделах мы применим эти принципы к реальным примерам оптимизации, включая ускорение сортировки и профилирование производительности.
Если у вас есть вопросы или вы хотите разобрать конкретный пример — дайте знать!
Добро пожаловать в раздел, где мы погружаемся в практическую оптимизацию на примере ускорения сортировки в Rust. Сортировка — это одна из самых распространённых задач в программировании, и её производительность может существенно влиять на общую эффективность программы. Мы рассмотрим несколько подходов к оптимизации сортировки, начиная с базового использования стандартной библиотеки, затем углубимся в нюансы выбора алгоритмов, избежания лишних аллокаций, использования unsafe-кода для экстремальной производительности и анализа компромиссов. Этот раздел будет самодостаточным, с примерами кода, комментариями, анализом "подводных камней" и лучшими практиками.
Сортировка — это процесс упорядочивания элементов в коллекции по заданному критерию (например, по возрастанию или убыванию). В Rust стандартная библиотека предоставляет метод .sort()
для изменяемых векторов (Vec<T>
), который реализует устойчивую сортировку с временной сложностью O(n log n). Однако "из коробки" это не всегда оптимально для всех случаев. Мы рассмотрим:
.sort()
и её улучшение через .sort_unstable()
.Каждый пример будет сопровождаться кодом, объяснением, анализом производительности и указанием ситуаций, где подход применим.
.sort_unstable()
Начнём с простого: сортировка вектора чисел с использованием стандартной библиотеки.
use std::time::Instant;
fn main() {
let mut numbers: Vec<i32> = (0..1_000_000).rev().collect(); // Худший случай: убывающая последовательность
let start = Instant::now();
numbers.sort(); // Устойчивая сортировка (stable sort)
let duration = start.elapsed();
println!("Stable sort took: {:?}", duration);
assert!(numbers.windows(2).all(|w| w[0] <= w[1])); // Проверка корректности
}
.sort()
использует Timsort (гибрид сортировки слиянием и вставками), который обеспечивает устойчивость (стабильность) — порядок одинаковых элементов сохраняется.Устойчивость требует дополнительных вычислений. Если стабильность не нужна (а в большинстве числовых сортировок это так), мы переплачиваем за ненужную функциональность.
.sort_unstable()
Меняем .sort()
на .sort_unstable()
:
use std::time::Instant;
fn main() {
let mut numbers: Vec<i32> = (0..1_000_000).rev().collect();
let start = Instant::now();
numbers.sort_unstable(); // Неустойчивая сортировка (unstable sort)
let duration = start.elapsed();
println!("Unstable sort took: {:?}", duration);
assert!(numbers.windows(2).all(|w| w[0] <= w[1]));
}
.sort_unstable()
использует introsort (гибрид быстрой сортировки, пирамидальной сортировки и вставок).Практический совет: Используйте .sort_unstable()
для примитивных типов (числа, строки), если порядок равных элементов не важен. Для пользовательских типов с ключами, где стабильность критична (например, сортировка записей по дате, затем по имени), оставьте .sort()
.
.sort_by()
Если данные имеют особую структуру, стандартный оператор <
может быть не оптимальным. Рассмотрим сортировку структур с кэшированным значением.
use std::time::Instant;
#[derive(Debug)]
struct Item {
value: i32,
expensive_key: i32, // Предположим, это результат сложных вычислений
}
impl Item {
fn compute_key(&self) -> i32 {
self.expensive_key // Здесь могла быть дорогая операция, например, вычисление хэша
}
}
fn main() {
let mut items: Vec<Item> = (0..100_000)
.map(|i| Item {
value: i,
expensive_key: i % 100,
})
.rev()
.collect();
let start = Instant::now();
items.sort_by(|a, b| a.compute_key().cmp(&b.compute_key()));
let duration = start.elapsed();
println!("Sort with compute_key took: {:?}", duration);
}
compute_key()
вызывается многократно для каждого элемента при каждом сравнении.compute_key()
дорогой (например, вычисляет хэш или выполняет I/O), это резко снижает производительность.Добавим предварительное вычисление ключей:
use std::time::Instant;
#[derive(Debug)]
struct Item {
value: i32,
expensive_key: i32,
}
fn main() {
let mut items: Vec<Item> = (0..100_000)
.map(|i| Item {
value: i,
expensive_key: i % 100,
})
.rev()
.collect();
let start = Instant::now();
// Преобразуем в вектор пар (ключ, элемент), сортируем, затем убираем ключи
let mut keyed: Vec<(i32, Item)> = items.into_iter().map(|item| (item.expensive_key, item)).collect();
keyed.sort_unstable_by(|a, b| a.0.cmp(&b.0));
let sorted: Vec<Item> = keyed.into_iter().map(|(_, item)| item).collect();
let duration = start.elapsed();
println!("Sort with precomputed keys took: {:?}", duration);
}
(ключ, элемент)
, что исключает повторные вызовы compute_key()
..sort_unstable_by()
для дополнительной скорости.Лучшая практика: Для дорогих сравнений всегда кэшируйте ключи перед сортировкой. Используйте .sort_by_key()
для простых случаев, где ключ — чистая функция без побочных эффектов.
Если данные тяжёлые (например, большие структуры), копирование элементов при сортировке может быть дорогостоящим. Вместо этого можно сортировать массив индексов.
use std::time::Instant;
#[derive(Debug)]
struct HeavyData {
data: [u8; 1024], // Большой массив
key: i32,
}
fn main() {
let mut items: Vec<HeavyData> = (0..10_000)
.map(|i| HeavyData {
data: [0; 1024],
key: i % 100,
})
.rev()
.collect();
let start = Instant::now();
// Создаём вектор индексов
let mut indices: Vec<usize> = (0..items.len()).collect();
indices.sort_unstable_by(|&i, &j| items[i].key.cmp(&items[j].key));
// Перестраиваем исходный вектор по индексам
let sorted: Vec<HeavyData> = indices.into_iter().map(|i| items[i]).collect();
let duration = start.elapsed();
println!("Sort by indices took: {:?}", duration);
}
usize
), а не сами структуры.Для полного избежания аллокаций можно переставить элементы по индексам:
use std::time::Instant;
#[derive(Debug)]
struct HeavyData {
data: [u8; 1024],
key: i32,
}
fn main() {
let mut items: Vec<HeavyData> = (0..10_000)
.map(|i| HeavyData {
data: [0; 1024],
key: i % 100,
})
.rev()
.collect();
let start = Instant::now();
let mut indices: Vec<usize> = (0..items.len()).collect();
indices.sort_unstable_by(|&i, &j| items[i].key.cmp(&items[j].key));
// Перестановка элементов по индексам
for i in 0..items.len() {
while indices[i] != i {
let j = indices[i];
items.swap(i, j);
indices.swap(i, j);
}
}
let duration = start.elapsed();
println!("In-place sort by indices took: {:?}", duration);
}
Если данные имеют известные свойства (например, ограниченный диапазон), можно использовать алгоритмы вроде сортировки подсчётом (counting sort).
use std::time::Instant;
fn counting_sort(arr: &mut [i32], max_val: i32) {
let mut counts = vec![0; max_val as usize + 1];
for &num in arr.iter() {
counts[num as usize] += 1;
}
let mut pos = 0;
for i in 0..counts.len() {
while counts[i] > 0 {
arr[pos] = i as i32;
counts[i] -= 1;
pos += 1;
}
}
}
fn main() {
let mut numbers: Vec<i32> = (0..1_000_000).map(|_| rand::random::<i32>() % 100).collect();
let start = Instant::now();
counting_sort(&mut numbers, 99); // Максимальное значение — 99
let duration = start.elapsed();
println!("Counting sort took: {:?}", duration);
assert!(numbers.windows(2).all(|w| w[0] <= w[1]));
}
Для максимальной скорости можно написать собственную быструю сортировку с использованием unsafe:
use std::time::Instant;
unsafe fn quicksort<T: Ord>(arr: &mut [T], low: isize, high: isize) {
if low < high {
let pivot = partition(arr, low, high);
quicksort(arr, low, pivot - 1);
quicksort(arr, pivot + 1, high);
}
}
unsafe fn partition<T: Ord>(arr: &mut [T], low: isize, high: isize) -> isize {
let pivot = high as usize;
let mut i = low - 1;
for j in low..high {
if arr[j as usize] <= arr[pivot] {
i += 1;
arr.swap(i as usize, j as usize);
}
}
arr.swap((i + 1) as usize, pivot);
i + 1
}
fn main() {
let mut numbers: Vec<i32> = (0..1_000_000).rev().collect();
let start = Instant::now();
unsafe {
quicksort(&mut numbers, 0, (numbers.len() - 1) as isize);
}
let duration = start.elapsed();
println!("Unsafe quicksort took: {:?}", duration);
assert!(numbers.windows(2).all(|w| w[0] <= w[1]));
}
Когда применять? Только в критических участках кода после профилирования. Используйте с осторожностью и документируйте.
.sort_unstable()
для простых типов, специализированные алгоритмы (counting sort) для ограниченных данных, unsafe для экстремальных случаев.Instant
) перед и после оптимизации.Этот раздел дал вам инструменты для ускорения сортировки в самых разных сценариях. Попробуйте применить их к своим задачам и измерьте результат!
Добро пожаловать в практическую часть главы 29! Здесь мы не просто обсуждаем теорию, а применяем знания на практике, оптимизируя реальный алгоритм. Упражнение — это кульминация всего, что вы узнали о производительности, профилировании и оптимизации в предыдущих разделах. Мы начнем с базового примера, проведем его через процесс анализа и оптимизации, рассмотрим несколько подходов, обсудим "подводные камни" и дадим вам возможность самостоятельно поэкспериментировать. Погружаемся!
Вам дан простой алгоритм для поиска парного соответствия в массиве чисел: нужно найти такие два числа, сумма которых равна заданному значению target
. Это классическая задача "Two Sum", которую мы будем оптимизировать. Код написан на Rust, но изначально он не оптимизирован — это ваша песочница для экспериментов.
fn find_pair_bruteforce(numbers: &[i32], target: i32) -> Option<(usize, usize)> {
for i in 0..numbers.len() {
for j in (i + 1)..numbers.len() {
if numbers[i] + numbers[j] == target {
return Some((i, j));
}
}
}
None
}
fn main() {
let numbers = vec![2, 7, 11, 15, 3, 6, 8, 12];
let target = 9;
match find_pair_bruteforce(&numbers, target) {
Some((i, j)) => println!("Found pair: ({}, {})", i, j),
None => println!("No pair found"),
}
}
Этот код работает с временной сложностью O(n^2)
, что делает его неэффективным для больших массивов. Ваша задача — проанализировать его производительность, найти узкие места и оптимизировать до уровня O(n)
или лучше, если получится. Мы разберем процесс шаг за шагом, а затем предложим варианты решений.
Прежде чем оптимизировать, нужно понять, где именно теряется время. Используем инструменты из раздела 1 (perf
и flamegraph
), чтобы собрать данные.
cargo build --release --features "debug"
Флаг --features "debug"
сохраняет символы для профилирования.
perf
perf record -g ./target/release/your_program
После выполнения используйте:
perf report
Вы увидите, что большая часть времени уходит на вложенные циклы в find_pair_bruteforce
.
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
Откройте flamegraph.svg
в браузере — вы заметите, что узкое место — это двойной цикл.
Вывод: Алгоритм тратит время на перебор всех возможных пар, что подтверждает сложность O(n^2)
. Это первый сигнал к оптимизации.
criterion
Чтобы измерить улучшения, добавим бенчмарк с помощью библиотеки criterion
. Создайте файл benches/two_sum.rs
:
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn find_pair_bruteforce(numbers: &[i32], target: i32) -> Option<(usize, usize)> {
for i in 0..numbers.len() {
for j in (i + 1)..numbers.len() {
if numbers[i] + numbers[j] == target {
return Some((i, j));
}
}
}
None
}
fn criterion_benchmark(c: &mut Criterion) {
let numbers = vec![2, 7, 11, 15, 3, 6, 8, 12];
let target = 9;
c.bench_function("bruteforce", |b| {
b.iter(|| find_pair_bruteforce(black_box(&numbers), black_box(target)))
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
Добавьте в Cargo.toml
:
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "two_sum"
harness = false
Запустите:
cargo bench
Результат покажет время выполнения для исходного алгоритма (например, 150 нс на маленьком массиве). Это базовая метрика для сравнения.
Теперь приступим к оптимизации. Мы знаем, что O(n^2)
— это плохо. Давайте применим подход с хэш-таблицей для достижения O(n)
.
use std::collections::HashMap;
fn find_pair_optimized(numbers: &[i32], target: i32) -> Option<(usize, usize)> {
let mut num_map: HashMap<i32, usize> = HashMap::new();
for (i, &num) in numbers.iter().enumerate() {
let complement = target - num;
if let Some(&j) = num_map.get(&complement) {
return Some((j, i));
}
num_map.insert(num, i);
}
None
}
fn main() {
let numbers = vec![2, 7, 11, 15, 3, 6, 8, 12];
let target = 9;
match find_pair_optimized(&numbers, target) {
Some((i, j)) => println!("Found pair: ({}, {})", i, j),
None => println!("No pair found"),
}
}
Как это работает:
HashMap
для хранения чисел и их индексов.num
вычисляем "дополнение" (target - num
).HashMap
, мы нашли пару.Сложность:
O(n)
— один проход по массиву, операции с HashMap
в среднем O(1)
.O(n)
— для хранения хэш-таблицы.В исходном коде мы работали со срезом &[i32]
, что уже хорошо, так как не копирует данные. В оптимизированном варианте мы также используем ссылки (&num
), избегая ненужных копий. Однако есть нюанс: если бы numbers
был Vec<i32>
и мы вызвали .into_iter()
вместо .iter()
, это могло бы привести к перемещению данных. Всегда проверяйте итераторы!
Подводный камень: Если вы случайно передадите &Vec<i32>
вместо среза &[i32]
, Rust не выдаст ошибку, но это может запутать при рефакторинге. Явно указывайте тип в сигнатуре функции.
Добавим оптимизированную версию в бенчмарк:
fn criterion_benchmark(c: &mut Criterion) {
let numbers = vec![2, 7, 11, 15, 3, 6, 8, 12];
let target = 9;
c.bench_function("bruteforce", |b| {
b.iter(|| find_pair_bruteforce(black_box(&numbers), black_box(target)))
});
c.bench_function("optimized", |b| {
b.iter(|| find_pair_optimized(black_box(&numbers), black_box(target)))
});
}
Результаты (примерные):
bruteforce
: 150 нсoptimized
: 50 нсДля больших массивов (например, 10,000 элементов) разница будет колоссальной: O(n^2)
может занять секунды, а O(n)
— миллисекунды.
O(n log n)
:
fn find_pair_sorted(mut numbers: Vec<i32>, target: i32) -> Option<(usize, usize)> {
numbers.sort_unstable();
let mut left = 0;
let mut right = numbers.len() - 1;
while left < right {
let sum = numbers[left] + numbers[right];
if sum == target {
return Some((left, right));
} else if sum < target {
left += 1;
} else {
right -= 1;
}
}
None
}
Плюсы: Не требует доп. памяти.
Минусы: Меняет порядок элементов, теряет исходные индексы.
rayon
для параллельного поиска:
use rayon::prelude::*;
fn find_pair_parallel(numbers: &[i32], target: i32) -> Option<(usize, usize)> {
numbers.par_iter().enumerate().find_map_any(|(i, &num)| {
numbers[i + 1..].iter().enumerate().find_map(|(j, &other)| {
if num + other == target {
Some((i, i + j + 1))
} else {
None
}
})
})
}
Плюсы: Ускорение на многоядерных системах.
Минусы: Оверхед на малых массивах, сложность остается O(n^2)
.
HashMap
хорош для общего случая, но если ключи — числа в ограниченном диапазоне, попробуйте Vec
как массив с прямой адресацией.inline
) небольшие функции автоматически, но для явного контроля используйте #[inline]
:
#[inline]
fn find_pair_optimized(numbers: &[i32], target: i32) -> Option<(usize, usize)> { ... }
bruteforce
доступ к памяти не локален, что замедляет работу из-за кэш-промахов. В optimized
мы жертвуем памятью ради скорости.
#[test]
fn test_find_pair() {
let numbers = vec![2, 7, 11, 15];
assert_eq!(find_pair_optimized(&numbers, 9), Some((0, 1)));
assert_eq!(find_pair_optimized(&numbers, 100), None);
}
target - num
для больших чисел.Попробуйте оптимизировать алгоритм для случая, когда нужно найти все пары, а не первую. Используйте Vec<(usize, usize)>
как результат. Сравните подходы:
Пример начала:
fn find_all_pairs(numbers: &[i32], target: i32) -> Vec<(usize, usize)> {
let mut num_map: HashMap<i32, Vec<usize>> = HashMap::new();
let mut result = Vec::new();
for (i, &num) in numbers.iter().enumerate() {
let complement = target - num;
if let Some(indices) = num_map.get(&complement) {
for &j in indices {
result.push((j, i));
}
}
num_map.entry(num).or_insert(Vec::new()).push(i);
}
result
}
Мы прошли полный цикл оптимизации: от анализа O(n^2)
до реализации O(n)
с хэш-таблицей, рассмотрели альтернативные подходы и обсудили нюансы. Этот процесс применим к любому алгоритму: профилируйте, измеряйте, экспериментируйте и тестируйте. Удачи в ваших оптимизациях!