The goal of this analysis is to try to understand what kind of gains a programmer should expect by using Rust and actix-web rather than Node and Express under typical use, without custom optimizations.
- Compare performance, stability and running cost of simple microservice-like workload on node/expressJS Vs actix. Setup is limited by 1 CPU Core and using a common pattern of development for each.
- Actix provides 85% running cost saving in a heavily loaded environments, while also providing smaller memory footprint and more runtime safety warranties. It also has an option to scale on CPU with minimum memory expense which unveils more saving opportunities.
- Rust’s types and concurrent logic validation saved time providing reliable working code after it compiled, Node’s solution required some time to debug and fix issues in runtime as well as explicit type validation.
- Actix is a small, pragmatic, and extremely fast web framework for Rust. According to techempower round 18 report is fastest web application platform in 4 out of 6 categories.
- Code is available on https://github.com/dunnock/actix-node-benchmark
Test scenario and setup
Presume we have microservice which allows its clients to search for tasks where tasks might be assigned to workers. Our simple database model has just two tables WORKER and TASK, and TASK has assignee relation to WORKER:
Let’s compare how application servers behave and check on resource consumption under moderate to high load. Let’s assume that database is not a bottleneck for now (we’ll leave it for different post).
Node.js app is built with express web framework and knex SQL builder library, both are widely popular. Knex claims to use prepared SQL statements under the hood and has embedded pool so it should be comparable to the Rust setup. Main handler:
Actix-web service is based on async-pg example. It uses deadpool-postgres as async connection pool and tokio-pg-mapper for SQL type bindings. Main route handler looks similar to the express one except that type checks on parameters performed based on type definition:
Testing performed on Ubuntu 18 Xeon E5–2660 with 40 cores. Database initialized with randomly generated 100_000 tasks assigned to 1000 workers.
Using wrk we are going to load server with a similar requests over a number of concurrent connections with predefined request. This should suit our case well as our server is not using cache for search.
We’ll need to run multiple tests with measurements of CPU, memory, latency and rps, so automation would pay off. Rust has a rich toolbox for writing CLI automation, including config, structopt and resources monitoring so it was quite easy to automate . Tests which will follow can be executed via:
cargo run --bin benchit --release -- -m -t 30
1. Fetch top 10 tasks
Search will return same first 10 records without “fat” description field, each row data is under 500b. It will be light for SQL server and should provide maximum requests per second. Test will be performed with the following command line under the hood:
wrk -c <concurrency> -d 30s "http://<host>:<port>/tasks"
For comparison of actix-web with nodeJS we will limit actix to 1 Core (e.g. 1 worker) initially, which will execute our web processing code. Deadpool will be configured with 15 connections and will handle database communication in parallel.
WORKERS=1 cargo run --release --bin actix-bench
cd node-bench; npm run start
Looking at the
rps even with a small load actix provides at least 2 times faster responses than node and under heavy load it is 6x faster. If we look at the database utilization actix seems performing 5 times more efficient. If we look at the response times, node.js for
concurrency = 4 provides similar delay (~4ms) as actix with
concurrency = 32, which is 8 times better. From the memory footprint point of view it’s even better, on a long run with 1 million requests memory for actix service was around 26MB, while node.js grew up to 104MB, hello gc!
Conclusion: for loaded systems we will need 6 times less CPU Cores with actix than with node.js which is 85% cost saving.
Actix is super efficient when using multiple CPU Cores: fixed set of worker threads (
workers) minimizes context switches. By default actix starts as many worker threads as there are CPU Cores.
Let’s see how well actix can scale on CPU with 4
workers and compare with 1
worker Actix setup:
WORKERS=4 PG_POOL_MAX_SIZE=30 cargo run --release --bin actix-bench
With 4 workers actix provides about 3 times better response time than actix with 1 worker on a heavy load, also providing 3 times more responses per second. Actix scales well on CPU, keeping memory consumption almost at the same level nevertheless of number of CPU’s occupied. Node can also scale on CPU running new process per CPU, which would duplicate memory usage for every new instance.
Actix can fully utilize cheap 2+ core cloud instances, while Node would require more expensive instances with more memory.
2. Search and fetch tasks with description
This search will return 10 matches by indexed varchar
summary LIKE .. also fetching ~3–5kb
TEXT description field. It definitely will put pressure on SQL server.
wrk -c <concurrency> -d 30s "http://<host>:<port>/tasks?summary=wherever&full=true&limit=10
Test will be heavy on DB hence we will run with 30 connections in the pool.
Quite a surprise! that Node.js is loading SQL server on a level similar to actix, while giving ~7 times worse performance. Probably has something to do with knex.js and the way how it handles prepared statements, though the goal of this post was to compare typical use. This leads to final summary
Actix-web and Rust ecosystem are a good fit for developing efficient web services, requiring ~6 times less CPU power and less memory it would allow significant 75%-95% runtime cost saving with just basic not optimized setup.
The goal of this post was to compare typical use and it does not account for deep fine tuning like node’s GC settings or using low level SQL libraries. There are opportunities to optimize Node.js solution and probably minimize the gap, though it will require significant effort from engineer and from other side same effort might be applied optimizing Rust solution too. Though in the next post I am going to use node-postgres and tokio-postgres directly and see if Node code can get closer to Actix-web.
const tasks = knex.from('tasks').innerJoin('workers as assignee', 'assignee.id', 'tasks.assignee_id' );
Code appeared to work nicely in local environment, though lead to huge SQL queries under the load. Debugging took some time but fix was simple.
I spent significantly less time fixing Rust part as it provided type safety warranties for request and response formats out of the box, types validation for database queries and concurrency safety.
Rust implementation takes more space for types definition, though those also provide very useful checks in runtime. For instance
GetTasksQuery type also validates that all supplied
One more thing to note, Rust server handles data initialization as tokio-postgres supports PSQL
COPY ... FROM BINARY statement, this allows to generate and load 100k records in 20 seconds. Texts are generated randomly with with markov chain built from the tale of the ring of Gyges which would emulate regular text load for search index.
P.S. Some generated descriptions are really funny, like this one
no man can be imagined to be a great proof that a man would ever submit to such an iron nature that he would be mad if he were no longer present