Clean Code Rules Can Cost You 15x in Runtime Performance
Writing "clean" code is a piece of programming advice that is repeated over and over, especially to beginners, to the point where their ears grow calluses. Behind "clean" code is a long list of rules telling you how you should write so that your code remains "clean."
In reality, a large portion of these rules do not affect code runtime. We cannot objectively evaluate these types of rules, and there is no need to do so. However, some so-called "clean" code rules (some of which are even repeatedly emphasized) can be objectively measured because they do affect the runtime behavior of the code.
By sorting and summarizing the "clean" code rules and extracting those that actually affect code structure, we get the following:
- Use polymorphism instead of "if/else" and "switch";
- Code should not know the internal structure of the objects it uses;
- Strictly control the size of functions;
- Functions should do only one thing;
- "DRY" (Don't Repeat Yourself): Don't repeat yourself.
These rules specify very concretely how we should write specific code snippets to keep the code "clean." However, my question is, if we create a piece of code that follows these rules, how does it perform?
To construct code that I believe strictly follows the "Clean Code" principles, I used existing examples included in articles about "clean" code. In other words, I did not write this code; I merely used the example code they provided to evaluate the rules advocated by "clean" code.
Those "Clean" Code Examples We've All Seen
When mentioning examples of "clean" code, you often see code like this:
/* ========================================================================
LISTING 22
======================================================================== */
class shape_base
{
public:
shape_base() {}
virtual f32 Area() = 0;
};
class square : public shape_base
{
public:
square(f32 SideInit) : Side(SideInit) {}
virtual f32 Area() {return Side*Side;}
private:
f32 Side;
};
class rectangle : public shape_base
{
public:
rectangle(f32 WidthInit, f32 HeightInit) : Width(WidthInit), Height(HeightInit) {}
virtual f32 Area() {return Width*Height;}
private:
f32 Width, Height;
};
class triangle : public shape_base
{
public:
triangle(f32 BaseInit, f32 HeightInit) : Base(BaseInit), Height(HeightInit) {}
virtual f32 Area() {return 0.5f*Base*Height;}
private:
f32 Base, Height;
};
class circle : public shape_base
{
public:
circle(f32 RadiusInit) : Radius(RadiusInit) {}
virtual f32 Area() {return Pi32*Radius*Radius;}
private:
f32 Radius;
};
This code is a base class for shapes, from which some specific shapes are derived: circle, triangle, rectangle, square. Additionally, there is a virtual function for calculating area.
Just as the rules require, we lean towards polymorphism, functions do only one thing, and they are small. Ultimately, we get a "clean" class hierarchy where each derived class knows how to calculate its own area and stores the data required for that calculation.
If we imagine using this hierarchy to do something, like calculating the total area of a series of shapes, we would expect to see code like this:
/* ========================================================================
LISTING 23
======================================================================== */
f32 TotalAreaVTBL(u32 ShapeCount, shape_base **Shapes)
{
f32 Accum = 0.0f;
for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
{
Accum += Shapes[ShapeIndex]->Area();
}
return Accum;
}
You might notice that I did not use any iterators here, because the "Clean Code" principles do not suggest you must use iterators. Therefore, I wanted to avoid any writing style that might compromise "clean" code as much as possible. I did not want to add any abstract iterators that could confuse the compiler and lead to performance degradation.
Furthermore, you might also notice that this loop operates on an array of pointers. This is a direct consequence of using a class hierarchy: we don't know how much memory each shape occupies. So unless we add another virtual function call to get the data size of each shape and use some variable-stride jumping process to traverse them, we need pointers to find out where each shape actually starts.
Because this calculation is an accumulation sum, the dependency caused by the loop itself might slow down the loop. Since the accumulation can be done in any order, to be safe, I also wrote a manually unrolled version:
/* ========================================================================
LISTING 24
======================================================================== */
f32 TotalAreaVTBL4(u32 ShapeCount, shape_base **Shapes)
{
f32 Accum0 = 0.0f;
f32 Accum1 = 0.0f;
f32 Accum2 = 0.0f;
f32 Accum3 = 0.0f;
u32 Count = ShapeCount/4;
while(Count--)
{
Accum0 += Shapes[0]->Area();
Accum1 += Shapes[1]->Area();
Accum2 += Shapes[2]->Area();
Accum3 += Shapes[3]->Area();
Shapes += 4;
}
f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
return Result;
}
Running these two routines in a simple test harness gives a rough estimate of the total cycles per shape required to perform this operation:
Image
The test harness times the code in two different ways. The first method is to run the code only once, to show the runtime in a non-warmed-up state (in this state, the data should be in L3, but L2 and L1 have been flushed, and the branch predictor has not yet predicted for the loop).
The second method is to run the code repeatedly, to see what happens when the caches and branch predictor are running in the most favorable way for the loop. Please note, these are not rigorous measurements, because as you can see, we have already observed huge differences, and no rigorous analysis tools are needed at all.
From the results, we can see that there is not much difference between these two routines. This "clean" code takes about 35 cycles to calculate the area of this shape, and if we are lucky, it might be reduced to 34 cycles.
So, we strictly followed "Clean Code," and it ended up requiring 35 cycles.
After Violating the First Rule of "Clean Code"
So, what happens if we violate the first rule? What if we don't use polymorphism, but use a switch statement instead?
Below, I wrote another piece of code that does exactly the same thing, except this time I did not use a class hierarchy, but used an enum, flattening everything into a single structure for the shape type:
/* ========================================================================
LISTING 25
======================================================================== */
enum shape_type : u32
{
Shape_Square,
Shape_Rectangle,
Shape_Triangle,
Shape_Circle,
Shape_Count,
};
struct shape_union
{
shape_type Type;
f32 Width;
f32 Height;
};
f32 GetAreaSwitch(shape_union Shape)
{
f32 Result = 0.0f;
switch(Shape.Type)
{
case Shape_Square: {Result = Shape.Width*Shape.Width;} break;
case Shape_Rectangle: {Result = Shape.Width*Shape.Height;} break;
case Shape_Triangle: {Result = 0.5f*Shape.Width*Shape.Height;} break;
case Shape_Circle: {Result = Pi32*Shape.Width*Shape.Width;} break;
case Shape_Count: {} break;
}
return Result;
}
This is the common "old-school" way of writing code before the Clean Code movement.
Note that since we don't have a specific data type for each shape, if a type lacks one of the values (like "Height"), the calculation simply doesn't use it.
Now, the user of this structure no longer needs to call a virtual function to get the area; instead, they need to use a function with a switch statement, which violates "Clean Code." Even so, you'll notice the code is more concise, but the functionality is essentially the same. Each case of the switch statement corresponds to a virtual function in the class hierarchy.
For the summation loop itself, you can see this code is almost identical to the "clean" version above:
/* ========================================================================
LISTING 26
======================================================================== */
f32 TotalAreaSwitch(u32 ShapeCount, shape_union *Shapes)
{
f32 Accum = 0.0f;
for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
{
Accum += GetAreaSwitch(Shapes[ShapeIndex]);
}
return Accum;
}
f32 TotalAreaSwitch4(u32 ShapeCount, shape_union *Shapes)
{
f32 Accum0 = 0.0f;
f32 Accum1 = 0.0f;
f32 Accum2 = 0.0f;
f32 Accum3 = 0.0f;
ShapeCount /= 4;
while(ShapeCount--)
{
Accum0 += GetAreaSwitch(Shapes[0]);
Accum1 += GetAreaSwitch(Shapes[1]);
Accum2 += GetAreaSwitch(Shapes[2]);
Accum3 += GetAreaSwitch(Shapes[3]);
Shapes += 4;
}
f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
return Result;
}
The only difference is that we call a regular function to get the area.
But we have already seen the direct benefit of using a flat structure compared to a class hierarchy: shapes can be stored in an array, without needing pointers. No indirection is needed because all shapes occupy the same amount of memory.
Additionally, we get an extra benefit: the compiler can now see exactly what we are doing in this loop, because it only needs to look at the GetAreaSwitch function. It doesn't have to assume that it can only see what some virtual area function does at runtime.
So, what can the compiler do for us with these benefits? Let's run the area calculation for all four shapes completely, and the results are as follows:
Image
Observing the results, we can see that by switching to the "old-school" writing style, the code's performance immediately improved by 1.5 times. We did nothing else; just removing the code that used C++ polymorphism yielded a 1.5x performance improvement.
Violating the first rule of Clean Code (one of its core principles) reduced the number of cycles per area calculation from 35 to 24. This means that following Clean Code causes the code to run 1.5 times slower. To use a mobile phone analogy, it's like swapping an iPhone 14 Pro Max for an iPhone 11 Pro Max. The hardware advancements of the past three or four years are instantly nullified, simply because someone said to use polymorphism and not switch statements.
However, this is just the beginning.
After Violating More Rules of "Clean Code"
What happens if we violate more rules? What if we break the second rule, "no internal knowledge"? What if our function can leverage knowledge of its own actual operation to improve efficiency?
Looking back at the switch statement for calculating area, you'll notice all the area calculations are quite similar:
case Shape_Square: {Result = Shape.Width*Shape.Width;} break;
case Shape_Rectangle: {Result = Shape.Width*Shape.Height;} break;
case Shape_Triangle: {Result = 0.5f*Shape.Width*Shape.Height;} break;
case Shape_Circle: {Result = Pi32*Shape.Width*Shape.Width;} break;
All shape area calculations involve multiplication: length times width, width times height, or multiplied by a coefficient like π, etc. It's just that the triangle's area needs to be multiplied by 1/2, and the circle's area needs to be multiplied by π.
This is one of the reasons I think using a switch statement here is very appropriate, even though it goes against Clean Code. Through the switch statement, we can see this pattern very clearly. When you organize code by operation rather than by type, it's easy to observe and extract common patterns. In contrast, looking at the class version, you might never discover this pattern, because the class version not only has a lot of boilerplate code, but you also need to put each class in a separate file, making side-by-side comparison impossible.
So, from an architectural perspective, I generally disapprove of class hierarchies, but that's not the point. What I want to say is that we can greatly simplify the switch statement through the pattern we discovered above.
Remember: this is not an example I chose; it's the example used by clean code advocates for illustration. So, I didn't deliberately pick an example where a pattern happens to be extractable. Therefore, this phenomenon should be quite common, because most similar types have similar algorithmic structures, just like this example.
To leverage this pattern, first we can introduce a simple table indicating which coefficient to use for each type's area calculation. Secondly, for shapes like circles and squares that only need one parameter (radius for circle, side length for square), we can consider their length and width to be coincidentally the same, allowing us to create a very simple function for calculating area:
/* ========================================================================
LISTING 27
======================================================================== */
f32 const CTable[Shape_Count] = {1.0f, 1.0f, 0.5f, Pi32};
f32 GetAreaUnion(shape_union Shape)
{
f32 Result = CTable[Shape.Type]*Shape.Width*Shape.Height;
return Result;
}
The two summation loops for this version are exactly the same, no modification needed. We just need to replace GetAreaSwitch with GetAreaUnion, and the rest of the code remains unchanged.
Let's see the effect of using this new version:
Image
We can see that switching from a type-based mindset to a function-based mindset gave us a huge speed boost. Going from the switch statement (which was already 1.5x faster than the clean code version) to the table-driven version resulted in a full 10x speed improvement.
We just added a table lookup and one line of code, that's it! Now not only does the code run significantly faster, but the semantic complexity is also significantly reduced. Fewer tokens, fewer operations, less code.
By fusing the data model with the required operations, the number of cycles per area calculation dropped to 3.0–3.5 cycles. Compared to the code following the first two rules of Clean Code, this version is 10 times faster.
A 10x performance improvement is so huge that I can't even use an iPhone analogy. Even the iPhone 6 (the oldest phone in modern benchmarks) is only about 3 times slower than the latest iPhone 14 Pro Max.
If we're talking about single-threaded desktop performance, a 10x speed improvement is equivalent to today's CPUs regressing to the year 2010. The first two rules of Clean Code erase 12 years of hardware development.
However, this test was just a very simple operation. We haven't yet explored "functions should do only one thing" and "keep them as small as possible." What happens if we adjust the problem and fully follow these rules?
The following code has the exact same hierarchy, but this time I added a virtual function to get the number of corners for each shape:
/* ========================================================================
LISTING 32
======================================================================== */
class shape_base
{
public:
shape_base() {}
virtual f32 Area() = 0;
virtual u32 CornerCount() = 0;
};
class square : public shape_base
{
public:
square(f32 SideInit) : Side(SideInit) {}
virtual f32 Area() {return Side*Side;}
virtual u32 CornerCount() {return 4;}
private:
f32 Side;
};
class rectangle : public shape_base
{
public:
rectangle(f32 WidthInit, f32 HeightInit) : Width(WidthInit), Height(HeightInit) {}
virtual f32 Area() {return Width*Height;}
virtual u32 CornerCount() {return 4;}
private:
f32 Width, Height;
};
class triangle : public shape_base
{
public:
triangle(f32 BaseInit, f32 HeightInit) : Base(BaseInit), Height(HeightInit) {}
virtual f32 Area() {return 0.5f*Base*Height;}
virtual u32 CornerCount() {return 3;}
private:
f32 Base, Height;
};
class circle : public shape_base
{
public:
circle(f32 RadiusInit) : Radius(RadiusInit) {}
virtual f32 Area() {return Pi32*Radius*Radius;}
virtual u32 CornerCount() {return 0;}
private:
f32 Radius;
};
A rectangle has 4 corners, a triangle has 3, and a circle has 0. Next, let's modify the problem definition. The original problem was to calculate the sum of the areas of a series of shapes; we'll change it to calculate the corner-weighted sum of areas: the total sum of areas multiplied by the number of corners. Of course, this is just an example; you wouldn't encounter this in real work.
Now, let's update the "clean" summation loop. We need to add the necessary math and one more virtual function call:
f32 CornerAreaVTBL(u32 ShapeCount, shape_base **Shapes)
{
f32 Accum = 0.0f;
for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
{
Accum += (1.0f / (1.0f + (f32)Shapes[ShapeIndex]->CornerCount())) * Shapes[ShapeIndex]->Area();
}
return Accum;
}
f32 CornerAreaVTBL4(u32 ShapeCount, shape_base **Shapes)
{
f32 Accum0 = 0.0f;
f32 Accum1 = 0.0f;
f32 Accum2 = 0.0f;
f32 Accum3 = 0.0f;
u32 Count = ShapeCount/4;
while(Count--)
{
Accum0 += (1.0f / (1.0f + (f32)Shapes[0]->CornerCount())) * Shapes[0]->Area();
Accum1 += (1.0f / (1.0f + (f32)Shapes[1]->CornerCount())) * Shapes[1]->Area();
Accum2 += (1.0f / (1.0f + (f32)Shapes[2]->CornerCount())) * Shapes[2]->Area();
Accum3 += (1.0f / (1.0f + (f32)Shapes[3]->CornerCount())) * Shapes[3]->Area();
Shapes += 4;
}
f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
return Result;
}
Actually, I should have written a separate function, adding another layer of indirection. To ensure the principle of innocent until proven guilty for "clean" code, I explicitly kept this code as is.
The switch statement version also needs the same modifications. First, let's add another switch statement to handle the number of corners, with case statements exactly matching the hierarchy version:
/* ========================================================================
LISTING 34
======================================================================== */
u32 GetCornerCountSwitch(shape_type Type)
{
u32 Result = 0;
switch(Type)
{
case Shape_Square: {Result = 4;} break;
case Shape_Rectangle: {Result = 4;} break;
case Shape_Triangle: {Result = 3;} break;
case Shape_Circle: {Result = 0;} break;
case Shape_Count: {} break;
}
return Result;
}
Next, we calculate the area in the same way:
/* ========================================================================
LISTING 35
======================================================================== */
f32 CornerAreaSwitch(u32 ShapeCount, shape_union *Shapes)
{
f32 Accum = 0.0f;
for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
{
Accum += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[ShapeIndex].Type))) * GetAreaSwitch(Shapes[ShapeIndex]);
}
return Accum;
}
f32 CornerAreaSwitch4(u32 ShapeCount, shape_union *Shapes)
{
f32 Accum0 = 0.0f;
f32 Accum1 = 0.0f;
f32 Accum2 = 0.0f;
f32 Accum3 = 0.0f;
ShapeCount /= 4;
while(ShapeCount--)
{
Accum0 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[0].Type))) * GetAreaSwitch(Shapes[0]);
Accum1 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[1].Type))) * GetAreaSwitch(Shapes[1]);
Accum2 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[2].Type))) * GetAreaSwitch(Shapes[2]);
Accum3 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[3].Type))) * GetAreaSwitch(Shapes[3]);
Shapes += 4;
}
f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
return Result;
}
Just like the version that directly summed the areas, the implementation code for the class hierarchy and the switch statement is almost identical. The only difference is calling a virtual function versus using a switch statement.
Now let's look at the table-driven approach again, and you can see how great the effect of fusing operations and data is. Unlike all other versions, in this version, the only thing that needs modification is the values in the table. We don't need to get secondary information about the shape; we can directly put the number of corners and the area coefficient into the table, while the code remains unchanged:
/* ========================================================================
LISTING 36
======================================================================== */
f32 const CTable[Shape_Count] = {1.0f / (1.0f + 4.0f), 1.0f / (1.0f + 4.0f), 0.5f / (1.0f + 3.0f), Pi32};
f32 GetCornerAreaUnion(shape_union Shape)
{
f32 Result = CTable[Shape.Type]*Shape.Width*Shape.Height;
return Result;
}
Running all the above "corner-weighted area" functions, we can see how much their performance is affected by the second shape property:
Image
As you can see, the performance of the "clean" code is even worse. In the direct area calculation, the switch statement version was 1.5 times faster, and now it's nearly 2 times faster, while the lookup table version is nearly 15 times faster.
This indicates a deeper problem with "clean" code: the more complex the problem, the greater the damage Clean Code does to performance. When you try to extend Clean Code to objects with many properties, the code's performance generally suffers.
The more you use Clean Code practices, the less clear the compiler is about what you are doing. Everything is in separate translation units, behind virtual function calls. No matter how smart the compiler is, it cannot optimize this kind of code.
Even worse, you cannot handle complex logic with this kind of code. As mentioned above, if your codebase is built around functions, simple features like extracting values into a table and removing switch statements are easy to implement. However, if it's built around types, it becomes much harder in practice, and might even be impossible without a massive rewrite.
We just added one property, and the speed difference went from 10x to 15x. This is like 2023 hardware regressing to 2008.
However, you might have noticed that I didn't even mention optimization. Other than ensuring no loop-carried dependencies, for testing purposes, I did no optimization at all!
If I run these routines using a slightly optimized AVX version, the results are as follows:
Image
The speed difference is between 20 and 25 times. Of course, the code without any AVX optimization used principles similar to Clean Code.
Above, we only mentioned 4 principles. What about the fifth?
Honestly, "Don't Repeat Yourself" seems fine. As shown above, we didn't repeat ourselves. You might say the four unrolled versions of the accumulation sum are somewhat repetitive, but that was just for demonstration purposes. In practice, we don't need to keep both routines.
If "Don't Repeat Yourself" has stricter requirements, like not building two different tables to encode versions of the same coefficient, then I would disagree, because sometimes we have to do that to get reasonable performance. But generally, "Don't Repeat Yourself" just means don't write the exact same code twice, so it sounds like reasonable advice.
Most importantly, we don't have to violate it to write code that achieves reasonable performance.
"Following the rules of clean code will make your code run 15 times slower."
Therefore, out of the five suggestions from Clean Code that actually affect code structure, I might only consider one, and forget the other four. Because as you can see, following these suggestions seriously impacts software performance.
Some argue that code written following Clean Code is easier to maintain. However, even if that's true, we have to ask: "At what cost?"
We cannot simply abandon performance to ease the burden on programmers, causing hardware performance to regress by a decade or more. Our job is to write programs that run well on the given hardware. If these rules cause software performance to degrade, that is unacceptable.
Finally, we should try to propose rules of thumb that help keep code organized, easy to maintain, and easy to read. These goals themselves are not problematic, but the rules proposed for them need rethinking. Next time these rules are discussed, I hope to add a note: "Following these rules will make your code run 15 times slower."
Can "Clean" Code and Performance Coexist?
In reality, there is often a gap between ideals and reality. Like neat handwriting, everyone hopes to see clean code, but not everyone can achieve it. However, not being able to achieve it doesn't mean the rules are problematic. On this point, some netizens have started a heated discussion:
Netizen 1:
I think the author applied general advice to a special case.
Violating the first rule of Clean Code (one of its core principles) reduced the number of cycles per area calculation from 35 to 24.
Most modern software spends 99.9% of its time waiting for user input and only 0.1% of its time actually computing. If you are writing a AAA game or high-performance computing software, then of course you can optimize like crazy and get these improvements.
But most of us don't do that. Most developers are just adding the next planned feature. Clean code allows features to be delivered faster, rather than making the CPU do less work.
Netizen 2:
A frequently cited rule of thumb: First make it run, then make it pretty, then make it fast — in that order. That is to say, if your code is clean from the start, performance bottlenecks are easier to find and resolve.
I sometimes wish I had performance problems in my projects, but in reality, performance issues often occur at a higher level, or at the architectural level. For example, an API gateway making too many queries to an SAP server in a loop. This executes billions of lines of code, but the root cause of the performance issue is why the operator clicked many links at once.
Apart from school assignments, I have never encountered a performance problem. But I have encountered many situations where I had to deal with poorly written ("unclean") code and expended a lot of brain cells understanding it.
For this reason, do you think "clean code" and performance are mutually exclusive?