jandrewrogers 10 hours ago

I’ve seen many examples of a similar phenomenon. Modern compilers are impressively good at generating bit-twiddling micro-optimizations from idiomatic code. They are also good at larger scale structural macro-optimization. However, there is a No Man’s Land for compiler optimization between micro-optimization and macro-optimization where the effectiveness of compiler optimizations are much less reliable.

Intuitively I understand why it is a hard problem. Micro-optimizations have deterministic properties that are simple enough that optimality is all but certain. Macro-optimization heuristics create incidental minor pessimization effects that are buried below the noise floor by major optimization effects on average.

In the middle are optimizations that are too complex to guarantee effective optimization but too small in effect to offset any incidental pessimization. It is easy to inadvertently make things worse in these cases. Most cases of surprising performance variances I see fall into this gap.

It is also where the codegen from different compilers seems to disagree the most, which lends evidence to the idea that the “correct” codegen is far from obvious to a compiler.

  • fooker 6 hours ago

    It's a hard problem because the cost model is incredibly complicated.

    For example: you decide to unroll/inline/vectorize code has unpredictable effect on performance.

    • bell-cot 3 hours ago

      Or worse - it has perfectly predictable effects on the performance...so long as you know details (core counts, cache sizes, etc.) of the CPU model and dataset which your code will be run on, and the TLB/cache/fabric/memory usage pattern of other jobs which might be running at the same time.

      • fooker 2 hours ago

        No, maybe you misunderstand the problem here?

        Given a specific program, yes it is measurable and predictable.

        A compiler has to deal with arbitrary programs, actually - small fragments of arbitrary programs at a time. Even if you could have an oracle giving you very precise measurements for the program fragment the compiler is dealing with, it won't hold up in practice.

        Consider a small representative example. Suppose the compiler is compiling a function, and it did the best possible job.

        Now, inlining this function at two callsites could have completely different second order effects like further optimizations, register pressure. There are partial solutions of course, people have spent their entire careers dealing with this specific problem. Rest assured that whatever you can come up with after thinking for a minute has been tried in 1970.

        • fancyfredbot 2 hours ago

          I think OP was agreeing with you.

          • fooker 2 hours ago

            My point was - even if you knew everything about the hardware and could measure performance with absolute precision, it is still a hard problem for compilers for many many reasons.

  • algo_trader 8 hours ago

    > also good at larger scale structural macro-optimization

    Can u give some examples?

    Perhaps you mean small granular choices which occur widely throughout the code?

    • viraptor 8 hours ago

      Automatic unrolling + inlining + constant hoisting (+ vectorisation)? I'd call that macro, but maybe op had something different in mind.

adzm 2 hours ago

Notably this is on an arm processor (aarch64) and they don't say specifically which one. I've seen extreme differences in jump table performance between x86/amd64 cpus. Specifically I remember seeing a big speedup on similar code with either Haswell or Skylake that I pinned down to a jump table suddenly performing blazing fast compared to the previous architecture. Which actually led me to implement the same thing here (avoid generating the jump table) so it would perform better on the earlier processors. This was code that was, basically, stream parsing varint values so the code pattern immediately triggered that core memory!

president_zippy 10 hours ago

If you wanna really see this at work on a whole other extreme, try compiling code for an N64 game. It's no surprise that optimizations for modern-day x86_64 and Arm64 processors with a lot of cache would not generalize well to a MIPS CPU with a cache that must be manipulated at the software layer and abysmal RDRAM latency, but the exact mechanics of it are interesting.

KazeEmmanuar did a great job analyzing exactly this so we don't have to!

https://www.youtube.com/watch?v=Ca1hHC2EctY

  • MrCheeze 3 hours ago

    Exactly what I was going to post. Optimizations like loop unrolling slow down the N64 because keeping the code size small is the most important factor. I think even compilers of the time got this wrong, not just modern ones.

PaulKeeble 6 hours ago

I suspect over time as memory latency, bandwidth and branch prediction have become increasingly the hot aspect of CPUs that optimisations put into compilers before now often end up improving for the wrong thing. Maintaining sequential reads of memory and ahead of the calculation is now critical for peak performance as is the branch being correctly predicted. Saving calculation time on the ports of the CPU however much less impactful as we mostly struggle to shuttle the work into them to achieve peak performance.

Branch predictors are a lot bigger and more capable now than they were and there is every chance when the optimisation for jump tables was tested against the equivalent branch code it outperformed it, but on more recent CPUs the situation reverses.

