Side project: Fast definite integral calculator
Recently, I built a definite integral calculator to supplement a project for a calculus class that I'm taking. In the project, I researched Romberg integration, which is a way to approximate definite integrals with just a few calculations.1 Along with a paper documenting my research, I also made a website that implements the algorithm defined in my paper.
The website was fun to make! In order to get the best performance possible, I wrote the core algorithm in Rust and compiled it to WebAssembly. The calculator can quickly determine when the integral of a function over an interval does not converge,2 and is able to get extremely accurate approximations in just a few milliseconds.
By comparison to other online calculators, the one that I wrote is faster, just as accurate, and importantly, reports when the integral does not converge. In this blog post, I'll go over some things I learned in the process of making the website!
Stuff I Learned
Here are the main things I learned while making the website:
- Wasm-Rust bindings are great, but the amount of tooling is cumbersome
- Rust idioms don't always translate well to JavaScript
I'll go over these points in detail in the following subsections.
Wasm-Rust Bindings
The wasm-bindgen project makes
communicating between Rust and WebAssembly a breeze. It also makes using Web
APIs a super easy! Even if the functions that I want to export to WebAssembly
are complex, wasm-bindgen
pretty much always has an intuitive solution
waiting for me.
Say I want to export this function in my compiled Wasm module:
fn fun_format(s: &str) -> String { format!("{s} is so cool!") }
If you've done any FFI work, you most likely just had flashbacks to late-night
FFI rabbit holes, complicated build steps, and the most "are you kidding me"
bugs possible. Sidestepping all of that, wasm-bindgen
allows me to do this:
#[wasm_bindgen]
fn fun_format(s: &str) -> String { format!("{s} is so cool!") }
And I'm done (at least from the Rust side). The JavaScript side is about as
easy. I cannot explain in words how satisfying it feels to put #[wasm_bindgen]
over a struct
, enum
, or function and just have it work. I know a good
amount about WebAssembly (the instruction set), and it still feels magical.
The Tooling Issue
Unfortunately, even with such fantastic FFI, there's quite a bit of work you
have to do to get Rust running on the browser with libraries like
wasm-bindgen
. There are three separate
mdbooks that I was bouncing between while
figuring out the website:
wasm-bindgen
, of course, is the library I mentioned earlier. wasm-pack
is
the recommended tool for building your wasm-bindgen
project (it's essentially
a wrapper over cargo build
). There are a selection of targets to build for:
module bundlers like webpack (the default), node, and the browser.
All of this is pretty overwhelming when all I want to do is take advantage of
wasm-bindgen
. I understand that an extra build step is pretty much required,
but I think wasm-pack
has defaults that do not reflect the bare minimum of
work to get Rust code on the browser. The defaults should be simple: a Wasm
binary and some JavaScript files to load. No more, no less. The tutorials should
show you how to build around those compilation artifacts to take advantage of
more complex tools, like bundlers.
To make it worse, while I was working on getting my code on the browser, I was
getting deprecation warnings and incompatibility errors from the various
dependencies that this build process requires. As we all know, web tooling is a
very fast-growing space, and there were a ton of dependencies required by using
wasm-pack
, many of which were outdated.
Rust Idioms to JavaScript
This point is less of an annoyance and more of just an interesting problem I encountered. I'll start with the actual issue:
We all know and love Rust's Result<T, E>
type.3 However, the
errors-as-values idea (and even enums in general) don't translate well to
JavaScript. In my program, the error type is defined as follows:
#[wasm_bindgen]
pub enum EvalError {
ParseError,
DoesNotConverge,
}
pub type Result<T> = std::result::Result<T, EvalError>;
// Example function
#[wasm_bindgen]
pub fn do_some_stuff() -> Result<()> { /* ... */ }
If I call do_some_stuff
from JavaScript, and it returns a Result::Err
, an
exception containing my EvalError
will be thrown. This is a completely
different method for error handling! Interestingly, it's neither idiomatic Rust
nor idiomatic JavaScript. If I want to catch this error, then I will have to
do something like this:
try {
// ...
} catch (e) {
switch (e) {
case 0:
console.log("parsing error");
break;
case 1:
console.log("DNE");
break;
default:
console.error("bad error type found");
break;
}
}
e
is a number because EvalError
is translated to a number across the FFI
boundary. Not great...
Wrap Up
To summarize my thoughts about the whole Rust-Wasm experience, I'd say that allowing for nice APIs that work in complete generality is hard. Wasm is useful (in part) because of its generality. That makes it hard to provide a great FFI experience out of the box. Not to mention, a lot of the idioms of a language are lost in translation.
This project was really fun and a small dip into the waters of the practical uses of WebAssembly. There's a lot of theory and cool experiments being done all the time in the Wasm community, including one of my own projects. So it's nice to check in with the real world once in a while. Not to mention, it was fun mixing together heavy math and programming!
You can read the paper if you're interested in the math or the algorithm we used to implement the formula. Make sure to check out the project's GitHub repository if you're interested in the actual implementation! Or, of course, you can use the calculator on the website. Lastly, if you had any questions or comments about this post, please submit an issue on this blog's GitHub repository!
Basically, it takes a number of Riemann sums, and "combines" the information in them to extrapolate a better approximation. It's fast because it takes calculations you've already made and produces a better one. ↩︎
It does this much faster than a graphing calculator can. ↩︎
If you don't, I'd recommend reading about it in the Rust book! ↩︎