Two Weeks With Julia: a Java Programmer’s Journey Into New Paradigms

otde
The Startup
Published in
10 min readNov 26, 2019
The logo for the Julia programming language.

I decided I wanted to learn a new language two weeks ago. While I was having a lot of fun working with Java 8’s Streams for the first time, I felt like I was trying to make Java do something it was not initially built for. This isn’t a quantifiable feeling, but it was enough to have me looking in other places. A brief glance at the Exercism language list led me to Julia, whose founders it describes as wanting “to eat their cake, and have it too.” This, by all accounts, a fairly accurate assessment of Julia’s design goals, which fold dynamic typing, multiple dispatch, powerful metaprogramming support, and some seriously wild performance into a single nifty language.

I decided to dive in with a very simple project: recreating the basic geometric structures in chapter two of Physically Based Rendering Techniques (hereafter referred to as PBRT). This hefty bugger is an omnibus of rendering theory and implementation, clocking in at 1238 pages on the digital edition. Its geometric structures follow very particular sets of rules, and interact with each other quite strictly. The full source for the file I’m about to go into can be found here. I’m going to explain how I learned the Julian approach to building these structures, and the implications that has. Full source code for the package in its current state is at the end of this article.

Thoughts on PBRT’s design

PBRT creates separate classes for each data type among Vectors, Points, and Normals, for each dimension. Each of these is a template class, with a few convenient aliases for certain types — that is, you’d write a three-dimensional vector with integer coordinates as Vector3i. Because they’re separate classes, every operation between these points must be explicitly defined. Can you add a Point2i to a Normal3f? Only if PBRT has outlined that explicitly. On the one hand, this is useful: in every case, you will only ever get expected behavior or an error when you attempt any operations between these types. However, it’s also really verbose — take a look at the size of the file there. What can Julia offer us that might make this more concise?

“Stock images of Julia code” is a surprisingly abundant wellspring.

Types!

In Java, we might approach the problem by creating an base class and assigning certain methods (add, sub, mul, div, dot, etc.) to each subclass. Unlike in C++, we wouldn’t be able to overload any of the operators to achieve indexing behavior or the appearance of numerical computation, which means out code is going to look less like explicit equations. In some cases, making your code read like prose might be nice! But mathematics is a prose of its own, and this is a feature we’d sorely miss. Can we do better?

One of the first things you learn about Julia is that code is largely organized by behavior instead of by structure — by what happens to something, rather that what something is. This blog post was a great introduction to the concept for me, and is useful for understanding which use cases work best for this kind of code. In our case, that means we need to think about how we operate on Vectors, Points, and Normals, rather than what actions belong to those types. This is useful because many of the operations between these types don’t actually “belong” to anyone. If you’re adding a Vector and a Point, who owns that operation? When you say something like “adding 1 to 2” when you define the operation for 1 + 2, you’re the implying that addition belongs to the right side of the expression. With Julia, we can avoid this kind of strict judgement by thinking about their behaviors: what behaviors are shared? Which are different? By doing this, we can create a hierarchy of types, like so:

Notice a couple things:

We’re using FieldVector from StaticArrays.jl, which defines a few things in advance for us. This includes the following:

  • Indexing behavior: this tells us a concrete implementation will have N fields of type T, which can be any kind of Number. We can access fields 1 through N with the indexing operator, since the type is assumed to behave like an Array.
  • In fact, because we’re inheriting Array behavior, every broadcasted operation works exactly as expected, so using the .+ operator between two CartesianTuples of the same dimension will return the expected behavior implicitly.

The structs Vec, Pnt, and Nrm are specialized types we can pass as a parameter to any kind of concrete implementation we make of CartesianTuple. Now, given all these parameter types, we can dispatch on different types with almost no repetition. The code for defining addition between two tuples of the same dimension and component type is a two-liner:

Look at it! Baby function!

Do you see this thing? This defines component-wise addition for every N-dimensional tuple with shared component types. This defines addition for every 2D and 3D Vector, Point, and Normal with another of its kind. If they contain different number types, the function uses Julia’s built-in type promotion system for its numbers and promotes the resulting number types to contain the type with the highest precision. What’s even more wild is that Julia’s compiler optimizes away any unnecessary branches of code in the process. Adding a Point2f and a Point2i generates the following LLVM code:

