In the world of software development, ‘clean code’ is often treated as the holy grail. We’re taught to write small, focused functions, use interfaces, and avoid ‘premature optimization.’ But what if these well-intentioned practices are secretly sabotaging your application’s speed? In 2023, game developer Casey Muratori sparked a heated debate with his essay ‘Clean Code, Horrible Performance,’ showing how ‘clean’ abstractions can make code 2 to 10 times slower—or worse—even with modern optimizing compilers. This isn’t just a niche concern for game developers; it’s a wake-up call for anyone building performance-sensitive software, from web services to mobile apps.
In this article, we’ll break down Muratori’s core argument, explore why ‘clean’ code can be so costly, and offer a balanced perspective on when to prioritize performance over purity. You’ll learn why a straightforward, ‘ugly’ implementation often beats a beautifully abstracted one, and how to make informed trade-offs in your own projects.
The Tale of Two Codebases
Imagine you’re building a simple game where you have a bunch of entities—say, spaceships—that need to update their positions each frame. You have two ways to write this.
The ‘Clean’ Way:
– Define an Entity interface with a virtual update() method.
– Create separate classes like Spaceship, Asteroid, etc., each implementing update().
– Use dependency injection to pass in a PhysicsEngine to each entity.
– Wrap all data in getters and setters to ‘protect’ it.
The ‘Ugly’ Way:
– Use a simple struct with plain public fields.
– Write a single function that loops over an array of these structs and updates them directly.
Muratori’s article demonstrates that the ‘clean’ version can be 2 to 10 times slower than the ‘ugly’ one. Why? Because the ‘clean’ abstractions—virtual functions, interfaces, getters/setters—create layers of indirection that the compiler can’t see through. It can’t inline the virtual call, it can’t vectorize the loop because it doesn’t know which concrete type it’s dealing with, and it can’t eliminate dead code because the interface hides what’s actually happening.
The Compiler’s Dilemma
To understand why ‘clean’ code is slow, you need to understand how modern compilers optimize. Compilers like GCC, Clang, and MSVC are incredibly good at optimizing simple, flat code. They can inline small functions, vectorize loops to use SIMD instructions, and eliminate unused code—all in milliseconds. But they’re also incredibly fragile. The moment you introduce a virtual function, a function pointer, or an opaque interface, the compiler’s ability to optimize plummets.
Here’s a concrete analogy: think of the compiler as a chef who can prepare a meal much faster if they know exactly what ingredients they have. If you hand them a sealed box (an interface) and say ‘cook whatever’s inside,’ they have to open it, inspect it, and then decide—that takes time. But if you hand them a clear container with labeled ingredients (a plain struct), they can start cooking immediately, even combining steps.
In code, a virtual function call means the compiler doesn’t know which function will be called at runtime. It has to look up the function pointer in a vtable, which prevents inlining. Inlining is crucial for performance because it eliminates the overhead of a function call and allows further optimizations across function boundaries. Without inlining, every call has overhead, and the compiler can’t optimize the code inside the function in the context of the caller.
Similarly, getters and setters—even if they’re inline—can prevent the compiler from seeing the actual data layout. If you have a class with private fields and only access them via getters, the compiler might not be able to reorder or combine operations as effectively as it could with direct field access.
The Real-World Impact
You might be thinking, ‘So what? My app isn’t a game. It’s a CRUD app that’s mostly waiting on database queries.’ That’s a fair point. For I/O-bound applications, CPU performance is rarely the bottleneck. But consider these scenarios:
- Cloud Computing Costs: If your service is CPU-bound, a 10x performance hit means you need 10x more servers to handle the same load. That’s a direct financial cost. In 2023, with cloud costs rising, this is a serious concern.
- Real-Time Systems: In game development, frame rate is a product feature. A slow update loop means dropped frames, which players notice immediately. Muratori’s example is from a game, but the same applies to VR, AR, and any real-time simulation.
- Data Processing: If you’re processing large datasets—like in machine learning or analytics—a 2x slowdown can turn a 10-minute job into a 20-minute one. Multiply that by thousands of jobs, and you’ve got a significant efficiency loss.
Even in web development, JavaScript engines like V8 are excellent at optimizing hot paths, but they too struggle with dynamic dispatch and heavy abstraction. So the principles apply beyond C++.
The ‘Clean Code’ Defense
Now, let’s play devil’s advocate. Defenders of clean code argue that performance isn’t everything. Maintainability, team velocity, and bug reduction often matter more. They point out that Muratori’s examples are cherry-picked—they’re CPU-bound loops where performance is critical, but most business applications are I/O-bound. They also cite Knuth’s famous quote: ‘Premature optimization is the root of all evil.’
There’s truth to this. If you’re building a simple CRUD app, obsessing over micro-optimizations is a waste of time. But the counter-argument is that ‘clean’ code often isn’t just ‘not optimized’—it’s actively slow. And the problem is that once you’ve built a system with layers of abstraction, it’s very hard to optimize later without a major refactor. So the choice isn’t ‘optimize now’ vs. ‘optimize later’; it’s ‘write simple, fast code now’ vs. ‘write abstract, slow code and hope you can fix it later.’
The Middle Ground: Know Your Hot Paths
The real lesson from Muratori’s article isn’t ‘clean code is bad’ or ‘performance is everything.’ It’s that you need to know your performance budget. Not every line of code needs to be optimized, but the hot paths—the code that runs most frequently—deserve special attention.
A practical approach is to:
- Measure first. Use a profiler to find where your code spends most of its time. Don’t guess.
- Write simple, flat code for hot paths. Avoid virtual functions, interfaces, and excessive abstraction in loops that run millions of times.
- Use ‘clean’ practices where they matter. Naming, formatting, and small functions are great for readability and don’t hurt performance. The problem is unnecessary abstraction, not cleanliness per se.
- Consider data-oriented design. Instead of thinking in terms of objects, think in terms of data and how it’s laid out in memory. This often leads to better cache locality and vectorization.
A Concrete Example
Let’s look at a simplified version of Muratori’s example to make it concrete. Suppose you have a Particle class with a virtual update() method:
“`cpp
class Particle {
public:
virtual void update(float dt) = 0;
};
class MovingParticle : public Particle {
public:
void update(float dt) override {
x += vx * dt;
y += vy * dt;
}
private:
float x, y, vx, vy;
};
“`
Now, if you have a vector of Particle* and call update() on each, the compiler can’t inline the call because it doesn’t know the concrete type. It has to do a vtable lookup for each particle.
In contrast, a data-oriented approach might look like:
“`cpp
struct Particle {
float x, y, vx, vy;
};
void updateParticles(Particle* particles, int count, float dt) {
for (int i = 0; i < count; ++i) {
particles[i].x += particles[i].vx * dt;
particles[i].y += particles[i].vy * dt;
}
}
“`
This version is trivially vectorizable—the compiler can use SIMD to process multiple particles at once. It’s also cache-friendly because the data is contiguous. The result is a massive speedup, often 2-10x or more.
The Bigger Picture
Muratori’s article is part of a larger movement toward performance-aware programming, championed by people like Mike Acton and the data-oriented design community. They argue that the way we teach software engineering—with a focus on OOP and abstraction—is fundamentally at odds with how modern CPUs work. CPUs are optimized for sequential, predictable access patterns, not for jumping through pointers.
This doesn’t mean OOP is useless. It’s great for organizing large codebases and modeling complex domains. But it’s a tool, not a universal solution. For performance-critical code, you need to think about data layout, cache behavior, and compiler optimizations.
In 2023, this debate is more relevant than ever. With the rise of AI code generation, ‘clean’ patterns are often auto-generated, potentially baking in performance issues. And with cloud costs soaring, performance is a financial issue, not just a technical one.
So, what should you do? Start by measuring your code. Find your hot paths. And don’t be afraid to write ‘ugly’ code if it’s fast. Your users—and your wallet—will thank you.
The ‘clean code vs. performance’ debate isn’t about choosing one over the other. It’s about being intentional. Clean code is valuable for maintainability, but it shouldn’t come at the cost of 10x performance losses in critical sections. By understanding how compilers work and where your hot paths are, you can make informed trade-offs. Write clean code where it matters, and write fast, simple code where it counts. Measure, optimize, and repeat.
Summary
- ‘Clean’ code practices like virtual functions, interfaces, and getters/setters can make code 2-10x slower by preventing compiler optimizations like inlining and vectorization.
- Modern compilers excel at optimizing simple, flat code but fail with layers of indirection.
- Performance matters not just for games but for cloud costs, real-time systems, and data processing.
- The solution is to know your hot paths, measure performance, and use data-oriented design for critical loops.
- The debate is not about rejecting clean code entirely but about avoiding unnecessary abstraction in performance-sensitive areas.
FAQ
Q: Does this mean I should never use interfaces or virtual functions?
A: No. Interfaces and virtual functions are useful for extensibility and decoupling. The key is to avoid them in hot paths—code that runs frequently, like loops and update methods. Use them for high-level architecture, but keep the low-level performance-critical code simple and concrete.
Q: Is this only a C++ problem?
A: No. Any language with virtual dispatch or dynamic typing (Java, C#, Python, JavaScript) suffers similar issues. Even JIT-compiled languages like Java can struggle with megamorphic call sites. The principles apply broadly.
Q: What is data-oriented design?
A: Data-oriented design is an approach that focuses on how data is laid out in memory and accessed, rather than on objects and their relationships. It emphasizes contiguous arrays, cache locality, and batch processing, which often leads to better performance.
Q: How do I know if my code is performance-critical?
A: Use a profiler. Identify functions that consume a significant portion of CPU time or are called millions of times. These are your hot paths. Optimize those, and leave the rest as clean as you like.
Q: Can’t I just optimize later?
A: You can, but it’s often harder. Refactoring a heavily abstracted system to be fast can require significant rewrites. It’s better to write simple, fast code from the start for hot paths, and add abstraction only where it doesn’t hurt performance.
Leave a Reply