Tracing Rays with Jank

yogthos1 pts0 comments

Tracing rays with jank<br>Tracing rays with jankJun 01, 2026 · Jeaye Wilkerson<br>I've continued my optimization work through May and today I'll be reporting the status of the next benchmark: a little Clojure ray tracer. Before jumping into the details, I want to say thank you to my Github sponsors and to Clojurists Together for sponsoring me this whole year. Thank you!The ray tracer<br>A whopping three years ago, I wrote a blog post focused on optimizing a ray tracer. This was before jank fully supported macros, before jank had seamless C++ interop, before jank had beautiful error messages, and before jank could AOT compile programs. This was still when I was working at EA and building jank during my nights and weekends.Now it's time to revisit this benchmark, but with some modern updates. I have patched the ray tracer to use idiomatic macros, to invoke keywords to do map lookups, and to use jank's new seamless C++ interop as needed. I've also spiced up the visuals a bit. Here's the image we're going to be building.<br>The code<br>You can find the full ray tracer source code here. Note that Clojure JVM and jank are using the same source file. This particular ray tracing code is not optimized to be fast. It's written to just be naíve Clojure code, as though we had no care in the world. What we're optimizing is how well jank can handle this code, compared to Clojure. If someone wanted to write a CPU ray tracer in Clojure and have it be fast, the code would look quite different.The main thing to keep in mind, for the ray tracer, is that we generate a "scene" of spheres, each with a particular material. We then cast a bunch of rays and bounce them all around the scene, collecting colors from the sky and the various spheres they bounce into. Our rays are just Clojure maps which look like this.{:r 1.04<br>:g 0.00377<br>:b 37.1984}Spheres are also just Clojure maps and look like this.{:center (vec3-create 0 -1000 0)<br>:radius 1000<br>:material {:albedo (color 79 71 137)<br>:scatter scatter-lambertian}}We expect to spend most of our time creating maps, looking up values in maps, and crunching numbers. Each ray is a map, each bounce off a sphere creates a new ray, and basically all math is done one the components of these rays and spheres.Baseline numbers<br>For this blog post, we're generating a 100x60 version of the above image. Using OpenJDK 21 (default on my distro), Clojure 1.12.4 on my x86_64 Linux machine takes 2.53 seconds. By using a relatively large image size here, and a longer benchmark, we're spending more time generating garbage and triggering more GC collections. Overall, this gives us a better impression of not only how quickly Clojure/jank can render this image, but also how well they hold up when doing this for a longer period of time.On the jank side, we start our benchmark time at 8.10 seconds. That's roughly 3.2x slower than Clojure, to start with. I do look forward to starting on benchmarks and having jank already be faster than Clojure, but we're not quite there yet. Here's a chart. Did you know these are written in Clojure?<br>NaN boxing<br>For our last optimization benchmark, a recursive fibonnaci function, we implemented tagged pointers for integers. This allowed us to avoid dynamic allocations for most integers by encoding them within the pointer and using the lowest bit of the pointer to denote this special case. This worked really well, but it only applied to integers. Many real world programs, and indeed our ray tracer, are built around floating point numbers. The very first thing I want to tackle is how to avoid dynamic allocations for all of these floating point numbers. However, encoding floating point numbers into a 64 bit pointer is much more involved, since 64 bit IEEE 754 doubles have a particular layout, with each bit assigned to a specific role. They look like this.seeeeeee|eeeemmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm<br>^- exponent ^- mantissa<br>^- sign bitNote, I am using the same visualization that Nikita Popov did on his beautiful post about NaN boxing, since it's my favorite of all that I've read. I am going to briefly summarize this approach, but Nikita does a great job of getting more into the details. He even provides some helpful starter code.Part of the encoding of these doubles is the ability to represent NaN, or "not a number". There are a couple of different bit patterns used to identify NaN. For our use case, if all exponent bits are set, and the first mantissa bit is set, we have a NaN. The sign bit doesn't matter and the remaining 51 bits are a "payload" which was originally designed to hold error information. We'll endeaver to represent our pointer there.seeeeeee|eeeemmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm|mmmmmmmm<br>s1111111|11111ppp|pppppppp|pppppppp|pppppppp|pppppppp|pppppppp|pppppppp<br>^- first mantissa bit 1 everything else is "payload" -^<br>^- exponent bits all 1<br>^- any sign bitBut how do we store a 64 bit pointer in 51 bits? Well, it turns out that most pointers are...

jank clojure mmmmmmmm tracer code rays

Related Articles