Pure Functions in C++
One of the best optimisation features of C++ is declaring functions as pure. This one is less known to developers, though it can be an excellent optimisation hit for your software. Marking a function with “pure” attribute enables a wide range of possible optimisations for the compiler. You must declare your pure functions as such, in all code that cares about performance. In this post, I am going to show how to use this feature and its effects on generated code.
Side Effects and Referential Transparency
C++, just like its predecessor, C, is an imperative programming language. It has very few to none functional programming features. Thus, functions in C++ can are allowed to contain side effects. A side effect of a function is an observable change that calling it makes in the state of the system. Except for the return value of course. For example, changing a global or static variable in a C function is considered as a side effect. Functional programming languages, largely limit side effects (even in some cases, prohibit them). Any function with no side effects is called a pure one. Pure functions have interesting properties, that enable very effective optimisations. One of them is Referential Transparency. Briefly speaking, an expression (including a function) is called referentially transparent, if it can be replaced with its corresponding value without changing the program’s behaviour. So if you have a pure function, it is referentially transparent and it can be replaced with its return value in all places that it’s been called with the same set of arguments.
As an example, consider the very simple function, pow2
which takes a
number and returns it’s second power:
double pow2(double x) {
retutn x*x;
}
This is a pure function. Its value does not depend on any external source of
information, and it always returns the same result for specific input. So you
can replace pow2(5)
with its evaluation result, 25
, everywhere in
your code. This information is very useful for optimisation purposes: Removing
extra invocations by the compiler, can save your software a lot of calculations.
Referential Transparency in C++
Since C++ has no notion of pure functions, all functions are supposed to have side effects by default. Hence, there is no optimisation based on referential transparency applied by default.
To observe the lack of referential transparency, see the assembler output of the following code:
double pow2(double x) { return x*x; }
// ...
double caller() {
const double x = pow2(5.0) + pow2(5.0);
const double y = pow2(5.0) + pow2(5.0);
return x+y;
}
This code has is compiled with gcc 10, with maximum optimisations enabled
(-O3
). Before you go ahead and test it by yourself, I should warn you to
exclude the implementation of pow2
function (or hide it from your
compiler). Failing to do so, the compiler will optimise your code so
aggressively, that invalidates the point of this post completely! So, for the
sake of this example, we will suppose the compiler is not aware of the
implementation of the pow2
function at the compile time. Below you can see
the assembly output of the caller
function:
caller():
sub rsp, 24
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
movsd QWORD PTR [rsp], xmm0
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
movsd xmm1, QWORD PTR [rsp]
addsd xmm1, xmm0
movsd xmm0, QWORD PTR .LC0[rip]
movsd QWORD PTR [rsp], xmm1
call pow2(double)
movsd QWORD PTR [rsp+8], xmm0
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
addsd xmm0, QWORD PTR [rsp+8]
addsd xmm0, QWORD PTR [rsp]
add rsp, 24
ret
.LC0:
.long 0
.long 1075052544
As you can see, pow2
is called four times (highlighted lines), while it
was only necessary to run it once. Fortunately, there are optimisation features
for this situations.
Declaring Functions as “Pure”
In gcc and clang, you can mark a function as pure using this syntax:
[[gnu::pure]] double pow2(double x);
Adding pure
attribute to a function tells the compiler that calling it is
not going to modify program state. Doing so, we explicitly allow the compiler to
perform optimisations, based on referential transparency. This can save a lot of
unnecessary calculations, especially in embedded systems programming, and also
in computationally intensive or highly parallel software.
Let’s take a look at it’s effect on our example code:
caller():
sub rsp, 8
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
add rsp, 8
addsd xmm0, xmm0
addsd xmm0, xmm0
ret
.LC0:
.long 0
.long 1075052544
As you can see, there is only one invocation of the pow2
function. The
compiler caches its return value since all calls to it have the same set of
arguments (5.0
). The compiler, rightfully, has removed the other three
invocations. Note that the order of execution and call point does not affect
this kind of optimisations, for the same reasons explained in referential
transparency property. So, if you were to call pow2(5.0)
in another place
or a different order, it would still get optimised away.
Although pure attribute and its relatives are not standard yet, using them is fairly safe and simple. Modern compilers (except MSVC), provide a similar syntax for them.
Declaring Functions as “const”
Pure functions are guaranteed not to change program state. But there is also a higher level of guarantee: That is not only keep program state intact, but not to read any observable external state as well. Such functions are called “constant”. Note that this kind of constness, differs from immutability defined by cv specifier. Marking a function as const, will enable even larger set of optimisations for the compiler. To do so, one can use this syntax:
[[gnu::const]] double pow2(double x);
In Comparison with constexpr
You might think const
or pure
functions are the same as
constexpr
. That is not correct. There are fundamental differences between
them. Most important one is that it must be possible to evaluate a function
marked as constexpr
at compile time. That requires all its arguments to be
literal types, or constexpr
functions and variables. In contrast, a pure
or const
function has no such requirement and can be fully evaluated at
runtime. The only requirement is that calling the function with the same set of
arguments, should always result in the same value.
Standard C++
As I mentioned before, this feature is not standard. There were discussions in
the community to push it through C++20, but it didn’t make it. I can guess that
it’s because checking pureness of a function is virtually impossible. If
programmer marks a function as const
while it is infected with some sort
of side effect; the compiler will have no means to warn you or report errors.
Simply the calculations will be incorrect. Thus pure functions are provided as
a non-standard extension for now. I would love to see them in the C++ standard one day…