My JavaScript is faster than your Rust (and it's true)

February 23 1929 Words

NOTE: This blog has nothing to do with Josh Urbane's My JavaScript is Faster than Your Rust except I have stolen his title.

We hate JavaScript

People hate JavaScript so much, as a Java developer, I first had no opinions on JavaScript (Because writing Java makes me feel inferior, I can't even join the conversation before the public static void main is done). After knowing some other languages, I started hating JavaScript like I'm a pro. More than half of the programming jokes I saw/heard are about JavaScript (Another half are about Android Studio).

These are some nice classic features/jokes I took from the internet.

javascript

0.1 + 0.2 === 0.3; // -> false

console.log([3, 2, 10].sort()) // -> [10, 2, 3]

"b" + "a" + +"a" + "a"; // -> 'baNaNa'

Actually, they were taken from https://github.com/denysdovhan/wtfjs, more tricky JavaScript features can be found in the repo too.

People hate JavaScript for many reasons, I do understand most of them, writing JavaScript (without TypeScript) could be such a painful thing in my life. But I couldn't understand the reason people hate JavaScript because they think JavaScript is still too slow, even now in 2024.

JavaScript may not be fast itself, but the JavaScript ecosystem has evolved a lot, this tweet from Node.js has a good explanation for modern JavaScript.

A recent tweet from Node.js

And for certain tasks, My JavaScript is Faster than Your Rust is very true.

It's not slow

Yes, JavaScript can be fast and can be faster than Rust, that's true. I created a benchmark to prove it (No one asked, I did it for myself). It's a benchmark to create, write, read, and delete 1000 text files asynchronously.

Rust

For Rust, Tokio was used.

rust

const FILE_COUNT: usize = 1000;

pub async fn run_file_task() {
    let dir = std::env::temp_dir().join("rust-async-bench-temp");
    tokio::fs::create_dir(dir.clone()).await.unwrap();
    let mut files = Vec::with_capacity(FILE_COUNT);
    for i in 0..FILE_COUNT{
        let file = dir.join(format!("{}.txt", i));
        files.push(file);
    }
    // Write files
    let tasks: Vec<_> = files
        .iter()
        .map(|file| {
            let file = file.clone();
            tokio::task::spawn_blocking(|| std::fs::write(file, "Hello, world!"))
        })
        .collect();
    for task in tasks {
        let _ = task.await.unwrap();
    }
    // Read files
    let tasks: Vec<_> = files
        .iter()
        .map(|file| {
            let file = file.clone();
            tokio::task::spawn_blocking(|| {
                let bytes = std::fs::read(file).unwrap();
                let _ = String::from_utf8(bytes);
            })
        })
        .collect();
    for task in tasks {
        let _ = task.await;
    }
    // Delete files
    tokio::fs::remove_dir_all(dir).await.unwrap();
}

After lots and lots of compilations, I finally made it running. I know this is a skill issue, but don't blame me (too much), even if you can write and run it at once, you still need minutes to compile it on my machine. Anyway, I then ran hyperfine to benchmark it.

bash

# cargo build --release
# hyperfine --warmup=5 "./target/release/rust-async-bench --file"
Benchmark 1: ./target/release/rust-async-bench --file
  Time (mean ± σ):     134.3 ms ±   6.2 ms    [User: 23.9 ms, System: 131.6 ms]
  Range (min … max):   118.9 ms … 146.6 ms    21 runs

134ms for all these operations? That is fast enough for me, especially since the code ran in WSL on an old i5 Windows machine.

How was JavaScript?

I then wrote this equivalent JavaScript code, in minutes, with no compile errors. Super confident at that moment, thank you JavaScript, I felt that I had become a programmer again!

javascript

const FILE_COUNT = 1000;

export async function runFileTask() {
  const dir = path.join(os.tmpdir(), "js-async-bench-temp");
  await fs.mkdir(dir);

  const files = new Array(FILE_COUNT);
  for (let i = 0; i < FILE_COUNT; i++) {
    files[i] = path.join(dir, `${i}.txt`);
  }

  // Write files
  await Promise.all(
    files.map(async (file) => {
      await fs.writeFile(file, "Hello World!");
    })
  );

  // Read files
  await Promise.all(
    files.map(async (file) => {
      (await fs.readFile(file)).toString();
    })
  );

  // Delete files
  await fs.rm(dir, { recursive: true });
}

Let's check if it's faster than Rust.

bash

# hyperfine --warmup=5 "node index.mjs --file"
Benchmark 1: node index.mjs --file
  Time (mean ± σ):     462.9 ms ±  44.5 ms    [User: 201.1 ms, System: 504.1 ms]
  Range (min … max):   400.5 ms … 547.2 ms    10 runs

No luck, 462ms, it was not slow but still a lot slower than Rust. But don't worry, I still have my new best friend Bun, I ran the same script using Bun, and the result almost killed the competition.

bash

# hyperfine --warmup=5 "bun index.mjs --file"
Benchmark 1: bun index.mjs --file
  Time (mean ± σ):     103.6 ms ±   4.4 ms    [User: 46.7 ms, System: 116.5 ms]
  Range (min … max):    97.0 ms … 112.1 ms    29 runs

It only took about 100ms, which is about 30ms faster than Rust! My JavaScript code can be really fast, and it doesn't need to be compiled, that's fastx100, maybe.

Am I doing right?

Some may say,

You are comparing Rust with Bun/Zig, not JavaScript itself.

I would say, that no matter what runtime it runs on, or what API it calls internally, it's still JavaScript. I mean,

JavaScript is nothing without its underlying API.

No? It's still a thing that can evaluate 0.1 + 0.2 === 0.3 and it's false? Okay, that makes JavaScript useful sometimes, and what I really mean is it's JavaScript, no one would call it BunScript or NodeScript or IEScript or something.

Rustaceans are still angry?

What? Rust guys didn't accept my explanation? I don't want to offend the Rustaceans. So I did more checking for the Rust code.

I first doubted whether Rust's std fs API was too slow, then removed all async calls.

rust

const FILE_COUNT: usize = 1000;

pub fn run_file_task_sync() {
    let dir = std::env::temp_dir().join("rust-async-bench-temp");
    std::fs::create_dir(dir.clone()).unwrap();
    for i in 0..FILE_COUNT {
        let file = dir.join(format!("{}.txt", i));
        std::fs::write(file.clone(), "Hello, world!").unwrap();
        let bytes = std::fs::read(file.clone()).unwrap();
        let _ = String::from_utf8(bytes).unwrap();
    }
    std::fs::remove_dir_all(dir).unwrap();
}

The result showed it was much faster than the async version and faster than the JavaScript (Bun) version! Nice, Rust is faster than JavaScript (without async) now, I feel safer.

bash

Benchmark 1: ./target/release/rust-async-bench --file
  Time (mean ± σ):      93.9 ms ±   5.9 ms    [User: 7.3 ms, System: 64.7 ms]
  Range (min … max):    83.0 ms … 112.9 ms    32 runs

But why? Shouldn't async IO be faster? It should be unless it's single-threaded. Scheduling these async tasks in a single-threaded runtime will surely be slower than running the loop synchronously. I added a logging line to check if Tokio worked as expected.

rust

tokio::task::spawn_blocking(|| {
    println!("Current thread: {:?}", std::thread::current().id());
    std::fs::write(file, "Hello, world!");
})

Em, no problem, it was multi-threaded.

Current thread: ThreadId(6)
Current thread: ThreadId(6)
Current thread: ThreadId(10)
...
Current thread: ThreadId(61)
Current thread: ThreadId(65)
Current thread: ThreadId(84)

I made more attempts, including changing Tokio's thread pool size, replacing std::fs with async tokio::fs (even if tokio::fs just wraps std::fs with spawn_blocking(), the same as my first version), but none of these made the benchmark faster.

Then I finally found the problem might be Tokio, it couldn't make my multi-threaded async file operations as fast as synchronous operations. What about other async runtimes? Will they perform better?

Then I tried futures-rs, it looks much simpler than Tokio, maybe it could do better.

rust

pub async fn run_file_task2(pool: Arc<futures::executor::ThreadPool>) {
    let dir = std::env::temp_dir().join("rust-async-bench-temp");
    std::fs::create_dir(dir.clone()).unwrap();
    let mut files = Vec::with_capacity(COUNT);
    for i in 0..COUNT {
        let file = dir.join(format!("{}.txt", i));
        files.push(file);
    }

    let mut tasks: FuturesUnordered<_> = files
        .iter()
        .map(|file| {
            let file = file.clone();
            pool.spawn_with_handle(async move {
                std::fs::write(file, "Hello, world!").unwrap();
            })
            .unwrap()
        })
        .collect();
    while let Some(_) = tasks.next().await {}

    let mut tasks: FuturesUnordered<_> = files
        .iter()
        .map(|file| {
            let file = file.clone();
            pool.spawn_with_handle(async move {
                let bytes = std::fs::read(file).unwrap();
                let _ = String::from_utf8(bytes).unwrap();
            })
            .unwrap()
        })
        .collect();
    while let Some(_) = tasks.next().await {}

    std::fs::remove_dir_all(dir).unwrap();
}

I also updated my main function.

rust

fn main() {
    let pool = Arc::new(futures::executor::ThreadPool::new().unwrap());
    let pool_clone = pool.clone();
    let handle = pool
        .spawn_with_handle(async move {
            run_file_task2(pool_clone).await;
        })
        .unwrap();

    block_on(async move {
        handle.await;
    });
}

The result was amazing, I finally made an async version faster than JavaScript (Bun)!

bash

Benchmark 1: ./target/release/rust-async-bench --file
  Time (mean ± σ):      81.5 ms ±   7.3 ms    [User: 13.5 ms, System: 137.1 ms]
  Range (min … max):    72.4 ms … 108.7 ms    37 runs

That was about 20ms faster than Bun, and about 10ms faster than the synchronous version! I'm sure that Rust can still be faster but I will stop here today because I have spent a day to make it faster than a script that I wrote in minutes, and I likely will never use this code outside of this benchmark. More importantly, I am actually safe! Everyone may be happy now!

By the way, there was also a C version of the benchmark, it's synchronous but beat everything above. I won't post the code because it's too long and no one here seems to enjoy reading C.

bash

# hyperfine --warmup=5 "./a.out"
Benchmark 1: ./a.out
  Time (mean ± σ):      58.8 ms ±   2.7 ms    [User: 7.3 ms, System: 46.3 ms]
  Range (min … max):    54.0 ms …  66.4 ms    50 runs

Conclusion

I would never use Tokio again! No, that's kidding, because I won't use futures-rs or even Rust either, for these kinds of tasks.

JavaScript (on Bun) can be really fast and it can be faster than Rust, I hate JavaScript, but I can't hate its speed, and

Life is short, I need JavaScript.

That's all for the blog, thank you for reading this, whoever writing JavaScript, Rust, or Java.

©2024 letmutex.com