julia> @code_llvm debuginfo=:none Point2f(2.4, 3.7) + Point2i(3, 4); Function Attrs: uwtable
define { float, float } @”julia_+_17584"({ float, float } addrspace(11)* nocapture nonnull readonly dereferenceable(8), { i64, i64 } addrspace(11)* nocapture nonnull readonly dereferenceable(16)) #0 {
top:
%gcframe = alloca %jl_value_t addrspace(10)*, i32 3
%2 = bitcast %jl_value_t addrspace(10)** %gcframe to i8*
call void @llvm.memset.p0i8.i32(i8* %2, i8 0, i32 24, i32 0, i1 false)
%3 = call %jl_value_t*** inttoptr (i64 1801343456 to %jl_value_t*** ()*)() #6
%4 = getelementptr %jl_value_t addrspace(10)*, %jl_value_t addrspace(10)** %gcframe, i32 0
%5 = bitcast %jl_value_t addrspace(10)** %4 to i64*
store i64 2, i64* %5
%6 = getelementptr %jl_value_t**, %jl_value_t*** %3, i32 0
%7 = getelementptr %jl_value_t addrspace(10)*, %jl_value_t addrspace(10)** %gcframe, i32 1
%8 = bitcast %jl_value_t addrspace(10)** %7 to %jl_value_t***
%9 = load %jl_value_t**, %jl_value_t*** %6
store %jl_value_t** %9, %jl_value_t*** %8
%10 = bitcast %jl_value_t*** %6 to %jl_value_t addrspace(10)***
store %jl_value_t addrspace(10)** %gcframe, %jl_value_t addrspace(10)*** %10
%11 = bitcast { i64, i64 } addrspace(11)* %1 to <2 x i64> addrspace(11)*
%12 = load <2 x i64>, <2 x i64> addrspace(11)* %11, align 8
%13 = sitofp <2 x i64> %12 to <2 x float>
%14 = bitcast { float, float } addrspace(11)* %0 to <2 x float> addrspace(11)*
%15 = load <2 x float>, <2 x float> addrspace(11)* %14, align 4
%16 = fadd <2 x float> %15, %13
%17 = extractelement <2 x float> %16, i32 0
%18 = extractelement <2 x float> %16, i32 1
%19 = fcmp uno float %18, %17
br i1 %19, label %L20, label %L18
L18: ; preds = %top
%.fca.0.insert = insertvalue { float, float } undef, float %17, 0
%.fca.1.insert = insertvalue { float, float } %.fca.0.insert, float %18, 1
%20 = getelementptr %jl_value_t addrspace(10)*, %jl_value_t addrspace(10)** %gcframe, i32 1
%21 = load %jl_value_t addrspace(10)*, %jl_value_t addrspace(10)** %20
%22 = getelementptr %jl_value_t**, %jl_value_t*** %3, i32 0
%23 = bitcast %jl_value_t*** %22 to %jl_value_t addrspace(10)**
store %jl_value_t addrspace(10)* %21, %jl_value_t addrspace(10)** %23
ret { float, float } %.fca.1.insert
L20: ; preds = %top
%24 = bitcast %jl_value_t*** %3 to i8*
%25 = call noalias nonnull %jl_value_t addrspace(10)* @jl_gc_pool_alloc(i8* %24, i32 1744, i32 16) #2
%26 = bitcast %jl_value_t addrspace(10)* %25 to %jl_value_t addrspace(10)* addrspace(10)*
%27 = getelementptr %jl_value_t addrspace(10)*, %jl_value_t addrspace(10)* addrspace(10)* %26, i64 -1
store %jl_value_t addrspace(10)* addrspacecast (%jl_value_t* inttoptr (i64 114348416 to %jl_value_t*) to %jl_value_t addrspace(10)*), %jl_value_t addrspace(10)* addrspace(10)* %27
%28 = bitcast %jl_value_t addrspace(10)* %25 to %jl_value_t addrspace(10)* addrspace(10)*
store %jl_value_t addrspace(10)* addrspacecast (%jl_value_t* inttoptr (i64 273106832 to %jl_value_t*) to %jl_value_t addrspace(10)*), %jl_value_t addrspace(10)* addrspace(10)* %28, align 8
%29 = addrspacecast %jl_value_t addrspace(10)* %25 to %jl_value_t addrspace(12)*
%30 = getelementptr %jl_value_t addrspace(10)*, %jl_value_t addrspace(10)** %gcframe, i32 2
store %jl_value_t addrspace(10)* %25, %jl_value_t addrspace(10)** %30
call void @jl_throw(%jl_value_t addrspace(12)* %29)
unreachable
}

A lot of this code comes from a few key details: first, the constructor checks if any values in its parameters are NaN, and throws an AssertionError if this is the case. The rest comes from converting two integer values to floating point values. Now watch what happens when we add two integer point values:

