performancerustfile-iobenchmarkingmicrobenchmark

Why is my Rust program slower than the equivalent program in another language?


I was playing around with binary serialization and deserialization in Rust and noticed that binary deserialization is several orders of magnitude slower than with Java. To eliminate the possibility of overhead due to, for example, allocations and overheads, I'm simply reading a binary stream from each program. Each program reads from a binary file on disk which contains a 4-byte integer containing the number of input values, and a contiguous chunk of 8-byte big-endian IEEE 754-encoded floating point numbers. Here's the Java implementation:

import java.io.*;

public class ReadBinary {
    public static void main(String[] args) throws Exception {
        DataInputStream input = new DataInputStream(new BufferedInputStream(new FileInputStream(args[0])));
        int inputLength = input.readInt();
        System.out.println("input length: " + inputLength);
        try {
            for (int i = 0; i < inputLength; i++) {
                double d = input.readDouble();
                if (i == inputLength - 1) {
                    System.out.println(d);
                }
            }
        } finally {
            input.close()
        }
    }
}

Here's the Rust implementation:

use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;

fn main() {
    let args = std::env::args_os();
    let fname = args.skip(1).next().unwrap();
    let path = Path::new(&fname);
    let mut file = BufReader::new(File::open(&path).unwrap());
    let input_length: i32 = read_int(&mut file);
    for i in 0..input_length {
        let d = read_double_slow(&mut file);
        if i == input_length - 1 {
            println!("{}", d);
        }
    }
}

fn read_int<R: Read>(input: &mut R) -> i32 {
    let mut bytes = [0; std::mem::size_of::<i32>()];
    input.read_exact(&mut bytes).unwrap();
    i32::from_be_bytes(bytes)
}

fn read_double_slow<R: Read>(input: &mut R) -> f64 {
    let mut bytes = [0; std::mem::size_of::<f64>()];
    input.read_exact(&mut bytes).unwrap();
    f64::from_be_bytes(bytes)
}

I'm outputting the last value to make sure that all of the input is actually being read. On my machine, when the file contains (the same) 30 million randomly-generated doubles, the Java version runs in 0.8 seconds, while the Rust version runs in 40.8 seconds.

Suspicious of inefficiencies in Rust's byte interpretation itself, I retried it with a custom floating point deserialization implementation. The internals are almost exactly the same as what's being done in Rust's Reader, without the IoResult wrappers:

fn read_double<R : Reader>(input: &mut R, buffer: &mut [u8]) -> f64 {
    use std::mem::transmute;
    match input.read_at_least(8, buffer) {
        Ok(n) => if n > 8 { fail!("n > 8") },
        Err(e) => fail!(e)
    };
    let mut val = 0u64;
    let mut i = 8;
    while i > 0 {
        i -= 1;
        val += buffer[7-i] as u64 << i * 8;
    }
    unsafe {
        transmute::<u64, f64>(val);
    }
}

The only change I made to the earlier Rust code in order to make this work was create an 8-byte slice to be passed in and (re)used as a buffer in the read_double function. This yielded a significant performance gain, running in about 5.6 seconds on average. Unfortunately, this is still noticeably slower (and more verbose!) than the Java version, making it difficult to scale up to larger input sets. Is there something that can be done to make this run faster in Rust? More importantly, is it possible to make these changes in such a way that they can be merged into the default Reader implementation itself to make binary I/O less painful?

For reference, here's the code I'm using to generate the input file:

import java.io.*;
import java.util.Random;

public class MakeBinary {
    public static void main(String[] args) throws Exception {
        DataOutputStream output = new DataOutputStream(new BufferedOutputStream(System.out));
        int outputLength = Integer.parseInt(args[0]);
        output.writeInt(outputLength);
        Random rand = new Random();
        for (int i = 0; i < outputLength; i++) {
            output.writeDouble(rand.nextDouble() * 10 + 1);
        }
        output.flush();
    }
}

(Note that generating the random numbers and writing them to disk only takes 3.8 seconds on my test machine.)


Solution

  • Have you tried running Cargo with --release?

    When you build without optimisations, it will often be slower than it would be in Java. But build it with optimisations (rustc -O or cargo --release) and it should be very much faster. If the standard version of it still ends up slower, it’s something that should be examined carefully to figure out where the slowness is—perhaps something is being inlined that shouldn’t be, or not that should be, or perhaps some optimisation that was expected is not occurring.