anchor
Insights /  
Clojure Speed: Evaluating the Performance of Clojure

Clojure Speed: Evaluating the Performance of Clojure

July 25, 2024
->
5 min read
Clojure
By
Oleksandr Druk
Clojure Developer
Sofiia Yurkevska
Content Writer

Clojure offers a unique combination of developer productivity and performance potential. It has gained popularity for its emphasis on immutability, concurrency, and simplicity. However, one of the recurring questions among developers considering Clojure is its performance. How does Clojure stack up in terms of speed?

While it may not be the fastest language out of the box, its optimization capabilities make it a strong contender for performance-critical applications. Businesses can leverage Clojure to build scalable, maintainable, and efficient systems by carefully considering the trade-offs and investing in optimization where necessary.

The Business Case for Clojure Performance

Before diving into technical details, let's consider why Clojure's performance matters from a business perspective:

1
Cost Efficiency
Optimized Clojure code can run efficiently on existing hardware, potentially reducing infrastructure costs.
2
Scalability
Clojure's concurrency features and performance optimizations can improve scalability, supporting business growth.
3
Time-to-Market
While initial development in Clojure might be slower due to its learning curve, its expressiveness can accelerate feature development in the long run.
4
Maintenance Costs
Clojure's emphasis on simplicity and immutability can lead to more maintainable code, reducing long-term costs.

The JVM Advantage

Dynamic, functional languages like Clojure are often perceived as slower than their statically typed or object-oriented counterparts due to features like dynamic function dispatch and immutability. However, one of Clojure's key benefits is its execution on the JVM, a mature and highly optimized runtime environment that offers excellent performance and stability. Languages on the JVM can take advantage of Just-In-Time (JIT) compilation, garbage collection, and a vast array of libraries in the Java ecosystem. With proper optimization, Clojure can achieve performance levels comparable to those of JVM languages like Java or Scala.

Execution Speed

Clojure is generally slower than Java in raw execution speed. This is partly due to its dynamic nature, which introduces overhead, dispatch, immutable orientation, etc.

We can apply several strategies to optimize Clojure code and get speed results close to those of Java. We'll start with low-hanging optimizations and then move on to micro-optimizations. All code samples use the criterium library for benchmarking.

Laziness

Think of laziness like a just-in-time inventory system. Rather than storing vast amounts of inventory (computed data), you produce only what's needed when needed. This reduces overhead and increases efficiency. 

Clojure’s sequence abstraction and laziness are powerful and convenient programming facilities. Still, lazy implementations require generating a per-element state, which can be a substantial overhead compared to non-lazy alternatives like arrays and vectors, whose elements each contribute just their value’s size to memory.

1  (def test-lazy-seq (map inc (range 1e6)))
2  (quick-bench (reduce + test-lazy-seq)) ; => Execution time mean : 29.041281 ms
3
4  (def test-eager-vector (mapv inc (range 1e6)))
5  (quick-bench (reduce + test-eager-vector)) ; => Execution time mean : 18.085722 ms

Reflection 

Reflection allows Clojure to inspect and interact with Java objects at runtime without knowing their types in advance. It's like being able to use a tool without reading its manual first.

Reflection occurs when the type of an object is not known at compile time, causing the JVM to look up method information at runtime. Reflection can significantly slow down performance. Use type hints to help the compiler avoid It. For example:

1  (defn char-at [arg idx] (.charAt arg idx))
2  (defn char-at-hint [^String arg idx] (.charAt arg idx))
3  (let [test-str "Hello, World!"]
4    (quick-bench (char-at test-str 7)) ; => Execution time mean : 4.821564 µs
5    (quick-bench (char-at-hint test-str 7)) ; => Execution time mean : 12.351424 ns
6    )

The results difference is impressive.

Transducers & Reducers

Transducers and reducers are Clojure's power tools for processing data efficiently. Think of them as specialized assembly lines for your data. Transducers are reusable transformation recipes that can be composed and applied to different data sources, and reducers process collections more efficiently by reducing intermediate steps.

Reducers

The reducers library (in the clojure.core.reducers namespace) has an alternative map, filter, and seq functions implementations. These alternative functions are called reducers, and you can apply almost everything you know about seq functions to reducers. However, reducers are designed to perform better than seq functions by performing eager computation, eliminating intermediate collections, and allowing parallelism.

1  (quick-bench (reduce + (map inc (range 1e6)))) ; => Execution time mean : 64.090585 ms
2
3  (quick-bench (r/fold + (r/map inc (range 1e6)))) ; => Execution time mean : 53.005428 ms

Although reducers are much faster, we should only use them when there is only computation (i.e., no I/O blocking), data is sufficiently large, and source data can be generated and held in memory.

Transducers

Transducers perform transformations without creating intermediate collections, resulting in significant performance gains. They offer a more efficient means of processing sequences by eliminating the creation of intermediate lazy sequences.

1(quick-bench
2  (->> (range 1e6)
3       (filter odd?)
4       (map inc)
5       (take 1000000)
6       (vec))) ; => Execution time mean : 109.297682 ms
7
8(quick-bench
9  (into []
10        (comp
11          (filter odd?)
12          (map inc)
13          (take 1000000))
14        (range 1e6))) ; => Execution time mean : 60.394658 ms

Transients

Transients provide a way to create and modify collections efficiently before converting them back to immutable collections. This can be particularly useful when building large collections.

1  (quick-bench (reduce conj [] (range 1e6))) ; => Execution time mean : 77.095948 ms
2  (quick-bench (persistent! (reduce conj! (transient []) (range 1e6)))) ; => Execution time mean : 38.756604 ms

