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…

Comments

comments powered by Disqus