Optimising compilers mindset for a Python programmer

Viewed 83

Coming from a mostly Python background, I am now learning both C and x86-64 assembly. I used C indirectly via Cython previously but I am now learning C proper in addition to assembly.

My basic question is in what sort of a mindset should I put myself when it comes to optimising compilers. Should I just let a compiler do its job but, once I am sufficiently proficient in assembly, start to check and confirm the assembly output? Is that what responsible C programmers wanting to write high-performance code do?

The question was triggered because I wanted to check what gcc 7.5.0 would optimise the code below to. In particular, I ran objdump to find out how accessing an array twice at the same index would be optimised on various levels.

  • On -O3 there were some instructions I have not learnt yet, e.g. movaps XMMWORD PTR [rsp+0x10],xmm0
  • Levels -O2 and -O1 were somewhat clearer but still I did not understand it fully
  • On level -O0 I believe I could see a rather straightforward translation of the code where I think messages[idx] was indeed accessed twice

My question is not when these levels should be used. I just ask the more experienced programmers if this is what you do, run code with high optimisations and check assembly output to make sure everything is as expected? Is that the natural workflow for people who want to truly know what machine code a compiler produces?

I understand that the example below is a trivial kind of an opportunity for optimisations but have you just learnt that certain optimisations occur for sure and you do not think about them anymore? There is not a lot of information about what kind of transformations and optimisations can take place, not to mention the fact that compilers leave no notes or messages for programmers to understand what was optimised and why, so I just cannot imagine any other way than simply learning it all in practice. Thanks.

#include <stddef.h>
#include <stdio.h>

int main(int argc, char ** argv)
{
    size_t len_messages = 9;
    int messages[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};

    for(size_t idx=0; idx < len_messages; idx++) {
        printf("Accessing here %d and there %d\n", messages[idx], messages[idx]);
    }

    return 0;
}
2 Answers

My basic question is in what sort of a mindset should I put myself when it comes to optimising compilers. Should I just let a compiler do its job but, once I am sufficiently proficient in assembly, start to check and confirm the assembly output?

Mostly no.

Different pieces of code influence performance by different amounts - a piece of code that's only used once during initialization won't influence performance much, and a piece of code in the middle of a loop that's being frequently executed may have an extreme impact on performance. Optimizing with assembly costs developer time and portability; and often those extra costs can't be justified by negligable performance improvements of code that isn't executed often.

For this reason the main tactic is to use a profiler to determine where the most important (for performance) pieces of code are; and investigate performance improvements for those pieces only.

However "investigate performance improvements" still doesn't necessarily mean going directly to assembly. You think about improving the algorithm, improving data structures and cache locality, improving parallelism ("more threads!"), etc.

After all of that you might look at the assembly the compiler generates and see if you can find a way to improve/optimize it by hand. You also might not.

The reason you still might not use assembly language is that different CPUs are different. You can optimize for one CPU (whatever your computer has) and make the software significantly slower on other CPUs (whatever the end users who run your software have); or you can rely on features (e.g. AVX512) that may not exist. Of course this also means that the results you got from profiling aren't as useful as you might think (good enough for a crude estimate and never usable as an accurate representation applicable to all CPUs).

To get around that you might need multiple different versions in assembly language for different CPUs - one for "64-bit Intel with AVX-512", one for "64-bit Intel with AVX2", one for "64-bit Intel without any AVX", 2 more versions for AMD because you found out that a few instructions take longer on AMD and a few other instructions are faster on AMD; then another collection of different versions for 64-bit ARM, then PowerPC, then ...

Basically; it's rare to optimize in assembly. For a "heavily pounded" library (e.g. MPEG decoder, big number library, ...) it can make a lot of sense, and for a few performance critical parts of a large program it might be justified; but apart from that it's likely that you have far more important things to do with your time.

I rarely look at disassembly alone. Mostly I decompile the function with Ghidra to see what's going on with the optimizer. You get a much bigger and better picture then. In a more familiar language, where you still can see the generated assembly.

Related