julia> @code_llvm debuginfo=:none Point2i(2, 3) + Point2i(3, 4); Function Attrs: uwtable
define void @”julia_+_17590"({ i64, i64 }* noalias nocapture sret, { i64, i64 } addrspace(11)* nocapture nonnull readonly dereferenceable(16), { i64, i64 } addrspace(11)* nocapture nonnull readonly dereferenceable(16)) #0 {
top:
%3 = bitcast { i64, i64 } addrspace(11)* %1 to <2 x i64> addrspace(11)*
%4 = load <2 x i64>, <2 x i64> addrspace(11)* %3, align 8
%5 = bitcast { i64, i64 } addrspace(11)* %2 to <2 x i64> addrspace(11)*
%6 = load <2 x i64>, <2 x i64> addrspace(11)* %5, align 8
%7 = add <2 x i64> %6, %4
%8 = bitcast { i64, i64 }* %0 to <2 x i64>*
store <2 x i64> %7, <2 x i64>* %8, align 8
ret void
}

No conversion is necessary, and integer values can’t be NaN, so the compiler doesn’t need to check if they are or not. Even so, the first one is still fast:

julia> @btime Point2f(2.4, 3.7) + Point2i(3, 4)
0.001 ns (0 allocations: 0 bytes)

The flexibility that comes with this kind of free abstraction combined with this level of conciseness cannot be understated. Remember the abstract VectorLike type up top? Vectors and Normals share a lot of behavior, and for cases where they’re interchangeable, we can define functions that dispatch on that type, too. Consider the dot product, which we define using the infix operator \cdot from our good friend LaTeX.

We get the broadcast operation from the fact that we’re subtyping abstract arrays of equal dimensions, and then summing the eventual result. This means that for any combination of Vector3 and Normal3 we’ve defined the dot operator in a single line of code, clear as day.

Using a variety of techniques like this, I was able to whittle my port of geometry.h down to about a third of its original size. I’ve never written anything like this in any other language. It was an absolute joy to code.

So what do I, an inexperienced college graduate with two weeks of Julia under my belt, have to say about working with Julia? I bet you’re dying to know, aren’t you? Let’s get into it.

High-level impressions

Less is more

I’m already a math-oriented person, so this language was bound to be up my alley, regardless of how it felt to program in. What surprised and delighted me as I developed further, though, was how naturally the language’s type and dispatch systems lent themselves to making concise, clear, say-it-once-and-never-again code. Terse and readable code expression can be difficult to come by in high-performance environments, but somehow, Julia manages to ride that line.

Testing is fun somehow

Julia lets you build tests and test sets iteratively, meaning I can loop through all the valid types and run a single test suite on all of them. It’s simple, fun, and makes testing large batches a dream. Depending on the data set, I can make this sort of test run any number of tests on any kind of data I want:

More of this, please.

Julia‘s community is great

I joined the Julia slack around the same time I started learning the language. Every day, I’ve asked questions in the channel and talked with people as I’ve worked on this package. Every day, those questions have been answered in rigorous detail by multiple people. It’s not just that Julia is a fun language to learn — it’s that I’m in a space with people who make learning fun. Your mileage will vary if you’re trying out the language on your own, and I’d highly encourage connecting with people who know more than you do about Julia (for me, that’s 99% of Julia users, so it’s been quite easy).

It’s a little more than that, though. I am a nonbinary person who does not share that fact often in tech circles, because it rarely ends well. Regardless of what YouTube might tell you, I do not wish to be a “special snowflake.” I would, however, love to code among people I know will respect my personhood, as I do theirs. I have only had one conversation with respect to my gender on the Julia server (as it is almost never relevant), but when I clarified my pronouns, the reference to me was immediately edited. This is the base standard for politeness, but it is one I rarely get. I appreciate the Julia community’s commitment to being a welcoming place for people like me.

Some stuff I struggled with

Julia is rarely a programmer’s first language, and I doubt it aims to be — as I listened to other people’s thoughts on Julia, I got the sense that many were coming from other places and looking for a different take on programming. One person even told me as much. Even so, I found the learning curve for setting up Julia to be something I wasn’t quite expecting. This is probably because I was used to AOT-compiled languages with Intellisense-esque IDE features, but I had to figure out completely different workflows as I transitioned languages.

It can sometimes be difficult for me to understand when Julia needs a restart. Revise-based workflows seem to take a lot of the pain away on this, as do startup scripts, but a lot of it is very power-user focused, and I found it difficult as a first timer to learn those parts quickly.

In short:

If you’ve been working with inheritance-based OOP for your entire time as a programmer, learning this style of programming can be difficult at first, because it bucks a lot of the assumptions about object relationships that you may be going into the experience with. It is still absolutely worth the trip, and Julia is well-suited for the task.

--

--