Introduction
Why Rust and Kotlin?
I'm first and foremost a developer writing applications that run on the JVM (Java Virtual Machine), using languages such as Kotlin, Scala and Java (in my order of preference). However, it's impossible to ignore the remarkable strides Rust has been making lately, especially when it comes to tooling.
Let's look at some examples:
- Turborepo - This blog is powered by NextJS. When building/running your site you can enable the
--turbopackflag to speed up builds. - Tauri - An Electron alternative for building multiplatform applications.
- uv - The Python community is currently enjoying a faster pip alternative.
- Ruff - Another treat to the Python community, a super quick linter.
- Polars - Fast DataFrames for Python, Node.js, R and SQL.
- Alacritty - The terminal I'm using is written in Rust, and it's awesome.
- Deno - An alternative to NodeJS, built on V8 and Rust.
The list goes on and on. Suffice to say that Rust is making a strong impression on the software industry by improving the efficiency and performance of many tools and applications which makes it worth investing time and effort into evaluating the technology.
What we'll do
I'm curious to know how Rust compares to Kotlin (JVM) when building standard HTTP APIs. Rust is less ergonomic (in my opinion) than Kotlin when it comes to writing code, but if the performance benefits are convincing enough it might be worth building new services in Rust instead.
Keep in mind that this is a very opinionated performance comparison. I'll select libraries that I like to work with for both Rust and Kotlin and I won't really nitpick on small changes in performance, I expect to see a big differences in resource utilization.
We will start off with building two REST APIs, one in Rust and the other in Kotlin, aiming for an identical feature set that we will then exercise with a load test. We'll take a look at CPU, memory usage and request latencies.
Common Stack
- PostgreSQL - Let's keep things simple with a PostgreSQL database where we can store our entities.
- Docker - Will serve as our runtime environment. We will use Docker Compose to orchestrate and automate the tests for quick iterations.
- pidstat - Continuously gathers CPU and Memory usage metrics from the service process.
- Locust - A convenient load testing tool.
Rust & Kotlin Stack
Below are the libraries we'll use listed with their corresponding counterpart in both languages.
| Kotlin | Rust | Description |
|---|---|---|
| ktor | axum | HTTP server library |
| jdbi | sqlx | RDBMS communication and conversion of objects to- and from PostgreSQL |
| HikariCP | N/A | RDBMS Connection pooling manager (sqlx has a good one built-in already) |
| jackson | serde | JSON conversion |
| kotlinx.coroutines | tokio | Async runtime for non-blocking IO |
| koin | N/A | Dependency injection for Kotlin (Axum kind of has this functionality built-in) |
Code
The Database Schema
Let's create a very simple database schema. We'll have a customer that can purchase products. That's it. I don't think this needs to be much more complicated than this. We don't want the database to be the main focus in these tests so we need just enough to make sure we have at least some meaningful queries.
With the above schema we got:
customerwith aname.saleis tied to a customer and occur on a specifictransaction_date.productWith a name and a price. We will have to prepare the database with a couple of products before any sales can be made.product_saleis only there to facilitate a many-to-many relationship between sale and product (one sale can include many products and one product can be sold multiple times).
You can freely browse all the code on GitHub ❤. I've managed to contain both the Rust and Kotlin code in a single file, both less than 400 lines. I hope it's readable enough 😅.
Let's start by taking a look at the Kotlin service code.
Kotlin Web Service
You might be surprised to discover that I've managed to fit the entire application in a single file. Java, and by proxy, the JVM languages have worked up a reputation for being very verbose and sluggish. I'm happy to tell you that Kotlin is a game changer in the ergonomics department, featuring an incredibly rich standard library with pretty much anything you can think of. Kotlin is also developed by JetBrains who also make IntelliJ IDEA which makes IDE integration with the language excellent.
Kotlin Dockerfile
Next up we want a way to build and run our Kotlin service. Below is the Dockerfile that builds and runs the Kotlin service.
The file consists of three stages,
- A java-base image which is really just a debian image with Java installed.
- The build stage compiles and bundles all classes into a self-contained distribution also known as a fat jar or shadow jar. This allows us to easily invoke the service with
java -jar <jar_with_all_dependencies>.jar. - At last we launch the application while continuously polling the service CPU and memory usage with
pidstateach second and store the result in process_metrics.txt.
About the Java Flight Recorder
We've also included an option that enables the Java Flight Recorder (JFR). This allows us to run the actual benchmarks without JFR so that we don't interfere with the benchmarking results, but lets us run tests on the side to gather more detailed metrics for the JVM. This is very useful for figuring out how much of the OS reported memory usage is actually in use by the application in the JVM. We need this because the JVM reserves a pool of memory from the operating system called the committed heap space which resizes depending on how much memory the application needs. For example, The OS might tell us that the JVM process is using 100MB of memory but when looking at the JFR data we can tell that only 80MB of that data is actually in use. In practice this means that there is always a buffer of unused memory that's allocated by the JVM. This is one of the reasons the JVM is often considered a memory hog, but it's not the only reason. I'd love to expand further on this topic but let's save it for a future post 🙂.
Rust Web Service
Please forgive me if I've not followed proper coding standards for Rust, but I think this should hold up for a decent performance test at least.
What does the serde_iso8601 module do? 🤔
I noticed weird formatting of the ISO-8601 date-times when I was testing the APIs (I prefer returning human readable ISO-8601 date-times in my APIs instead of millis since epoch).
This is what it looks like with the default time::serde::iso8601 serializer:
See the +00 at the very start of the date-time string? This was quite unexpected to me. There's some discussion on the topic in this PR if you're curious.
The solution is to create and use a serializer that's configured not to express years with six digits, which is why we need the serde_iso8601 module you see at the end of the Rust Web Service code block.
In case you're here because you have searched for this particular issue I've left a snippet for you below: 😉
Let's move on to the Dockerfile so that we can automatically build and run Rust as well.
Rust Dockerfile
We're using the same Linux distro (Debian) as for the Kotlin service but it's a bit neater than the Kotlin Dockerfile because I chose to build from a prepared Rust base image.
I'd like to mention that I learned quite a neat little bash trick while writing this. Notice the $! in the command above? It retrieves the pid (process id) of the last executed command which (I think) makes the above command quite nice and tidy.
Why curl? 🤔
If you have a good eye for detail (maybe you're a top-notch PR reviewer) you've probably noticed that we're installing curl in the containers, and it's not immediately obvious why. I would once again like to go out on a tangent, beginning with the embarrassing confession that I forgot to add a healthcheck endpoint
to the services without really noticing at first. While running the load tests I noticed that Locust was reporting some transient failures when testing the Kotlin application which later stabilized. The Rust service didn't have the same problem. As it turns out, the Rust service booted up so quickly
that it didn't really matter if I added a healthcheck step in the docker-compose.yml file that waits for the service to become available before starting the load tests. It did matter for the JVM application though. We now have a healthcheck step in the docker-compose.yml file that uses curl (in the container)
to check whether the service is healthy before spinning up the load test container.
All this to say I'm impressed with the blazingly fast startup time of the Rust application. 🙌🦀
Locust
🦗 Locust is a beautifully simple load testing tool. You write Python code to define the actions a user can perform. There is a bunch of available configuration options depending on how you want to test your application. We're using the most basic features because we only want to model a customer sign in and (very) frequent product purchases.
When a user spawns it immediately starts purchasing the same products over and over every second. You can modify this file if you want more interesting user behavior.
Docker Compose
To enable quick iterations and a well defined setup I've fabricated a docker-compose.yml file that sets up the Locust, PostgreSQL and the Kotlin/Rust service. Let's have a look.
The above declaration might look a little busy, but it allows us to spin up a database, web service and locust with a single command:
The docker-compose command bootstraps everything (including compiling the projects in a Docker container) so that we can begin our load tests. If you're following along you can navigate to the Locust UI http://localhost:8089/ and play with different settings. It's quite fun to play with the parameters to see how far the limit goes. For more advanced tests I recommend installing Locust on multiple machines simultaneously hammering a service in a distributed fashion (if you use a cloud provider you might have to ask for permission before doing this, be careful).
Why is the Locust service declared twice? 🤔
We use profiles to decide which applications to run. For example, you can specify a profile in the Docker Compose service declaration so that the service is only run if that profile is enabled. We want to test one service at a time, but we have both Kotlin and Rust declared in the same docker-compose file so we use profiles to selectively launch one or the other. However, profiles are quite limited. You can't specify an environment variable based on the profile you've set so we can't modify the locust configuration and, for example, change the host of the application. Even more importantly, if you remember the health checks we installed before, we can't specify the service the locust service depends on because those are different depending on the profile. The easiest solution is to duplicate the declaration. 🤷♂️
Final Automation
It wouldn't be very convenient to run these tests with various parameters if we had to copy the results from the containers and from the Locust UI every time each test is finished. For this reason there's a cli.sh script in the root directory which:
- Spins everything up with docker-compose.
- Waits for Locust to be ready and then automatically starts the load test.
- Copies and downloads the results.
- Performs some basic data wrangling to convert the
pidstatoutput file into two CSV files (memory and CPU usage). - Stores the results in <projectRoot>/benchmarks/<date>/<serviceName>
Here's the script:
I've tried making it as simple as possible to just run:
Without having to install anything on your machine except jq, curl, python and Docker. The first three are usually pre-installed on most Linux systems. Python is needed to convert the pidstat output to CSV files for visualization using the metric_parser.py script in the project root.
The script runs the tests via docker-compose.yml which also builds everything in Docker containers so you don't have to worry about the script installing any dependencies on your machine.
If you want to see each command that's executed by the script as it's running you can execute it with the execution trace option:
The scrips accepts the following optional parameters:
- --user-count - Specifies the maximum number of simultaneous users (default 100).
- --spawn-rate - Sets the amount of new users that should spawn every second until the maximum user count is reached (default 10).
- --run-time - The run time of the load test. Examples include 30s, 5m, 2h etc (default 1m).
Benchmark #1 - 500 users
Alright, we're ready to run some tests. Throughout all benchmarks Rust will be compiled with default --release option. We aren't adding any JVM options when running the Kotlin application either.
I did actually test tweaking the Rust and Java options somewhat, but I didn't see a big enough difference to include them here. However, it's entirely possible to bring down the Java memory usage considerably by setting the -Xmx option to a lower value. The amount of permutations that can be formed by tweaking all of these flags and options is quite large, so I'll leave it as an exercise to the curious reader.
Host specs:
- Windows 10 Home
- Intel® Core™ i9-9900K (8 Cores, 16 vCores, 3.6GHz)
- 64GB RAM
I've configured docker-compose to allocate 2 CPUs and 512MB of memory for the services to simulate the minimum tier of popular cloud provider offerings.
Let's run the cli.sh script with the following parameters. Grab a coffee or tea and chill out while the test runs if you're following along ☕
If you're following along you can quickly view the results in the analysis.ipynb notebook. Just run all the cells so that the charts pop up. The notebook is written in Kotlin, so you might want to use IntelliJ IDEA for the smoothest experience.
Now, let's have a look at the results.
Request Latency
Unsurprisingly the request latencies are mostly the same except in the 99th percentile and above which is due to the cold start of the Kotlin application code (most likely the very first couple of requests). Everything else is almost exactly the same which is expected.
Why is this expected? We're just forwarding all calls to the PostgreSQL database so there's nothing computationally expensive going on here. Most of the time is spent doing:
- Network IO while waiting for a response from the database.
- Copying buffers back and forth.
- Marshalling objects (serializing and deserializing JSON)1.
The 99.99th percentile request(s) in Kotlin is most likely due to the cold start of the application in the JVM. Classes needs to be loaded, initial connections to the database must be made, perhaps some JIT2 related features spring into action and a bunch of other things. Typically in real applications the healthcheck endpoint issues a database requests that checks the database connection with the added benefit of initializing the connection pool to the database before accepting any requests. We could improve upon the Kotlin (and Rust) service to include this functionality if request latency is an issue.
So far so good. Considering servers typically run for extended periods of time I'm not too worried about the very first request being slow unless there's an SLA promising faster response times. If we were developing this application for serverless functions (AWS Lambda for example) the latency would be a dealbreaker for Kotlin (on the JVM)3. There are methods of avoiding long cold starts for AWS Lambda using SnapStart but it does have some quirks.
CPU Usage
Now this is the interesting part. We can see the CPU usage shoot up at the very start of the Kotlin application while the CPU usage of the Rust application is increasing much more evenly. The JVM does a bunch of things at the very start of the application (which we covered briefly in the previous section) resulting in these CPU spikes. The smaller spikes that occur regularly throughout the applications lifetime is most likely garbage collection events or JIT compilation.
By simply eyeballing the chart we can see that the baseline memory usage appears to converge around 42% for Kotlin and 23% for Rust, making the Kotlin server about 82% more CPU intensive compared to the Rust server in this scenario.
CPU time is the most valuable resource today from my experience. RAM is comparably cheap and available while CPU time gets throttled and is usually virtualized and shared among other tenants. If you've been unsure of whether it's worth investing in Rust before, maybe this is convincing enough if you're looking into reducing costs.
Memory Usage
Yet again I'll eyeball the graph to conjure an estimate. We can see that the Kotlin application stabilizes at around 214MB of memory usage while the Rust application appears to be fine running on fumes, clocking in at about 18.4MB of memory for 500 active users. Meaning that the Kotlin application uses about 1163% more memory than the Rust application. That's... quite incredible, but not too surprising.
Only about 47MB of the memory belongs to the Committed Heap Space memory of which only 20.4MB is actually in use by the application. The JVM is a heavy machine that uses a lot of baseline memory, meaning that, if we doubled the amount of active users we won't double the memory usage. We'll make that comparison in the next test.
Either way, it's quite clear that Rust is vastly more memory efficient than Kotlin in this case (and, to be honest, probably all other cases too). However, memory is quite cheap, and it might not matter for your use case, but it's worth keeping in mind.
Let's move on to the next test.
Benchmark #2 - 1,000 users
Let's double the amount of users and keep all other configurations the same.
Notice that I've also doubled the spawn rate so that we keep the acceleration of users uniform.
We now have 1k users spawning at a rate of 20 per second. Let's have a look at the results.
Request Latency
The latencies are more even this time, probably because this test issues more requests overall which evens out the distribution across all percentiles, pushing the requests affected by the JVM cold start to the higher percentiles.
CPU Usage
Looks like Kotlin stabilizes around 80% and Rust around 42% CPU usage, increasing the gap compared to the previous test. To summarize, Kotlin increased CPU consumption by ~90% and Rust increased by ~82% when doubling the amount of active users from 500 to 1.000 users, suggesting that Rust is better at managing CPU resources when scaling up.
Memory Usage
Now Kotlin stabilizes around 224MB and Rust at about 29.2MB. Notice that the Kotlin application didn't change much, from 214MB to 224MB, increasing memory usage by 10MB (roughly 4.6%). The JVM committed 59.2MB (+12.2MB) memory out of which 28.3MB (+7.9MB) was in use.
Rust increased memory usage from 18.4MB to 29.2MB, an increase of 10.8MB (roughly 58.6%) but still uses 87% less memory than the Kotlin application.
The Kotlin application only increased memory usage by 4.6% when doubling the load, showing that the baseline memory usage of the JVM is quite high.
If you're running multiple JVMs on the same machine it might be worth looking into enabling -Xshare:auto. Enabling sharing of class data can help keep the
overall memory footprint down by allowing multiple JVMs to share class data with each other, reducing duplication. Additionally, if you're allocating and keeping a lot of string objects around, you can experiment with the -XX:+UseStringDeduplication flag as well. The garbage collector will identify strings that are identical and internally re-reference the pointers to the same memory address. This is different from the statically allocated strings that end up in the String Pool.
Benchmark #3 - Alpine 1,000 users
Request Latency
Request latencies are pretty much identical, no change here.
CPU Usage
CPU Usage remains mostly the same, no real difference to make note of.
Memory Usage
Here's the interesting part. Notice how both Kotlin and Rust uses less memory now that we're running in an Alpine Linux container? Kotlin evens out at 187.9MB and Rust at about 25.2MB which means that Kotlin has reduced memory usage by 36.1MB and Rust reduced memory usage by 4MB. But why is that? We've only changed the base image from Debian to Alpine, why does the applications themselves use less memory?
Well, Alpine does not support glibc which means that, for Rust, we're compiling towards the musl target instead of glibc. Similarly, it doesn't matter that we compiled Kotlin in the Alpine container, but rather that we're running the application with a JVM compiled with musl instead of glibc. The eclipse-temurin:21-alpine-3.22 base image downloads a JVM that's compiled with musl instead of the standard glibc.
So what's musl and glibc? glibc is the more mature libc implementation and is commonly available on most Unix systems. However, musl is an a modern alternative that is more lightweight than glibc. This is the reason why simply switching the container base image can give us even lower memory usage.
I thought it was an interesting addition to mention the musl target, both for the JVM and Rust to display some of their differences. Choose whatever works best for your use case, but be mindful of the differences. Musl used to have DNS issues which should since have been fixed, but keep in mind that there are many articles online that mention the pain of debugging DNS query issues in Kuberenetes clusters4, particularily with Alpine containers.
Conclusion
🦀 Should I all-in Rust?
I thought this was a fun exercise to compare a language that's designed to be extremely fast and efficient with a language/platform that's more focused on ergonomics. I'm not going to tell you that Rust is the only correct choice now and always, but I think it's important to bring these differences to light so that we can make decisions based on data. Feel free to clone and modify the code to adapt it to your use case. There's an accompanying notebook you can run for quick feedback loops.
Raw performance isn't everything. Choosing a technology depends a lot on the skills you already have available in your company, or on the job market. Rust is gaining traction incredibly quickly and great improvements are constantly being made to improve the ergonomics of the language, but I still feel a bit cornered by the many rules the compiler enforces on you when trying to quickly iterate on some new feature or functionality. Kotlin feels quicker when writing code but Rust is quicker running it, at least in my experience. I feel that this is especially true when writing tests, which I often have to shoehorn in, or trade static impls for dynamic dispatch to get some mocks working. I'm sure this too will improve in the future though, and I'm by no means a Rust expert so YMMV.
🌳 The Environmental Aspect
One aspect that I think is often overlooked, especially in corporations that have many thousands of different services running hundreds- to thousands of instances starting to think about efficiency doesn't just affect your bottom line, but also reduces the carbon footprint. It's even more important today considering the explosion of data processing that's happened since LLMs took over the scene.
Footnotes
-
In Rust you annotate a struct with
#[derive(serde::Serialize, serde::Deserialize)]which is a macro that generates the code needed to for (de)serialization during the macro expansion phase (before compilation). There's no need for runtime reflection. Super cool feature. ↩ -
I'm referring to the Just in Time (JIT) compilation that the JVM does on hot code paths. There's a lot of juicy information on OpenJDK if you want to read more. ↩
-
Kotlin/Native compiles to native code (with a garbage collector) similar to Golang. I've not investigated its performance as the ecosystem of libraries is quite limited because you can't tap into the Java ecosystem. However, I think their efforts are focused on cross platform mobile development with Compose Multiplatform which is really promising. ↩
-
The musl DNS issue discussed in multiple places theregister, martinheinz, Hacker News ↩