Rust и Benchmarking

от
Прочие языки   rust

Оригинальный код бенчмарка на Java выглядел так:
  1. public class Main {
  2.     public static void main(String[] args) {
  3.         int w = 25600;
  4.         int h = 2048;
  5.         int a[] = new int[w*h];
  6.         long stt = System.currentTimeMillis();
  7.         for (int i=0; i<w; i++)
  8.             for (int j=0; j<h; j++)
  9.                 a[i+w*j] = i*j;
  10.         System.out.println(System.currentTimeMillis()-stt);
  11.     }
  12. }
Казалось бы — что сложного просто перенести этот код на другой язык, тем более, что близкий по парадигме. Полный энтузиазма и решительности, я поставил Rust, Cargo и SolidOak (рекламирующую себя как Rust IDE, но на деле представляющую из себя просто NeoVim в окошке) и понял как сильно я ошибался.

Открыть спойлер

Первые шаги
Сразу же я столкнулся с жестокой реальностью 2015-го — в стандартной библиотеке Rust нет методов работы со временем и даже аналога System.currentTimeMillis() нет.
Для этого нужно подключить внешний crate. Не то, чтобы это мне сильно помешало, но сама ситуация вышла, можно сказать, довольно забавной.
  1. extern crate time;
  2.  
  3. use time::precise_time_ns;
  4. fn main(){
  5.     //...
  6.     let start = precise_time_ns();
  7.     //код, время исполнения которого нужно измерить
  8.     let total = precise_time_ns() - start;
  9.     println!("{}", total / 1000 / 1000);
  10. }

Размер стека
Подождите, не пролистывайте этот раздел, это важно.

Вся локальная информация в программах создается на стеке (и может быть перемещена в специально выделенную память). Многие из нас сталкивались со StackOverflowException, который появляется, если рекурсивная функция очень много раз себя вызвала.
Это не случайность. Стек для программ не может быть бесконечно большим — где-то нужно провести линию. Для Rust-программ стек главного потока равен 2-м мегабайтам и не может быть изменен (без редактирования исходников компилятора).

С переполнением же стека разные языки справляются по разному. Есть три подхода:
— Не париться и смириться с тем, что стек может переполняться.
— Проводить статический анализ при компиляции и удостоверяться, что в стеке всегда будет место.
— Во время выполнения программы выполнять проверки и быть уверенным, что стек никогда не переполнится.

Первый подход нам всем знаком по широко известному языку C и не менее популярной ошибке SEGFAULT. В эту группу языков также входят C++, Objective C и похожие. В группе разработки Rust решили, что такой подход не для них.

Второй подход очень бы хотелось применить, но, увы, таких технологий еще нет.

Остается лишь третий подход, который довольно просто реализовать и который, при этом, защищает программиста от нежелательных ошибок. Практически все современные языки используют именно его (Java, Python, Lua, Ruby, Go как примеры таких языков). Именно его использует и Rust.
Открыть спойлер
Выделение памяти
К чему был весь предыдущий раздел?

Дело в том, что в Rust выделить статический фиксированный массив можно только на стеке, поэтому такой код

  1. fn main(){
  2.     const W: i32= 25600;
  3.     const H: i32 = 2048;
  4.     const SIZE: usize = (W * H) as usize;
  5.     //создает массив размером SIZE и заполняет его нулями
  6.     let mut v: [i32; SIZE] = [0; SIZE];
  7. }
успешно компилируется, но падает при исполнении с сообщением о переполнении стека.

Пытливый читатель может заметить: «А как же Box::new?».
Да, действительно, эта операция позволяет выделить память в куче, но дело в том, что на текущий момент в Rust выполнение кода Box::new([0; SIZE]); тоже приведет к ошибке времени исполнения, ведь [0; SIZE] нужно сначала выделить на стеке, а уже потом загрузить в кучу. Возможно, в будущих версиях языка появится синтаксис для создания статических массивов в куче, минуя стек, а пока мы будем использовать динамические массивы:
  1. extern crate time;
  2.  
  3. use time::precise_time_ns;
  4. use std::iter::repeat;
  5.  
  6. fn main() {
  7.     const W: i32 = 25600;
  8.     const H: i32 = 2048;
  9.     const SIZE: usize = (W * H) as usize;
  10.     let mut v: Vec<i32> = repeat(0).take(SIZE).collect();
  11.     let start = precise_time_ns();
  12.     for i in 0..W {
  13.         for j in 0..H{
  14.             //usize имеет размер указателя, а i32 — нет
  15.             v[(i + W * j) as usize] = i * j;
  16.         }
  17.     }
  18.     let total = precise_time_ns() - start;
  19.     println!("{}", total / 1000 / 1000);
  20. }
В предыдущем листинге я добавил еще, собственно говоря, и сами циклы.
В принципе, наш код готов, попробуем запустить его:
  1. cargo run
  2. 2610
  3. cargo run --release
  4. 48
Упс!
Да, вы верно все поняли, компилятор заботливо оптимизировал наш код и убрал из него цикл, потому что его результаты нигде не используются. Стоит добавить вывод какого-либо элемента массива, чтобы предотвратить такое поведение:

  1. use time::precise_time_ns;
  2. use std::io::BufWriter;
  3. use std::io::prelude::*;
  4. use std::fs::OpenOptions;
  5. use std::path::Path;
  6. use std::iter::repeat;
  7.  
  8. fn main() {
  9.     //Уже написанный код
  10.     write_to_null(v[(W+1) as usize]);
  11. }
  12.  
  13. fn write_to_null(i :i32) {
  14.     let path = Path::new("/dev/null");
  15.     let mut options = OpenOptions::new();
  16.     options.write(true).append(true);
  17.  
  18.     let file = match options.open(path) {
  19.         Ok(file) => file,
  20.         Err(..)  => panic!("room"),
  21.     };
  22.     let mut buffer = BufWriter::new(&file);
  23.     write!(buffer, "{}", i);
  24. }
А я хочу статический массивНе проблема. У нас же низкоуровненвый язык?

Я упоминал ранее, что у главного потока в Rust размер стека фиксированный (2 мегабайта), однако в любой момент можно запустить новый поток со своим размером стека:
  1. let child = thread::Builder::new()
  2.     .stack_size(SIZE * size_of::<usize>())
  3.     .spawn(move || {
  4.         let mut v: [i32; SIZE] = [0; SIZE];
  5.         //...
  6.     }).unwrap();
  7. child.join();
Единственное, что мне непонятно — почему массив выравнивается по usize (64 бита), а не по i32 (32 бита), если кто знает, расскажите причину.

Открыть спойлер
Конец
Ну вот и все! Спрашивайте свои вопросы, пишите свои замечания в комментарии и помните, что я не являюсь экспертом языка Rust.

По результатам бенчмарка, вычисление такого массива в среднем происходит на такой же скорости, что и на Java (на моей машине около 598 мс).

Стоит упомянуть, что для решения с кучей время выполнения в среднем на 10-20 миллисекунд меньше.

Весь код опубликован на https://github.com/White-Oak/rust-simple-bench
+7   7   0
1251