first and last

The first and last functions are implemented over Clojure’s lazy sequence API, which can be inefficient for specific types like vectors. For instance, last traverses the entire sequence, even if it's a vector. Both first and lastseq the input unnecessarily, which is an additional computation cost.

1  (def coll (range 1e6))
2
3  (quick-bench (first coll)) ; => Execution time mean : 48.040800 ns
4  (quick-bench (nth coll 0)) ; => Execution time mean : 9.987383 ns
5  (quick-bench (.nth ^Indexed coll 0)) ; => Execution time mean : 9.899466 ns
6
7  (quick-bench (last coll)) ; => Execution time mean : 31.809881 ms
8  (quick-bench (nth coll (-> coll count dec))) ; => Execution time mean : 40.855690 ns

Using peek instead of last for vectors avoids the complete traversal, improving performance.

Dynamic Vars vs. ThreadLocal

Clojure has dynamic vars and binding, but the Clojure implementation of reading a dynamic var is far less optimal than using a ThreadLocal directly:

1  (def ^:dynamic *dynamic-state* 0)
2
3  (defn dynamic-test []
4    (binding [*dynamic-state* 42]
5      ,*dynamic-state*))
6
7  (quick-bench (dynamic-test)) ; => Execution time mean : 511.877784 ns
8
9  (def thread-local-state (ThreadLocal.))
10
11  (defn thread-local-test []
12    (.set thread-local-state 42)
13    (.get thread-local-state))
14
15  (quick-bench (thread-local-test)) ; => Execution time mean : 3.449529 µs

Destructuring

Destructuring sequential collections, as we can see, leverages nth, which evaluates a series of reflective conditionals to determine the type of its argument to dispatch the correct behavior.

1  (def arr (long-array [1 2 3]))
2
3  (quick-bench
4    (let [[a b c] arr]
5      a)) ; => Execution time mean : 240.429974 ns
6
7  (quick-bench (nth arr 0)) ; => Execution time mean : 69.167753 ns
8
9  (quick-bench (aget arr 0)) ; => Execution time mean : 9.020751 µs

Avoiding destructuring can lead to performance gains by reducing the overhead associated with reflection.

Safe arithmetic

Safe arithmetic functions check for overflow and other errors and are slower than their unchecked counterparts. You can use unchecked arithmetic if you're sure it's safe in your case.

1  (defn safe-add [a b]
2    (+ a b))
3
4  (quick-bench (safe-add 1 2)) ; => Execution time mean : 15.238300 ns
5
6  (defn unsafe-add [a b]
7    (unchecked-add a b))
8
9  (quick-bench (unsafe-add 1 2)) ; => Execution time mean : 13.901950 ns

Profiling tools

Keep in mind the famous Kent Beck-attributed aphorism, “Make it work, then make it fast”, or the Donald Knuth-attributed “Premature optimization is the root of all evil.” Before optimizations, you should profile your code and understand its problems and bottlenecks.

For this, you can leverage these tools and libraries:

1
Tufte allows you to easily monitor the ongoing performance of your Clojure and ClojureScript applications in production and other environments. It provides sensible application-level metrics and presents them as Clojure data that can be easily analyzed programmatically.
1
A benchmarking tool that is a Clojure industry standard at this point. It is designed to address some of the pitfalls of benchmarking, particularly benchmarking on the JVM.
1
Clj-async-profiler has very low overhead during profiling, which is suitable even in highly loaded production scenarios. clj-async-profiler presents the profiling results as an interactive flame graph. You can navigate the flame graph, query it, change parameters, and adapt the results for a more straightforward interpretation.
1
A feature-rich benchmarking toolkit for the JVM. Its usage is heavily Java/annotation-based (so not REPL-friendly) but has an expressive feature set, making it possible to run more specialized benchmarks.

Conclusion

Clojure generally offers high developer productivity and solid performance out of the box, but significant optimization is achievable for critical performance areas.

The optimization process often starts with naive Clojure implementations that leverage persistent structures and boxed math and then evolve towards more optimized versions that use hints, primitive math, efficient class-based access, and direct Java interop when necessary.

Always profile your code first to understand the performance issues, then optimize as needed, knowing that Clojure can meet your performance requirements with the proper techniques. Don't let performance concerns hold you back from exploring Clojure's potential. Take the first step by discussing Clojure adoption with your technical team and evaluating its potential impact on your projects.

Build Your Team
with Freshcode
Author
linkedin
Oleksandr Druk
Clojure Developer

Self-taught developer. Programming languages design enthusiast. 3 years of experience with Clojure.

linkedin
Sofiia Yurkevska
Content Writer

Infodumper, storyteller and linguist in love with programming - what a mixture for your guide to the technology landscape!

Share your idea

Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
What happens after
you fill this form?
We review your inquiry and respond within 24 hours
A 30-minute discovery call is scheduled with you
We address your requirements and manage the paperwork
You receive a tailored budget and timeline estimation

Talk to our expert

Nick Fursenko

Nick Fursenko

Account Executive
With our proven expertise in web technology and project management, we deliver the solution you need.
We review your inquiry and respond within 24 hours
A 30-minute discovery call is scheduled with you
We address your requirements and manage the paperwork
You receive a tailored budget and timeline estimation
Shall we discuss
your idea?
Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
What happens after you fill this form?
We review your inquiry and respond within 24 hours
We hold a discovery call to discuss your needs
We map the delivery flow and manage the paperwork
You receive a tailored budget and timeline estimation
Looking for a Trusted Outsourcing Partner?