While scrolling through endless videos on YouTube, I often get recommended videos on software development. One of them caught my interest recently: Clean Code, Horrible Performance by Casey Muratori. 1 It’s a well-known video with considerable controversy, and I wanted to dive into the topic.
As many developers before me, I read Clean Code by “Uncle Bob” Robert C. Martin. It is a book on best practices; how does one write code such that it is maintainable and “clean”. It discusses things like encapsulation, SOLID principles in OOP, etc.
Casey’s video is a rebuttal on some of the techniques typical in “clean” object oriented code, on how the abstraction can hurt performance in C++. Good to note is that Casey is a game developer and appreciates performance.
Casey and Uncle Bob debated this in a GitHub repo, ultimately unable to agree. The core contention: what do you find more important:
- performant code,
- maintainable code with clear APIs.
I thought it would be fun to discuss Casey’s example and do some measurements for myself.
I also want to try out some of C++’s newer features, like std::variant and std::ranges.
I also wanted to compare C++ (both with gcc and clang) and Rust’s version of the same ideas and see
how performance might differ.
I will discuss some architectural implications Casey’s optimizations have as well.
The Horrible Performance Example
Casey’s example is on writing a piece of code that works for multiple types at once, that is, polymorphic code. Casey distilled five tenets from “clean” code:
- Prefer (dynamic) polymorphism to “if/else” and “switch”
- Code should not know about the internals of objects it’s working with
- Functions should be small
- Functions should do one thing
- “DRY” - Don’t Repeat Yourself
He uses his “clean” code example and applies optimizations that break the tenets (except tenet 5, that one is fine) to obtain a performance increase of $20 \times$. Casey’s first optimization is changing dynamic polymorphism into switch cases over tagged unions, which breaks tenet 1. His second optimization is adding knowledge about the internals of the types to allow for some extra optimization, breaking tenet 2.
He then adds on to the example, introducing a computation using the number of corners in a shape, to show that tenets 3 and 4 are no good. I do not think his expanded example breaks tenets 3 and 4. Conceptually, to me, his code already has functions that are as small as they should be and do one thing. Indeed, I think the advice by R.C. Martin’s clean code book on this topic (max 4 lines of code per function) is too extreme. For this reason, I will skip the expanded example and focus on the first part of the article.
The example
Casey argues that C++’s typically taught OOP-style polymorphism, dynamic dispatch — passing a base class with a virtual method as a pointer and deciding what method to call during runtime based on the actual derived class of that object’s instance via a vtable (virtual function table) — affects performance significantly.
His actual example is C++ code written in quite a “C-styled” way. Maybe this is common in the game industry, but I rewrote it in a style I am more used to.
#include <stdint.h>
using u32 = uint32_t;
using u64 = uint64_t;
using f64 = double;
constexpr f64 PI = std::numbers::pi;
// =====================
// Virtual Table Approach
// =====================
class Shape {
public:
virtual f64 area() = 0;
virtual ~Shape() = default;
};
class Square : public Shape {
public:
Square(f64 side) : side(side) {}
f64 area() override { return side * side; }
private:
f64 side;
};
class Rectangle : public Shape {
public:
Rectangle(f64 width, f64 height) : width(width), height(height) {}
f64 area() override { return width * height; }
private:
f64 width, height;
};
class Triangle : public Shape {
public:
Triangle(f64 base, f64 height) : base(base), height(height) {}
f64 area() override { return base * height / 2.0; }
private:
f64 base, height;
};
class Circle : public Shape {
public:
Circle(f64 radius) : radius(radius) {}
f64 area() override { return PI * radius * radius; }
private:
f64 radius;
};
We can use dynamic polymorphism with this Shape base class:
f64 total_area(u32 shape_count, Shape** shapes) {
f64 accum = 0;
for (u32 shape_index = 0; shape_index < shape_count; ++shape_index) {
accum += shapes[shape_index]->area();
}
return accum;
}
This is our starting point.
Optimization 1: From Dynamic Polymorphism to Switching over Tagged Unions
The first performance improvement suggested by Casey is effectively a tagged union approach to polymorphism. 2 You flatten the structure into a shape union and use an enum field to tag the shape type. The advantage: baking polymorphism into the type system at compile time eliminates dynamic dispatch.
This is my version of the approach:
// =====================
// Tagged Union Approach
// =====================
class Square {
public:
Square(f64 side) : side(side) {}
f64 area() const { return side * side; }
private:
f64 side;
};
class Rectangle {
public:
Rectangle(f64 width, f64 height) : width(width), height(height) {}
f64 area() const { return width * height; }
private:
f64 width, height;
};
class Triangle {
public:
Triangle(f64 base, f64 height) : base(base), height(height) {}
f64 area() const { return base * height / 2.0; }
private:
f64 base, height;
};
class Circle {
public:
Circle(f64 radius) : radius(radius) {}
f64 area() const { return PI * radius * radius; }
private:
f64 radius;
};
enum ShapeType : u32 {
SQUARE,
RECTANGLE,
TRIANGLE,
CIRCLE,
};
class Shape {
public:
Shape(Square square) : shape_type(SQUARE), shape{.square = square} {}
Shape(Rectangle rectangle)
: shape_type(RECTANGLE), shape{.rectangle = rectangle} {}
Shape(Triangle triangle)
: shape_type(TRIANGLE), shape{.triangle = triangle} {}
Shape(Circle circle) : shape_type(CIRCLE), shape{.circle = circle} {}
f64 area() const {
switch (shape_type) {
case ShapeType::SQUARE:
return shape.square.area();
case ShapeType::RECTANGLE:
return shape.rectangle.area();
case ShapeType::TRIANGLE:
return shape.triangle.area();
case ShapeType::CIRCLE:
return shape.circle.area();
default:
throw std::out_of_range("num out of range");
}
}
private:
union ShapeUnion {
Square square;
Rectangle rectangle;
Triangle triangle;
Circle circle;
};
ShapeType shape_type;
ShapeUnion shape;
};
So the class Shape has two fields: shape_type that tags the shape and
shape that carries the shape data in a union.
For this approach we implement the same function:
f64 total_area(u32 shape_count, const Shape* shapes) {
f64 accum = 0;
for (u32 shape_index = 0; shape_index < shape_count; ++shape_index) {
accum += shapes[shape_index].area();
}
return accum;
}
Optimization 2: Expose internals
Casey argues that showing the internals can help you spot patterns. Suppose we had exposed the shapes’ internals as follows:
struct Square {
f64 side;
};
struct Rectangle {
f64 width, height;
};
struct Triangle {
f64 base, height;
};
struct Circle {
f64 radius;
};
enum ShapeType : u32 {
SQUARE,
RECTANGLE,
TRIANGLE,
CIRCLE,
NUM_SHAPE_TYPES,
};
struct Shape {
union ShapeUnion {
Square square;
Rectangle rectangle;
Triangle triangle;
Circle circle;
};
ShapeType shape_type;
ShapeUnion shape;
};
Here’s how we’d compute area with exposed internals:
f64 get_area(const Shape& shape) {
f64 result = 0.0;
switch (shape.shape_type) {
case ShapeType::SQUARE:
result = shape.shape.square.side * shape.shape.square.side;
break;
case ShapeType::RECTANGLE:
result = shape.shape.rectangle.width * shape.shape.rectangle.height;
break;
case ShapeType::TRIANGLE:
result = shape.shape.triangle.base * shape.shape.triangle.height / 2.0;
break;
case ShapeType::CIRCLE:
result = shape.shape.circle.radius * shape.shape.circle.radius * PI;
break;
default:
throw std::out_of_range("num out of range");
}
return result;
}
Inspecting the switch statement, we see that all area computations are of the form
area = constant x height x width.
Without exposed internals, we’d miss this pattern.
We do have to add an extra field to the square and circle and set it to side
and radius, respectively.
To account for this, I remove the union pattern and use
a struct with general param1 and param2 instead of height and width (this
is foreboding the trade-off we are making here).
Now we see that we can use a table of constants to compute the area
// ===========================
// Table of Constants Approach
// ===========================
enum ShapeType : u32 {
SQUARE,
RECTANGLE,
TRIANGLE,
CIRCLE,
NUM_SHAPE_TYPES,
};
struct Shape {
ShapeType shape_type;
f64 param1;
f64 param2;
};
f64 constexpr CTABLE[NUM_SHAPE_TYPES] = {1.0, 1.0, 0.5, PI};
f64 constexpr get_area_ctable(const Shape& shape) {
return CTABLE[shape.shape_type] * shape.param1 * shape.param2;
}
with call site
f64 total_area(u32 shape_count, const Shape* shapes) {
f64 accum = 0;
for (u32 shape_index = 0; shape_index < shape_count; ++shape_index) {
accum += get_area_ctable(shapes[shape_index]);
}
return accum;
}
Design Implications
Casey’s optimizations have an effect on the shape API.
Data/Object Antisymmetry
Suppose we took Casey’s route for optimization 1, tagged unions with exposed internals (see start of
the optimization 2). We change from an API that is horizontally
expandable (it is easy to add more shapes) to an API that is vertically expandable
(easy to add functionality).
For instance, just like the get_area() function, we can add a get_circumference(), where
we can switch over the shape types.
This is a well-known trade-off. Casey mentions it in the response on the critiques:
… switching between these two approaches is an API transposition. It goes from “open set” on types and “closed set” on functions, to “open set” on functions and “closed set” on types. In other words, it trades the ability for a third party (without access to the code base) to add new types for the ability to add new functions. So you do not lose something, you trade something, which is a very important distinction.
Uncle Bob also mentions this trade-off in “Clean Code”, section “Data/Object Anti-symmetry”:
Procedural code (code using data structures) makes it very easy to add new functions without changing the existing data structures. OO code, on the other hand, makes it easy to add new classes without changing existing functions.
The Expression Problem
These observations are related to the expression problem, which addresses the extensibility and modularity of statically typed data abstractions. The aim is defining a data abstraction that is:
- extensible in its representations (“open set” on types) and
- extensible in its behaviours (“open set” on functions),
- without having to recompile existing code and keeping static type safety.
John Reynolds observed the problem in 1975. He argued that Procedural Data Structures (our virtual polymorphism approach) and Abstract Data Types (the tagged enums with exposed internals) are complementary. The first can be extended with new representations, the second with new behaviours.
Solutions to the expression problem include Mixins, which are available in C++
(via the curiously recurring template pattern (CRTP))
and in Rust (via traits), and Multiple Dispatch, a pattern available
in many languages.
For us, point 3 is less relevant for our discussion: recompiling against a library is fine. However, points 1 and 2 are still interesting. It shows us that there is a API design choice to be made.
Runtime to Compile time optimization
The API design choice is separate from optimization 1, as can be seen
in my implementation of optimization 1. The Shape internals are hidden: my change
only moved the time when the polymorphism occurs (from runtime to compile time).
It did not change the API other than needing to recompile.
This fact was not addressed by Casey or Uncle Bob.
In a recent interview, Casey confirms that optimization 1’s goal is informing the compiler which function to call per variant. This is exactly what my approach achieves.
Generally, if I were to do a tagged union approach, I would use C++’s std::variant
or Rust’s enum types, so that adding or removing shape types leads to compiler
errors, as the visitor pattern / match arms check whether all type variants are matched.
Sum/Product Type
To discuss the API implications from optimization 2, let me define these terms:
A sum type holds exactly one of several alternatives. Examples are Rust’s
enum, C++’sstd::variant, a C tagged union, the OO style abstract base class with subclasses. The number of possible values is(circles) + (rectangles) + (triangles) + (squares). Addition, hence “sum.”A product type holds all its fields at once, e.g.,
struct Point { x, y }. Its number of possible values is(number of x's) x (number of y's). Multiplication, hence “product.”
For a product type you can read every field, while for a sum type you must check what you’re looking at. Every technique for reading a sum type (virtual function calls, switch/match/visitor pattern) is a different way of paying for that check.
Optimization 2 is about changing shape representation from a sum type to a product type. A shape is now
represented as struct Shape { tag, param1, param2 } and its area is computed by coef x param1 x param2.
For the shape API, this means:
- a shape must adhere to the uniform memory layout
{param1, param2}, - a shape’s area must be computable by the formula
coef x param1 x param2.
So we trade optimization for a smaller set of suitable shapes (arbitrary polygons are now excluded,
for example). Not only that, this API makes illegal states representable. The struct
{ circle, 1.0, 2.0 } would compile even though we assume that param1 == param2 == (radius) for circles.
We can save the situation somewhat with a shape constructor and making the shape fields read-only. In this case,
the invariant has been demoted from compile-time enforced to constructor-enforced.
Decisions, Decisions, Decisions
The optimizations lead to two discussion points:
- Data/Object Oriented Design
- Sum/Product Type
Both points are related to the question: what is likely to change in the future? Is it
likely that we add more shapes? Then, pick the OO design. Are the current shape types probably
going to stay the same, but more functionality will be needed? Pick the DO design / tagged union
approach. Is it expected that the area = coef x param1 x param2 pattern is going to hold for
new shapes? Use the product type with coefficient tables.
These kind of choices are nicely explained in the paper On the Criteria To Be Used in Decomposing Systems into Modules by D.L. Parnas, which is seen as one of the founding papers of software architecture. In short, it states that the benefit of encapsulation is the hiding of design decisions that are subject to change. Instead of decomposing a system into modules in terms of subprocesses, one should decompose a system around uncertain design decisions.
The conclusion of the paper states this as well:
We propose instead that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others. Since, in most cases, design decisions transcend time of execution, modules will not correspond to steps in the processing. To achieve an efficient implementation we must abandon the assumption that a module is one or more sub- routines, and instead allow subroutines and programs to be assembled collections of code from various modules.
In the end, the design choice will depend on the context and domain.
Measuring
I’ll measure the three versions above and add Rust variants to see how the optimizations perform.
Compiler-parallelizable
As suggested by Casey, to avoid loop-dependency bottlenecks, I tested an unrolled version:
// Virtual Table Approach
u64 total_area_4(u32 shape_count, Shape** shapes) {
u64 accum0 = 0;
u64 accum1 = 0;
u64 accum2 = 0;
u64 accum3 = 0;
u32 count = shape_count / 4;
while (count--) {
accum0 += shapes[0]->area();
accum1 += shapes[1]->area();
accum2 += shapes[2]->area();
accum3 += shapes[3]->area();
shapes += 4;
}
u64 result = (accum0 + accum1 + accum2 + accum3);
return result;
}
Casey makes a quip about not using iterators in his original article:
I give “clean” code the benefit of the doubt and not add any kind of abstracted iterator that might confuse the compiler and lead to worse performance.
To test this claim, I measured an iterator/range-based version as well:
// Virtual Table Approach
u64 total_area_ranges(std::span<Shape*> shapes) {
auto areas =
shapes | std::views::transform([](Shape* s) { return s->area(); });
return std::ranges::fold_left(areas, 0, std::plus<u64>{});
}
For the tagged union and the ctable approach we implement these functions exactly the same.
C++ Variants Approach
An alternative to the tagged union is C++17’s std::variant type. This is the C++ version
of Rust’s enum type.
The std::variant allows for the compiler-checked version of the tagged enum
approach above. It makes the compiler aware of the different “variants” of the
shape union, so that when switching over the different shapes, the compiler can tell
you whether you missed any types or specified unknown ones.
In particular, you do not have to throw an exception at runtime if the enum used
to tag the union is out-of-range.
These things made me love the Rust enum pattern, and using it in C++ in the form of
std::variant is great! Here is the implementation:
class Square {
public:
Square(u64 side) : side(side) {}
u64 area() const { return side * side; }
private:
u64 side;
};
class Rectangle {
public:
Rectangle(u64 width, u64 height) : width(width), height(height) {}
u64 area() const { return width * height; }
private:
u64 width, height;
};
class Triangle {
public:
Triangle(u64 base, u64 height) : base(base), height(height) {}
u64 area() const { return base * height / 2; }
private:
u64 base, height;
};
class Circle {
public:
Circle(u64 radius) : radius(radius) {}
u64 area() const { return PI * radius * radius; }
private:
u64 radius;
};
class Shape {
public:
Shape(Square square) : shape(square) {}
Shape(Rectangle rectangle) : shape(rectangle) {}
Shape(Triangle triangle) : shape(triangle) {}
Shape(Circle circle) : shape(circle) {}
u64 area() const {
return std::visit([](const auto& s) { return s.area(); }, shape);
}
private:
std::variant<Square, Rectangle, Triangle, Circle> shape;
};
u64 total_area(u32 shape_count, const Shape* shapes) {
u64 accum = 0;
for (u32 shape_index = 0; shape_index < shape_count; ++shape_index) {
accum += shapes[shape_index].area();
}
return accum;
}
u64 total_area_4(u32 shape_count, const Shape* shapes) {
u64 accum0 = 0;
u64 accum1 = 0;
u64 accum2 = 0;
u64 accum3 = 0;
u32 count = shape_count / 4;
while (count--) {
accum0 += shapes[0].area();
accum1 += shapes[1].area();
accum2 += shapes[2].area();
accum3 += shapes[3].area();
shapes += 4;
}
u64 result = (accum0 + accum1 + accum2 + accum3);
return result;
}
u64 total_area_ranges(std::span<const Shape> shapes) {
auto areas =
shapes | std::views::transform([](const Shape& s) { return s.area(); });
return std::ranges::fold_left(areas, 0, std::plus<u64>{});
}
Rust version
Rust’s trait system elegantly handles both polymorphisms: trait bounds for compile-time dispatch, trait objects for dynamic dispatch.
Unlike the C++ code above, we can define the polymorphic part once and decide on the dynamic versus compile-time implementation at the call site.
Here is the trait and the related types:
// shape.rs
const PI: f64 = std::f64::consts::PI;
pub trait Shape {
fn area(&self) -> f64;
}
pub enum ShapeEnum {
Square(Square),
Rectangle(Rectangle),
Triangle(Triangle),
Circle(Circle),
}
impl Shape for ShapeEnum {
fn area(&self) -> f64 {
match self {
ShapeEnum::Square(square) => square.area(),
ShapeEnum::Rectangle(rectangle) => rectangle.area(),
ShapeEnum::Triangle(triangle) => triangle.area(),
ShapeEnum::Circle(circle) => circle.area(),
}
}
}
pub struct Square {
side: f64,
}
impl Square {
pub fn new(side: f64) -> Self {
Self { side }
}
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}
pub struct Rectangle {
width: f64,
side: f64,
}
impl Rectangle {
pub fn new(width: f64, side: f64) -> Self {
Self { width, side }
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.side
}
}
pub struct Triangle {
base: f64,
height: f64,
}
impl Triangle {
pub fn new(base: f64, height: f64) -> Self {
Self { base, height }
}
}
impl Shape for Triangle {
fn area(&self) -> f64 {
self.base * self.height / 2.0
}
}
pub struct Circle {
radius: f64,
}
impl Circle {
pub fn new(radius: f64) -> Self {
Self { radius }
}
}
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.radius * self.radius
}
}
So we have the trait Shape with implementations by the specific shapes.
As in my tagged union approach in C++, we also add the enum type:
// shape.rs
pub enum ShapeEnum {
Square(Square),
Rectangle(Rectangle),
Triangle(Triangle),
Circle(Circle),
}
impl Shape for ShapeEnum {
fn area(&self) -> u64 {
match self {
ShapeEnum::Square(square) => square.area(),
ShapeEnum::Rectangle(rectangle) => rectangle.area(),
ShapeEnum::Triangle(triangle) => triangle.area(),
ShapeEnum::Circle(circle) => circle.area(),
}
}
}
So the tagged union type implements the Shape trait itself using the switch cases pattern.
The dynamic dispatch version of the total area computations (note the use of the dyn keyword) is:
// dynamic dispatch
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn total_area_4(shapes: &[Box<dyn Shape>]) -> f64 {
let sums = shapes
.chunks_exact(4)
.fold([0.0f64; 4], |mut accumulators, shapes| {
accumulators[0] += shapes[0].area();
accumulators[1] += shapes[1].area();
accumulators[2] += shapes[2].area();
accumulators[3] += shapes[3].area();
accumulators
});
sums.iter().sum()
}
The tagged union approach is:
// tagged union
fn total_area(shapes: &[ShapeEnum]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn total_area_4(shapes: &[ShapeEnum]) -> f64 {
let sums = shapes
.chunks_exact(4)
.fold([0.0f64; 4], |mut accumulators, shapes| {
accumulators[0] += shapes[0].area();
accumulators[1] += shapes[1].area();
accumulators[2] += shapes[2].area();
accumulators[3] += shapes[3].area();
accumulators
});
sums.iter().sum()
}
It is interesting how in Rust the approaches look similar, even though their actual implementation is quite different (dynamic dispatch vs switch casing). Rust’s trait system provides a nice API to use both.
Finally, here is the Rust ctable approach, both with iterators and raw for loop:
// shape_table.rs
use crate::shape::PI;
#[repr(usize)]
#[derive(Clone, Copy)]
pub enum ShapeType {
Square,
Rectangle,
Triangle,
Circle,
NumOfShapeTypes,
}
pub struct Shape {
shape_type: ShapeType,
param1: f64,
param2: f64,
}
const CTABLE: [f64; ShapeType::NumOfShapeTypes as usize] = [1.0, 1.0, 0.5, PI];
pub fn get_area_ctable(shape: &Shape) -> f64 {
CTABLE[shape.shape_type as usize] * shape.param1 * shape.param2
}
// ctable
fn total_area_iter(shapes: &[Shape]) -> f64 {
shapes.iter().map(get_area_ctable).sum()
}
fn total_area_iter_4(shapes: &[Shape]) -> f64 {
let sums = shapes
.chunks_exact(4)
.fold([0.0f64; 4], |mut accumulators, shapes| {
accumulators[0] += get_area_ctable(&shapes[0]);
accumulators[1] += get_area_ctable(&shapes[1]);
accumulators[2] += get_area_ctable(&shapes[2]);
accumulators[3] += get_area_ctable(&shapes[3]);
accumulators
});
sums.iter().sum()
}
fn total_area_raw(shapes: &[Shape]) -> f64 {
let mut sum = 0f64;
for i in 0..shapes.len() {
sum += get_area_ctable(&shapes[i]);
}
sum
}
fn total_area_raw_4(shapes: &[Shape]) -> f64 {
let mut accum1 = 0f64;
let mut accum2 = 0f64;
let mut accum3 = 0f64;
let mut accum4 = 0f64;
for i in 0..(shapes.len() / 4usize) {
accum1 += get_area_ctable(&shapes[4 * i + 0]);
accum2 += get_area_ctable(&shapes[4 * i + 1]);
accum3 += get_area_ctable(&shapes[4 * i + 2]);
accum4 += get_area_ctable(&shapes[4 * i + 3]);
}
accum1 + accum2 + accum3 + accum4
}
Measurements
I used a similar test harness to Casey’s (cold/hot runs); see my Codeberg repo for details. Runs vary slightly, but here’s a representative result:
===== clang version 22.1.8 =====
shapes: 1048576
┌─────────────────────┬───────────────────┬───────────────────┬──────────────────┬─────────┐
│ method │ area │ cycles/shape cold │ cycles/shape hot │ speedup │
├─────────────────────┼───────────────────┼───────────────────┼──────────────────┼─────────┤
│ vtbl │ 3180818.912270288 │ 21.4027 │ 22.4943 │ 1.00x │
│ vtbl_4 │ 3180818.912270598 │ 20.7930 │ 21.4229 │ 1.05x │
│ vtbl_ranges │ 3180818.912270288 │ 22.3620 │ 21.7968 │ 1.03x │
│ tagged_union │ 3180818.912270288 │ 18.6841 │ 18.0913 │ 1.24x │
│ tagged_union_4 │ 3180818.912270598 │ 17.2644 │ 17.7818 │ 1.27x │
│ tagged_union_ranges │ 3180818.912270288 │ 18.7335 │ 18.7731 │ 1.20x │
│ std_variant │ 3180818.912270288 │ 18.3815 │ 19.0223 │ 1.18x │
│ std_variant_4 │ 3180818.912270598 │ 17.0843 │ 17.1732 │ 1.31x │
│ std_variant_ranges │ 3180818.912270288 │ 19.4091 │ 18.4313 │ 1.22x │
│ ctable │ 3180818.912270288 │ 3.1750 │ 2.3749 │ 9.47x │
│ ctable_4 │ 3180818.912270598 │ 1.2258 │ 1.1975 │ 18.78x │
│ ctable_ranges │ 3180818.912270288 │ 3.6838 │ 2.4388 │ 9.22x │
└─────────────────────┴───────────────────┴───────────────────┴──────────────────┴─────────┘
===== g++ (GCC) 16.2.1 20260810 =====
shapes: 1048576
┌─────────────────────┬───────────────────┬───────────────────┬──────────────────┬─────────┐
│ method │ area │ cycles/shape cold │ cycles/shape hot │ speedup │
├─────────────────────┼───────────────────┼───────────────────┼──────────────────┼─────────┤
│ vtbl │ 3180818.912270288 │ 21.0363 │ 20.6898 │ 1.00x │
│ vtbl_4 │ 3180818.912270598 │ 20.1019 │ 20.1342 │ 1.03x │
│ vtbl_ranges │ 3180818.912270288 │ 20.5804 │ 20.5838 │ 1.01x │
│ tagged_union │ 3180818.912270288 │ 15.6737 │ 15.5081 │ 1.33x │
│ tagged_union_4 │ 3180818.912270598 │ 14.7456 │ 14.5740 │ 1.42x │
│ tagged_union_ranges │ 3180818.912270288 │ 15.2688 │ 15.0275 │ 1.38x │
│ std_variant │ 3180818.912270288 │ 15.9607 │ 15.6075 │ 1.33x │
│ std_variant_4 │ 3180818.912270598 │ 14.8661 │ 14.7946 │ 1.40x │
│ std_variant_ranges │ 3180818.912270288 │ 15.4554 │ 15.4968 │ 1.34x │
│ ctable │ 3180818.912270288 │ 3.3816 │ 2.3557 │ 8.78x │
│ ctable_4 │ 3180818.912270598 │ 1.2593 │ 1.3041 │ 15.86x │
│ ctable_ranges │ 3180818.912270288 │ 3.4145 │ 2.4656 │ 8.39x │
└─────────────────────┴───────────────────┴───────────────────┴──────────────────┴─────────┘
===== rustc 1.98.1 (48a229cea 2026-09-01) =====
shapes: 1048576
┌──────────────┬────────────────────┬───────────────────┬──────────────────┬─────────┐
│ method │ area │ cycles/shape cold │ cycles/shape hot │ speedup │
├──────────────┼────────────────────┼───────────────────┼──────────────────┼─────────┤
│ dyn │ 3183766.9654992316 │ 18.5884 │ 18.4714 │ 1.00x │
│ dyn_4 │ 3183766.9654997275 │ 18.3820 │ 18.0917 │ 1.02x │
│ enum │ 3183766.9654992316 │ 18.7759 │ 18.0324 │ 1.02x │
│ enum_4 │ 3183766.9654997275 │ 17.3693 │ 17.4651 │ 1.06x │
│ ctable_raw │ 3183766.9654992316 │ 9.4919 │ 2.5655 │ 7.20x │
│ ctable_raw_4 │ 3183766.9654997275 │ 1.6891 │ 1.7286 │ 10.69x │
│ ctable │ 3183766.9654992316 │ 2.5136 │ 2.5201 │ 7.33x │
│ ctable_4 │ 3183766.9654997275 │ 1.6263 │ 1.6352 │ 11.30x │
└──────────────┴────────────────────┴───────────────────┴──────────────────┴─────────┘
Table with comparisons:
hot cycles/shape
┌─────────────────────┬───────┬───────┬───────┬───────────────┬─────────────┐
│ benchmark │ clang │ gcc │ rust │ rust vs clang │ rust vs gcc │
├─────────────────────┼───────┼───────┼───────┼───────────────┼─────────────┤
│ dyn: plain loop │ 22.49 │ 20.69 │ 18.47 │ -17.9% │ -10.7% │
│ dyn: unrolled x4 │ 21.42 │ 20.13 │ 18.09 │ -15.5% │ -10.1% │
│ enum: plain loop │ 18.09 │ 15.51 │ 18.03 │ -0.3% │ +16.3% │
│ enum: unrolled x4 │ 17.78 │ 14.57 │ 17.47 │ -1.8% │ +19.8% │
│ ctable: plain loop │ 2.37 │ 2.36 │ 2.52 │ +6.1% │ +7.0% │
│ ctable: unrolled x4 │ 1.20 │ 1.30 │ 1.64 │ +36.6% │ +25.4% │
└─────────────────────┴───────┴───────┴───────┴───────────────┴─────────────┘
note: positive % means Rust spent more cycles/shape than that C++ build.
Observations:
- The ctable approach wins spectacularly, $10 \times$ for Rust code, $16 \times$ to $18 \times$ for C++ code.
- Using iterators and ranges in C++ does not generally affect performance consistently, except for the ctable approach, where it matters by a factor of $2$. In Rust, iterators perform slightly better than the raw for loop for the ctable approach.
- The dynamic dispatch versus tagged union has considerable effect for the C++ code, but almost none for the Rust code.
- The Rust dynamic dispatch is quite a bit quicker than the Clang C++ dynamic dispatch but the tagged union runs are very similar.
- gcc’s tagged union run is significantly more performant than Clang’s or Rust’s tagged union / enum runs.
Conclusion: Casey’s optimizations have merit for both C++ and Rust. Rust performs a lot worse than C++ in the ctable approach. I am not sure why this is, maybe interesting for a follow-up investigation.
Takeaway
This post by codef00 reflects my thoughts pretty well and it seems he drew similar conclusions as I did.
As expected, the ctable approach is amazingly quick. It makes sense, you get rid of any branching of your code, everything can be cache lined. If you are sure your shapes can be represented like this, you should definitely pick this option.
If your shapes representation might change in the future, but you want the optimization now, you could embed the client code into the module and package it as a whole.
Keep clean code at boundaries; optimize internally. Larger API surfaces enable better optimization.
As I have heard from experienced developers, the most important architectural question is: “where do you put your API boundaries?” Connecting this to Parnas’ Criterion, it is the same question as “where do you expect the design to change?”