Let’s talk about performance optimization.
What comes to mind when you think of this phrase? By default, we tend to think about making code run faster. When and how should we make code run faster? Let’s look at an example to gain some intuition.
Suppose we have an app that helps us sort, analyze, and aggregate the information in mathematics textbooks. Inside this app we break down the textbooks by unit, chapter, section, topic, and formula. We want to make a page in this app for each of the topics in the textbook, and we want to make a sidebar on this page that shows all of the formulas that might be valuable to know while reviewing this topic. So to find those formulae, we write a method in our `Topic` model.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Topic < ActiveRecord::Base | |
… | |
def applicable_formulas | |
[self, chapter, section, unit, *ancestors].compact.flat_map(&:formula_ids)/code> | |
end | |
end |
The method works. That said, it does so by making several queries to the database.
More queries = slower-running code. How do we reduce the number of queries, and how much faster will the code run?
Easy, tiger. We’re totally gonna get there. But first, let’s talk about whether we should—and how we know.
When is it important to optimize on code performance?
It’s important to couch ‘performant’ in terms of what we’re optimizing.
We started this post by talking about what ‘performant’ means to us. In the default case, as programmers, when we say ‘performant’ we mean how fast the code runs. Why do we care how fast the code runs? Not just for hyuks (hopefully). Instead, the speed of the code translates to something we care about like page load time or job completion time.
Improving performance on pageload/job time might mean trading off performance on developer time. That should factor into our decisions about how to write code.
Two things affect developer time: clarity of code and robustness of code.
Clarity: Is it clear what the code is doing? How long does it take a dev to understand it? Would more performant code that minimizes page load or job completion time increase the amount of time developers spend understanding it?
Robustness: How likely is this code to break? If we trade it for code that takes less time to run, is that code more likely to break? Or is it more inscrutable such that a developer would not notice a flaw in it as easily? If not, and if we miss something, we lose a glut of developer time finding and fixing the bug later.
When we optimize on page load or job time, we’re talking milliseconds or, if it’s egregious, seconds. When we optimize on developer time, we’re often talking minutes or hours—or, not uncommonly, days. Because of this multiplicative difference, I’m optimizing on developer time until page load/job time matters.
When does page load/job time matter? It matters when humans have to wait on our code. The human eye registers about 60 frames per second. We cannot see things happening faster than that. And to do something about it? Even slower. The average human reaction time to a visual stimulus is about a quarter of a second. So if our page changes and our stuff on that page takes less than a quarter of a second to be ready, we’re beating the human. Faster than that, and a human will not notice.*
*Human reactions to audio stimuli are faster (somewhere between 0.15 and 0.20 seconds). But if your app is yelling/honking/dinging at people to get them to do things, you have bigger UX issues than performance.
When a load time or a job time becomes noticeable to testers, then we have reached the threshold where this metric matters. At that point, I address it even if the end result makes the code less dev-legible. But until then, our code isn’t failing to perform to the standard our users need, so I am usually unwilling to make a tradeoff on dev-legibility. Is there a situation in which I’ll make code more performant if it’s already performant enough? Yes: I’ll do it if more performant code is also more dev-legible.
Would rewriting this code to reduce run time increase its developer time?
Here’s the code now:
- [self, chapter, section, unit, *ancestors].compact.flat_map(&:formula_ids)
Here are the queries that this code gives us:
Section Load (5.4ms) SELECT "sections".* FROM "sections" WHERE "sections"."id" = $1 LIMIT 1 [["id", 542]]
Chapter Load (1.8ms) SELECT "chapters".* FROM "chapters" WHERE "chapters"."id" = $1 LIMIT 1 [["id", 163]]
Unit Load (5.0ms) SELECT "units".* FROM "units" INNER JOIN "chapters" ON "units"."id" = "chapters"."unit_id" INNER JOIN "sections" ON "chapters"."id" = "sections"."chapter_id" WHERE "sections"."id" = $1 LIMIT 1 [["id", 542]]
(6.1ms) SELECT "formulas".id FROM "formulas" WHERE "formulas"."topic_id" = $1 [["topic_id", 268]]
(1.0ms) SELECT "formulas".id FROM "formulas" WHERE "formulas"."chapter_id" = $1 [["chapter_id", 163]]
(0.9ms) SELECT "formulas".id FROM "formulas" WHERE "formulas"."section_id" = $1 [["section_id", 542]]
(18.6ms) SELECT "formulas".id FROM "formulas" WHERE "formulas"."unit_id" = $1 [["unit_id", 15]]
I count 7 queries.
Here’s the code optimized to the best of my immediate knowledge:
Formula.where(topic_id: id).or(Formula.where(section_id: section.id)).or(Formula.where(chapter_id: chapter.id)).or(Formula.where(unit: unit.id)).pluck(:id)
Here’s the resulting queries:
Unit Load (0.5ms) SELECT "units".* FROM "units" INNER JOIN "chapter" ON "units"."id" = "chapter"."unit_id" INNER JOIN "sections" ON "chapter"."id" = "sections"."chapter_id" WHERE "sections"."id" = $1 LIMIT 1 [["id", 542]]
(0.8ms) SELECT "formulas"."id" FROM "formulas" WHERE ((("formulas"."rule_id" = $1 OR "formulas"."section_id" = $2) OR "formulas"."chapter_id" = $3) OR "formulas"."unit_id" = $4) [["topic_id", 268], ["section_id", 542], ["chapter_id", 163], ["unit_id", 15]]
2 queries, which would run faster.
I verified the two ActiveRecord Queries on the same topic, and I did get the same result.
So, what do we think of the code optimized on run time?
I find it clearer than what we had before.
How robust is it? Would the second code break where the first code wouldn’t? Doesn’t look like it right away to me (but someone check me on this if I’m missing something).
Conclusion
In my humble view, optimizing this code on run time doesn’t hurt it on developer time, so it’s worth doing. But if the optimization had hurt the code on developer time, I would need verification from the QA team that they did, in fact, have to wait on this page to load before I would change it.
If you like talking shop about app development, you might also like:
Tips for building on graph databases (also exemplified with Ruby and Rails)
Tips about passing information with segues in iOS development (this one is in Swift)
Idiosyncracies and examples from the Numpy API (Numpy is a Python library)
Test-Driven RecyclerViews in Android (the code is Java. This is inexplicably the second most viewed post on this blog, which tells me somebody out there is evangelizing it. Thanks, anonymous evangelist!)
Test-Driven Network Calls in iOS (mostly included because I had fun making a sample app about villains)
A note: I get that some of these posts might not be in languages you write. I’d encourage you to take a look anyway! It’s helpful to have a little bit of intuition about several different languages, and sometimes you’ll get the chance to cross-pollinate the wisdom and practices of one language or community to another one. And that’s a lot of fun! Also, regardless of language, I try to make the posts entertaining with example apps about fruits, villains, and candy.
[…] Performance Optimization: An Example Implemented in Ruby […]