Panzerschrek 10 hours ago

I think some people misunderstand how compiler optimizations work. it's not magic, which makes each piece of code faster, it's just a bunch of deterministic code transformation rules, which usually make code faster (considering large set of use-cases), but it's not proven that they always do so. So, by writing performant code one should always keep this in mind.

  • Someone 8 hours ago

    There are many optimizations that are guaranteed to make code run faster, but even then, that does not mean you should use them.

    There can be competition between optimizations. At a given moment, the preconditions for both optimization O1 and optimization O2 may hold, but if you then apply O1, the precondition for O2 may no longer hold and vice versa.

    In such cases, the best solution is to pick the better of O1 and O2.

  • account42 8 hours ago

    One could even say that compiler optimizations are a set of deterministic code transformation rules that happen to make SPEC run faster. But programmers are not wrong to expect better from their compilers.

userbinator 10 hours ago

ARM64 has many registers but I believe the lack of suitably large immediate values and, apparently, compilers that are willing to use them all across functions, puts it at a disadvantage here. Assuming we want the return value in eax and the leading count comes in cl, this can be done branchlessly and data-lessly on x86 as follows:

    mov eax, 0x00043201
    test cl, 8
    setz al
    shl cl, 2
    shr eax, cl
    and eax, 15
Something similar may be possible on ARM64, but I suspect it will definitely be more than 19 bytes ;-)
  • shiftingleft 9 hours ago

    Shouldn't your snippet be using lzcnt? I can't see how this would result in the desired lookup.

    for Zen5 rustc creates the following:

      utf8_sequence_length_lookup:
        shl edi, 24
        mov ecx, 274945
        not edi
        lzcnt eax, edi
        shl al, 2
        shrx rax, rcx, rax
        and al, 7
        ret
    
    https://rust.godbolt.org/z/hz1eKjnaG
anon-3988 3 hours ago

Since the biggest one is only 11110 (5 bits), can't you use a lookup table of 32 characters by masking the last 3 bits?

  • yuliyp 3 hours ago

    That's not the problem. The problem is that you're adding a data dependency on the CPU loading the first byte. The branch-based one just "predicts" the number of bytes in the codepoint and can keep executing code past that. In data that's ASCII, relying on the branch predictor to just guess "0" repeatedly turns out to be much faster as you can effectively be processing multiple characters simultaneously.

    • anon-3988 2 hours ago

      I am pretty sure CPUs can speculative load as well. In the CPU pipeline, it sees that there's an repeated instruction to load, it should be able to dispatch and perform all of it in the pipeline. The nice thing is that there is no chance of hazard execution here because all of this speculative load is usable unlike the 1% chance where the branch would fail which causes the whole pipeline to be flushed.

ky_vulnerable 2 hours ago

When I used to code in C++, I went down a long rabbit hole of compiler optimizations. It's way more complicated than we give developers credit for. Coming from C++ to Rust, I was happy to see that `--release` optimizes (almost) everything for you.

Panzerschrek 10 hours ago

It's a simple rule of thumb to avoid branching in performance-critical code. Branchless code is pretty predictable, but if branching takes place, one can no longer reason so easy about code performance, since compiler optimizations and CPU branch prediction may be involved.

  • account42 8 hours ago

    But reality is not that simple. Branchless with unfortunate data dependencies can be a lot worse than predictable branching code.

    • Panzerschrek 4 hours ago

      That's why I said it's a rule of thumb. There are always exceptions where branching does code faster.

YesThatTom2 6 hours ago

This is a good reminder that optimization without measurement is stupidity.

It’s also a reminder that compilers aren’t magic and optimizers aren’t all-knowing.

People have a tendency to ascribe magical properties to anything they don’t understand. Compilers and optimizers more so.

Measure before and after any optimization. You’d be surprised at what does and doesn’t improve efficiency.

Today’s CPUs are more complex than a human can understand.

Measure twice, cut once.

  • pjmlp 4 hours ago

    Worse is the cargo cult of "write this way because it is faster", without any measurements.

    Only because it used to be true in the past, someone told it was so during a coffee break, or it appeared in some computer related article.

    • superblas 4 hours ago

      Agreed. As an embedded software engineer new to the field I’ve seen senior engineers with over ten years of experience complain when division by a compile time constant (even by a power of two) is used in code instead of multiplication or bit shifting on our cortex-m7 mcu running at 600MHz