Rust и Benchmarking
от Oak
Оригинальный код бенчмарка на Java выглядел так:
Казалось бы — что сложного просто перенести этот код на другой язык, тем более, что близкий по парадигме. Полный энтузиазма и решительности, я поставил Rust, Cargo и SolidOak (рекламирующую себя как Rust IDE, но на деле представляющую из себя просто NeoVim в окошке) и понял как сильно я ошибался.
Первые шаги
Сразу же я столкнулся с жестокой реальностью 2015-го — в стандартной библиотеке Rust нет методов работы со временем и даже аналога System.currentTimeMillis() нет.
Для этого нужно подключить внешний crate. Не то, чтобы это мне сильно помешало, но сама ситуация вышла, можно сказать, довольно забавной.
Размер стека
Подождите, не пролистывайте этот раздел, это важно.
Вся локальная информация в программах создается на стеке (и может быть перемещена в специально выделенную память). Многие из нас сталкивались со StackOverflowException, который появляется, если рекурсивная функция очень много раз себя вызвала.
Это не случайность. Стек для программ не может быть бесконечно большим — где-то нужно провести линию. Для Rust-программ стек главного потока равен 2-м мегабайтам и не может быть изменен (без редактирования исходников компилятора).
С переполнением же стека разные языки справляются по разному. Есть три подхода:
— Не париться и смириться с тем, что стек может переполняться.
— Проводить статический анализ при компиляции и удостоверяться, что в стеке всегда будет место.
— Во время выполнения программы выполнять проверки и быть уверенным, что стек никогда не переполнится.
Первый подход нам всем знаком по широко известному языку C и не менее популярной ошибке SEGFAULT. В эту группу языков также входят C++, Objective C и похожие. В группе разработки Rust решили, что такой подход не для них.
Второй подход очень бы хотелось применить, но, увы, таких технологий еще нет.
Остается лишь третий подход, который довольно просто реализовать и который, при этом, защищает программиста от нежелательных ошибок. Практически все современные языки используют именно его (Java, Python, Lua, Ruby, Go как примеры таких языков). Именно его использует и Rust.
К чему был весь предыдущий раздел?
Дело в том, что в Rust выделить статический фиксированный массив можно только на стеке, поэтому такой код
успешно компилируется, но падает при исполнении с сообщением о переполнении стека.
Пытливый читатель может заметить: «А как же Box::new?».
Да, действительно, эта операция позволяет выделить память в куче, но дело в том, что на текущий момент в Rust выполнение кода Box::new([0; SIZE]); тоже приведет к ошибке времени исполнения, ведь [0; SIZE] нужно сначала выделить на стеке, а уже потом загрузить в кучу. Возможно, в будущих версиях языка появится синтаксис для создания статических массивов в куче, минуя стек, а пока мы будем использовать динамические массивы:
В предыдущем листинге я добавил еще, собственно говоря, и сами циклы.
В принципе, наш код готов, попробуем запустить его:
Упс!
Да, вы верно все поняли, компилятор заботливо оптимизировал наш код и убрал из него цикл, потому что его результаты нигде не используются. Стоит добавить вывод какого-либо элемента массива, чтобы предотвратить такое поведение:
А я хочу статический массивНе проблема. У нас же низкоуровненвый язык?
Я упоминал ранее, что у главного потока в Rust размер стека фиксированный (2 мегабайта), однако в любой момент можно запустить новый поток со своим размером стека:
Единственное, что мне непонятно — почему массив выравнивается по usize (64 бита), а не по i32 (32 бита), если кто знает, расскажите причину.
Ну вот и все! Спрашивайте свои вопросы, пишите свои замечания в комментарии и помните, что я не являюсь экспертом языка Rust.
По результатам бенчмарка, вычисление такого массива в среднем происходит на такой же скорости, что и на Java (на моей машине около 598 мс).
Стоит упомянуть, что для решения с кучей время выполнения в среднем на 10-20 миллисекунд меньше.
Весь код опубликован на https://github.com/White-Oak/rust-simple-bench
- public class Main {
- public static void main(String[] args) {
- int w = 25600;
- int h = 2048;
- int a[] = new int[w*h];
- long stt = System.currentTimeMillis();
- for (int i=0; i<w; i++)
- for (int j=0; j<h; j++)
- a[i+w*j] = i*j;
- System.out.println(System.currentTimeMillis()-stt);
- }
- }
Открыть спойлер
Первые шаги
Сразу же я столкнулся с жестокой реальностью 2015-го — в стандартной библиотеке Rust нет методов работы со временем и даже аналога System.currentTimeMillis() нет.
Для этого нужно подключить внешний crate. Не то, чтобы это мне сильно помешало, но сама ситуация вышла, можно сказать, довольно забавной.
- extern crate time;
- use time::precise_time_ns;
- fn main(){
- //...
- let start = precise_time_ns();
- //код, время исполнения которого нужно измерить
- let total = precise_time_ns() - start;
- println!("{}", total / 1000 / 1000);
- }
Размер стека
Подождите, не пролистывайте этот раздел, это важно.
Вся локальная информация в программах создается на стеке (и может быть перемещена в специально выделенную память). Многие из нас сталкивались со StackOverflowException, который появляется, если рекурсивная функция очень много раз себя вызвала.
Это не случайность. Стек для программ не может быть бесконечно большим — где-то нужно провести линию. Для Rust-программ стек главного потока равен 2-м мегабайтам и не может быть изменен (без редактирования исходников компилятора).
С переполнением же стека разные языки справляются по разному. Есть три подхода:
— Не париться и смириться с тем, что стек может переполняться.
— Проводить статический анализ при компиляции и удостоверяться, что в стеке всегда будет место.
— Во время выполнения программы выполнять проверки и быть уверенным, что стек никогда не переполнится.
Первый подход нам всем знаком по широко известному языку C и не менее популярной ошибке SEGFAULT. В эту группу языков также входят C++, Objective C и похожие. В группе разработки Rust решили, что такой подход не для них.
Второй подход очень бы хотелось применить, но, увы, таких технологий еще нет.
Остается лишь третий подход, который довольно просто реализовать и который, при этом, защищает программиста от нежелательных ошибок. Практически все современные языки используют именно его (Java, Python, Lua, Ruby, Go как примеры таких языков). Именно его использует и Rust.
Открыть спойлер
Выделение памятиК чему был весь предыдущий раздел?
Дело в том, что в Rust выделить статический фиксированный массив можно только на стеке, поэтому такой код
- fn main(){
- const W: i32= 25600;
- const H: i32 = 2048;
- const SIZE: usize = (W * H) as usize;
- //создает массив размером SIZE и заполняет его нулями
- let mut v: [i32; SIZE] = [0; SIZE];
- }
Пытливый читатель может заметить: «А как же Box::new?».
Да, действительно, эта операция позволяет выделить память в куче, но дело в том, что на текущий момент в Rust выполнение кода Box::new([0; SIZE]); тоже приведет к ошибке времени исполнения, ведь [0; SIZE] нужно сначала выделить на стеке, а уже потом загрузить в кучу. Возможно, в будущих версиях языка появится синтаксис для создания статических массивов в куче, минуя стек, а пока мы будем использовать динамические массивы:
- extern crate time;
- use time::precise_time_ns;
- use std::iter::repeat;
- fn main() {
- const W: i32 = 25600;
- const H: i32 = 2048;
- const SIZE: usize = (W * H) as usize;
- let mut v: Vec<i32> = repeat(0).take(SIZE).collect();
- let start = precise_time_ns();
- for i in 0..W {
- for j in 0..H{
- //usize имеет размер указателя, а i32 — нет
- v[(i + W * j) as usize] = i * j;
- }
- }
- let total = precise_time_ns() - start;
- println!("{}", total / 1000 / 1000);
- }
В принципе, наш код готов, попробуем запустить его:
- cargo run
- 2610
- cargo run --release
- 48
Да, вы верно все поняли, компилятор заботливо оптимизировал наш код и убрал из него цикл, потому что его результаты нигде не используются. Стоит добавить вывод какого-либо элемента массива, чтобы предотвратить такое поведение:
- use time::precise_time_ns;
- use std::io::BufWriter;
- use std::io::prelude::*;
- use std::fs::OpenOptions;
- use std::path::Path;
- use std::iter::repeat;
- fn main() {
- //Уже написанный код
- write_to_null(v[(W+1) as usize]);
- }
- fn write_to_null(i :i32) {
- let path = Path::new("/dev/null");
- let mut options = OpenOptions::new();
- options.write(true).append(true);
- let file = match options.open(path) {
- Ok(file) => file,
- Err(..) => panic!("room"),
- };
- let mut buffer = BufWriter::new(&file);
- write!(buffer, "{}", i);
- }
Я упоминал ранее, что у главного потока в Rust размер стека фиксированный (2 мегабайта), однако в любой момент можно запустить новый поток со своим размером стека:
- let child = thread::Builder::new()
- .stack_size(SIZE * size_of::<usize>())
- .spawn(move || {
- let mut v: [i32; SIZE] = [0; SIZE];
- //...
- }).unwrap();
- child.join();
Открыть спойлер
КонецНу вот и все! Спрашивайте свои вопросы, пишите свои замечания в комментарии и помните, что я не являюсь экспертом языка Rust.
По результатам бенчмарка, вычисление такого массива в среднем происходит на такой же скорости, что и на Java (на моей машине около 598 мс).
Стоит упомянуть, что для решения с кучей время выполнения в среднем на 10-20 миллисекунд меньше.
Весь код опубликован на https://github.com/White-Oak/rust-simple-bench