-5

I have a function named

grid

which is from dynamic programming problem "grid Traveler" I wrote the same function twice in JS and Rust and bench marked 10 million calculations whilst memoizing both functions

JS:

const grid = (m, n, memo) => {
    const key = m + ',' + n;
    if (key in memo) return memo[key]
    const max = Math.max(m, n)
    const min = Math.min(m, n)
    const d = Array.from({ length: max }, () => 1)
    for (let i = 1; i < min; i++) {
        for (let j = i; j < max; j++) {
            const index = j
            if (i === j) {
                d[index] *= 2
            } else {
                d[index] = d[index] + d[index - 1]
            }
        }
    }
    memo[key] = d[max - 1]
    return d[max - 1]
}


let start = new Date().getTime()
const memo = {}
for (let i = 0; i < 10_000_000; i++) {
    // grid(18, 18)
    grid(18, 18, memo)
}
console.log(new Date().getTime() - start)

Rust:

use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::time::SystemTime;

fn grid(m: &usize, n: &usize, memo: &mut HashMap<String, u64>) -> u64 {
    let key = m.to_string() + "," + &n.to_string();
    match memo.entry(key) {
        Entry::Occupied(x) => *x.get(),
        Entry::Vacant(v) => {
            let max: &usize;
            let min: &usize;
            if m > n {
                max = &m;
                min = &n;
            } else {
                max = &n;
                min = &m;
            }
            let mut d = Vec::<u64>::with_capacity(*max);
            for _ in 0..*max {
                d.push(1);
            }
            for i in 1..*min {
                for j in i..*max {
                    if i == j {
                        d[j] *= 2;
                    } else {
                        d[j] = d[j] + d[j - 1];
                    }
                }
            }
            v.insert(d[*max - 1]);
            return d[*max - 1];
        }
    }
}

fn main() {
    let start = SystemTime::now();
    let mut memo = HashMap::<String, u64>::new();
    let m = 18;
    let n = 18;
    for _ in 0..10_000_000 {
        grid(&m, &n, &mut memo);
        // grid(&m, &n);
    }

    println!("{}", start.elapsed().unwrap().as_millis());
}

benchmark results with commands :

node index.js = 9

cargo run --release = 1614

I thought maybe using a hashmap is not such a great idea so I tried the #[memoize] macro from this crate

results were yet disappointing :

node index.js = 9

cargo run --release = 254

my question is why is this happening and whats the optimal solution to generally memoize this function in Rust

also benchmark results without memoization :

node index.js = 15424

cargo run --release = 2400

EDIT 1 (Changes from Chayim Friedman) + (Switching to 100 million calls):

RUST:

use std::collections::hash_map::Entry;
use std::time::Instant;
use rustc_hash::FxHashMap;

fn grid(m: usize, n: usize, memo: &mut FxHashMap<(usize, usize), u64>) -> u64 {
    let key: (usize, usize) = (m, n);
    match memo.entry(key) {
        Entry::Occupied(x) => *x.get(),
        Entry::Vacant(v) => {
            let max: &usize;
            let min: &usize;
            if m > n {
                max = &m;
                min = &n;
            } else {
                max = &n;
                min = &m;
            }
            let mut d = Vec::<u64>::with_capacity(*max);
            for _ in 0..*max {
                d.push(1);
            }
            for i in 1..*min {
                for j in i..*max {
                    if i == j {
                        d[j] *= 2;
                    } else {
                        d[j] = d[j] + d[j - 1];
                    }
                }
            }
            v.insert(d[*max - 1]);
            return d[*max - 1];
        }
    }
}

fn main() {
    let start = Instant::now();
    let mut memo = FxHashMap::<(usize, usize), u64>::default();
    for _ in 0..100_000_000 {
        grid(18, 18, &mut memo);
    }
    println!("{}", start.elapsed().as_millis());
}

is still around 4 times slower than nodejs

node index.js = 54

cargo run --release = 236

EDIT 2: Problem solved details are in comments

  • 1
    First, take `usize` and not `&usize`. Also, don't use `to_string()` for the key, but rather store a tuple `(m, n)`. Lastly, the default hasher in Rust is very slow: replace it with [`FxHashMap`](https://docs.rs/rustc-hash/latest/rustc_hash/type.FxHashMap.html). – Chayim Friedman May 30 '23 at 06:24
  • And don't use `SystemTime` for benchmarking, use `Instant`. – Chayim Friedman May 30 '23 at 06:24
  • I guess the `FxHashMap` change alone will give you the performance you want, because this is the main change from JS. – Chayim Friedman May 30 '23 at 06:29
  • Side note: The memoize-crate supports using custom hashers: `#[memoize(CustomHasher: rustc_hash::FxHashMap, HasherInit: rustc_hash::FxHashMap::default())]`. – Caesar May 30 '23 at 07:06
  • @ChayimFriedman adding the changes you mentioned and doing 100 million function calls I still got 236 milliseconds on rust and 54 milliseconds on nodejs which is still disappointing – X-_-FARZA_ D-_-X Jun 01 '23 at 09:03
  • Instead of `Vec::with_capacity()` then lots of `push()`es, use `vec![1; *max]`. Also, use `usize` and not `&usize` for `min` and `max` (`&usize` is pretty much useless). – Chayim Friedman Jun 01 '23 at 12:59
  • I think I got it: you're using constant `18` for `m` and `n`. This means `memo` will only have one property. JS engine optimize such cases to not use hashmap but rather structure with fixed offsets. So while the Rust code is performing full hashmap lookup each time (which is fast but...) the JS code only does some quick checks then constant offset access, which is basically free. Now I guess this is not your real use-case, so benchmark with varying numbers. – Chayim Friedman Jun 01 '23 at 13:03
  • In other words, the equivalent code in Rust is not yours but code that assigns (and reads from) a struct with an `Option` field, which will be a lot faster than your JS code, probably so fast that you cannot even measure it. – Chayim Friedman Jun 01 '23 at 13:05
  • If you don't want to (or can't) use varying numbers, change the JS code to use `Map` instead of an object, this should make it equivalent to the Rust code. – Chayim Friedman Jun 01 '23 at 13:06
  • After changing the JS code to use `Map`, it executes in 20 seconds on my computer, so _somewhat_ slower than the Rust code. – Chayim Friedman Jun 01 '23 at 13:10
  • one other side note I would like to mention is creating a vector with max capacity and pushing items one by one is faster that vec![1; max] around 100 milliseconds faster on 100 million runs – X-_-FARZA_ D-_-X Jun 02 '23 at 11:14

1 Answers1

0

Since you only ever invoke grid() with one key (18, 18), the inline cache optimization kicks in, and V8 transforms the map lookup in memo (which dominates the time) into a field offset access in object, which is basically free, while the Rust code still has to perform full map lookup.

If you use Map in JS instead of an object, the JS code executes in about 20 seconds on my computer, so Rust is a lot faster. Alternatively, use variable m and n, so that the runtime is dominated by the calculation and not by the lookup.

Chayim Friedman
  • 47,971
  • 5
  • 48
  • 77