I spent a bit of time optimizing AnchorURL to make it 50% faster with various optimizations, while extending it with the Google Safe Browsing integration from my previous blog post. AnchorURL allows you to create a permanent link where the edit secret can be shared as a token; I described all of it in a previous blog post. One thing I left as a teaser was that the project was not heavily optimized yet and I wanted to go back to it. I took a benchmark-driven approach, where I benchmarked all changes, and this blog post shares those learnings. Overall the benchmarks show a 41% - 58% improvement depending on the scenario, with my production host expected to handle 67k - 73k requests per second, up from the previous 45k - 48k requests per second.
With the great strides forward in LLMs this year, I have begun using them more heavily. However, when it comes to side projects, my main goal is learning, so making AI write everything is kind of counterproductive. I saw a great video by Theo Browne where he explains a different way of using AI. Instead of using it to create the main code, use it to generate a ton of helper code that allows you to ensure the code you write is correct. So I used Fable 5 to help create a benchmark and run benchmarks while I was doing other stuff. This made it really fun and interesting to develop as I could write some code, ask it to bench it and come back to a result later.
The benchmarks were quite simple, spread over a few different read-heavy scenarios, a more pessimistic write scenario and a pure write scenario. Using these scenarios I could check each change to verify what it actually improved or did not improve. It also made a few of what I thought would be winning optimizations obsolete, since they did not actually yield any improvement. For example, trying to leave the logging to an async task that ran outside the main hot loop turned out to decrease performance instead of improving it.
The map structure
The main structure of AnchorURL is the map that stores the links. This was previously just a HashMap with a RwLock (Read-write lock) around it, which allows for multiple reads or a single write. My main theory here was that it was best to replace it with either dashmap or an scc map. My results were that dashmap beats scc in every test on a 2-core setup, which is also corroborated by dashmap’s performance results. However, pulling in dashmap as a dependency also brought in other dependencies. Instead, I chose to borrow some of their design by sharding the HashMap and changing the hash to foldhash. This change was comparable to dashmap, with around a 47% - 66% improvement for the benchmarks with writes. However, this did not have any noticeable effect on read-heavy benches.
Another change that helped was moving the write lock to a combination of the main map and a secondary write mutex sharded in the same way as the HashMap. The Mutex was then used to write files, before taking the main lock and changing the map. This meant that the write could take the mutex and write the file, which can take a few milliseconds, while the main map could still be used for reads, allowing more throughput.
Google Safe Browsing Integration impact
In my previous blog post, I described how AnchorURL had been flagged as malicious and I had to create a Google Safe Browsing integration to ensure no malicious links were created. The impact turned out to be nothing due to some design decisions. First, the local list that is checked against is a simple lookup of the URL hash in a set, which costs nothing. It is only when a link is determined to possibly be malicious that it has an impact, because then it has to be looked up either from the local cache or from the Google Safe Browsing API.
By having the check outside the lock structure, the links can be checked without blocking other traffic. Essentially, the integration as it exists does not negatively impact the performance.
Smaller changes
Some more optimizations I did:
- 30% - 40% was saved in redirect scenarios by hand-rolling a non-blocking logger.
- 8% - 11% was saved by tuning the compilation to
lto = "fat",codegen-units = 1,panic = "abort". However, this also drastically increased compilation times on release builds from 1 minute to 4 minutes. - 5% - 13% was saved in scenarios where the
Ignorequery parameters option was chosen by using a raw query string instead of parsing it. - A 4% increase in throughput was gained by only registering the request body limit and rate limit checker on the mutation requests.
- 5% was saved by moving the file saving to
spawn_blockinginstead of blocking for each call in the async task.
Some changes only improved a certain scenario, while other changes improved across the board. These were the interesting ones, but I also made multiple minor ones not worth mentioning.
Conclusion
I am now happy with where AnchorURL is. It is accessible again and performs quite well. Even with network traffic the site responds in 9-25ms for me. This is incredibly fast compared to what most websites do. I have set up logging to be informed if the project begins having any issues, but I consider the project done for now. If you need a link creation and redirection service, give AnchorURL a try.