Over the last couple of commutes to and from work I’ve been playing with Rust, which went v1.0 over the weekend.
Rust is touted as a systems language in the same vein as C, C++ and to a lesser extent, Google’s Go. It offers both high level abstractions like generic programming and object-orientism while also giving access to lower-level facilities like fine-grained memory allocation control and even inline assembly.
Critically for me, it has a “only pay for what you use” mentality like C++, a well-sized standard library and runtime, and no garbage collection. It’s quite feasible to use Rust to make a “bare metal” system in (for example, Zinc).
One of the novel things Rust brings to the table is its memory ownership semantics. Each allocation’s lifetime is tracked by the compiler (with an occasional helping hand from the programmer). Passing references to objects invokes the “Borrow Checker” which makes sure nobody holds on to objects beyond their lifetime. This solves a lot of memory ownership issues (maybe all of them?) up front, in the compiler. I love this.
Another nice feature is having proper, deterministic “destructors” that run when a variable binding goes out of scope. I miss this a lot in those times where I’m programming in a language other than C++.
So I loved the sound of all this, but in order to see how it all fitted together in practice, I decided to port a C++ path tracer to Rust, and see how I’d get on. I’m fond of smallpt, a 99-line C++ program that generates lovely images. While I was still at Google, I did the same experiment with the (then unreleased) Go language; and found the code generator lacking, and the development experience a little lacking. How would Rust fare?
Pretty well!
Firstly I hacked up a Vec3d
class to do all the 3D maths needed. It was
surprisingly easy to make a struct
, and then add an impl
to do all the
operations I needed. As a bonus, by impl
-ing the relevant Add
, Sub
etc
traits
, I was able to get things like let a = b + c;
working for vectors.
pub struct Vec3d {
pub x: f64,
pub y: f64,
pub z: f64
}
impl Vec3d {
pub fn dot(self, other: Vec3d) -> f64 {
self.x * other.x +
self.y * other.y +
self.z * other.z
}
...
}
impl Add for Vec3d {
type Output = Vec3d;
fn add(self, other: Vec3d) -> Vec3d {
Vec3d {
x: self.x + other.x,
y: self.y + other.y,
z: self.z + other.z
}
}
}
Source of the above here, full source on github.
Immediate things I found:
if
statement in the middle of your expression like :
a = if b > 1 { 1 } else { 2 }
.sqrt(x)
you say x.sqrt()
. Which
is awesome. Traits are not only interface descriptions (and are used both
for generics and for virtual-method type dispatch), they also have the
ability to provide C#-like extension methods to existing types.use foo::bar;
a trait into scope, it won’t act, so any
extension methods it provides won’t be there. This may lead to scratching of
the head and the saying of “but I called foo.bar()
in this other Rust file
ok!”f64
is how you spell double
in Rust.2.3 * 2
is an error, but 2.3 * 2.0
is not.foo as Type
, for example x as f64
. Its operator precedence is
high, so you can safely write 3.141 * x as f64
(assuming x
is an
integer or similar).snake_case_names
; not a personal
preference of mine but I guess I’ll get used to it.#[derive(Clone,Copy)]
in
front of it. This took a while to work out…I didn’t find much in the Rust
tutorial on this.use
as far as I can tell).mod
introducing new
submodules and the build process keying off of this somehow to work out what
Rust files to compile. I’m still
getting my head around it but have found something that works well enough
for me for now.Once the Vec3d class was finished (and even had a simple test), I moved on to
the body of the renderer. It was fairly simple to get up and running. Probably
the trickiest part was remembering to type cargo build
instead of make
!
Things I learned getting the first image rendered:
let a = [Vec3d; 1024];
and they’re always allocated on
the stack, which is fixed to be 2MB for the main thread. That made my
1024x768xVec3d
array to store the results of the render dump core as the
stack overflowed. The solution is to use a Vec
(the std::vector
of Rust)
which puts its memory on the heap.match
, if let
and their destructuring are awesome – being able to return an
Optional<T>
and then match and destructure it elsewhere.This leads to nice code like:
let mut result : Option<HitRecord> = None;
for sphere in scene {
// if let will assign to 'dist' if the return of intersect
// matches "Some".
if let Some(dist) = sphere.intersect(&ray) {
// if we don't currently have a match, or if this hit
// is nearer the camera than an existing result, then
// update 'result' with this hit.
if match result { None => true,
Some(ref x) => dist < x.dist) } {
result = Some(HitRecord {
sphere: &sphere, dist: dist
});
}
}
}
HitRecord
that may refer to one
of those spheres. In C++ you’d just use a Sphere *
and then be aware that
that pointer is only valid while the array of Sphere is. In Rust you have to
be explicit by tagging lifetimes with 'names
, then matching references up
with those names. This is only needed if the compiler can’t derive them
itself.An example:
struct HitRecord<'a> {
sphere: &'a Sphere,
dist: f64
}
fn intersect<'a>(scene: &'a [Sphere], ray: &Ray)
-> Option<HitRecord<'a>> {...}
Here the lifetime indicator 'a
is used to show the sphere reference inside
the HitRecord
is only valid while the similarly-tagged scene
slice is. The
compiler will give an error if you try and let a HitRecord
outlive the
scene
it came from.
With all that in place I got my first image:
A little example of path tracing in action.
If you look at the assembly output of Rust (e.g. with Rust explorer, you can see it’s able to utilize the LLVM backend to do some impressive SSE2 code generation. Coupled with the “no allocations unless you ask for them”, and no need to stop the world for garbage collection etc, it performs well.
In my simplistic benchmark on my laptop during my commute, it performs the
same (within a second or so) as the smallpt.c
compiled with -O3 -fopenmp
, at least once I put in
rudimentary threading to match the OpenMP implementation in smallpt
. I’ll
run a longer test (and debug some IO slowdowns that are contributing to the
difference) and post again with more information.
All in all I’m extremely excited and I look forward to spending more time hacking on Rust!
UPDATE: I’ve now written a follow-up post on the performance numbers, having fixed a number of bugs in the Rust version.
Matt Godbolt is a C++ developer living in Chicago. Follow him on Mastodon or Bluesky.