LLVM merges machine function splitter for reduction in TLB misses
lists.llvm.org
lists.llvm.org
I wish compilers were able to optimize functions boundaries for visible but not inlined better so they could optimize those function call overheads away automatically. There is no reason for a compiler to limit itself to the system ABI for completely internal functions.
Besides leaving values in the registers they are already in instead of moving them to where the ABI says they should be, this could enable passing C++ objects such as std::unique_ptr in registers where the current ABI forbids this. Or to eliminate nop destructors for objects that are always moved from in the called functions.
In general, I think compilers should see function boundaries in the source code as a mere hint when it comes to code generation just like they already do with the register keyword.
Is there any existing compiler work to optimize function calls where the implementation and all uses are visible in the translation unit?
Or with LTO / -fwhole-program: functions that have hidden visibility. Which is all of them for Windows executables and the default for DLLs (and -fvisibility=hidden is a sane default for other systems too).
> Once you have that down, you have to deal with the fact that the things you would want to optimize may differ at each call site (one may like to keep rdx free, one may be able to give up rdi) so I am unsure it would be worth the effort to not follow the ABI.
Sure, that makes things harder. But in many cases will only be one call site. In others you can still figure out which optimizations you can apply to all of them - for example passing std::unique_ptr and similar objects by register should always be doable as long as you control all call sites. Further, the compiler has the option to clone functions in order to apply optimizations specific to a group of call sites if it decides that an optimization is worth the increased code size. And some optimizations will be purely on the caller side - for example not spilling caller-saved registers if you know they won't be clobbered by the callee.
Sulong is an LLVM JIT-compiling engine that runs on GraalVM. So it uses the same infrastructure of the JVM, but without JVM bytecode getting into the picture. Instead the JVM runs LLVM bitcode directly. But it does so in the standard way which gives many benefits:
• Splitting of hot/cold paths, as in this work.
• Cross-function optimisation and inlining regardless of visibility, as requested above.
• Ability to debug an optimised program, because the JVM can 'deoptimize' the parts being inspected or debugged.
• Run the Linux version of the bitcode cross-platform.
• In "Managed Sulong" (which is an enhanced payware version), all allocations are garbage collected and bounds checked. Yes even for C/C++ programs, just needing a recompile. It's just about possible to do this within the rules of well defined C/C++, as long as the program doesn't try to do non-portable or non-defined things like pass stack pointers between threads. Managed Sulong can also sandbox LLVM code modules, and redirect IO back into the JVM for mapping via the pluggable socket/filesystem layers.
• Cross-language interop, including recent experimental support for e.g. casting Java or Ruby or Python objects to C++ objects and calling methods on them.
• All the same compiler optimisations as LLVM does still apply.
It's a pretty amazing and under-appreciated project. On the other hand the code being run inherits the limits of the JVM, e.g. you shouldn't try and fork the process (of course, lots of runtimes don't like being forked and on Windows you can't do it at all).
(Admittedly, generating the debugging records to be able to refer to variables in the non-outlined portion of the function from the outlined function is probably not done, although it's probably doable with the current DWARF expression syntax. This is much like how most of the <optimized out> variables you see in gdb could be specified with DWARF information but aren't because the compiler doesn't maintain the information.)
As far as I understand it, this limitation is also the only thing preventing tail-call elimination in C/C++. Tail-recursive functions are essentially the same as 'while' loops (execute some instructions then conditionally go back to the start); they just have different scope and calling conventions. In particular, 'while' loops are always inline and can't be referenced from other functions, but tail-recursive functions can. Hence compilers can apply more aggressive optimisations to 'while' loops, e.g. re-using the same stack frame and iterating with a conditional jump.
However, as you say, functions-as-subroutines don't necessarily need to coincide with functions-as-interface. Tail-recursive functions which are only used internally can also be optimised to re-use the same stack frame and iterate with a conditional jump. If such functions are also exposed in the interface, it's pretty trivial to expose a dummy entry point which invokes the optimised version (if we want to); although cross-unit tail calls wouldn't be eliminated in that case.
Of course, this is more useful than just writing 'while' loops in a different style (although I personally prefer recursion https://news.ycombinator.com/item?id=11150346 ), since tail calls don't have to be recursive: we can split our code up into modular, composable, self-contained functions without having to worry about stack usage (or code bloat caused by inlining into multiple locations).
Nothing "prevents" tail-call elimination in C. Compilers do it all the time.
> Tail-recursive functions which are only used internally can also be optimised to re-use the same stack frame and iterate with a conditional jump. If such functions are also exposed in the interface, it's pretty trivial to expose a dummy entry point which invokes the optimised version (if we want to)
Can you illustrate with a concrete example what you mean? If the function doesn't need a stack frame, there will be no stack frame either way. If the function needs a stack frame, that stack frame must be allocated in the function's start block either way, and tail recursive calls will of course not jump to that start block but to its successor. I don't see how being "exposed in the interface" (in C speak, static vs. non-static) would make a difference here.
Here's a tail recursive function C function that is compiled to tail recursive machine code without any problems: https://gcc.godbolt.org/z/dc886a
This function can be called from external compilation units without any problems. Whether or not it is compiled to tail recursive code is an implementation detail that the caller doesn't need to know about.
At the risk of introducing a tangent, can someone explain how compilers detect tail recursion? This is still a complete mystery to me.
LLVM's code is at https://llvm.org/doxygen/TailRecursionElimination_8cpp_sourc...
The core of this detection is in findTRECandidate. It iterates backwards over a basic block, looking for call instructions whose call target is the current function F:
CallInst *CI = nullptr;
BasicBlock::iterator BBI(TI);
while (true) {
CI = dyn_cast<CallInst>(BBI);
if (CI && CI->getCalledFunction() == &F)
break;
if (BBI == BB->begin())
return nullptr; // Didn't find a potential tail call.
--BBI;
}
The harder part is detecting whether calls are in fact tail calls that you can replace by jumps. There are all sorts of special cases, like whether alloca() might be called by the function.(Yours is still a helpful explanation, though -- thanks!)
All we require is that the current function is calling some other function, and passing on its return value unchanged. For example, let's take a simple function:
int foo(int x) {
return bar(x);
}
'return bar(x);' is a tail-call, since 'foo' isn't doing anything with the result; calling 'foo' is equivalent to calling 'bar'. In this case the compiler could actually replace every occurrence of 'foo' with 'bar' (e.g. inlining it), but we can't do that in general. However, when we call 'bar' we certainly don't need to keep any of 'foo's machinery around (stack frames, local variables, register contents, etc.); we can just start executing the code for 'bar' (i.e. a jump instruction).More realistically, 'foo' and 'bar' might not be equivalent, e.g.
int foo(int x) {
int y = baz(x);
return bar(x + 3 * y);
}
Here we can't just replace calls to 'foo' with calls to 'bar', since they do different things. However, notice the order that things will run in: (1) 'baz(x)' will be called, (2) 'x + 3 * y' will be calculated, (3) 'bar' will be called. Even though these 'foo' and 'bar' functions are very different to begin with, once we reach step three (the call to 'bar') the remaining instructions are the same (since 'foo' is calling 'bar' and returning its result unchanged; i.e. the definition of a tail-call!). Hence we can still discard 'foo's machinery and just jump to 'bar' (e.g. we can throw away 'y'; and the compiler can ensure the result of (2) is put where 'bar' expects its argument).If 'foo' and 'bar' are the same function then we happen to have tail recursion. For example:
uint fac(uint n) {
if (n) return fac(n-1);
return 1;
}
Here 'return fac(n-1)' is a tail-call; it also happens to be tail-recursion, since it's calling the 'fac' function from within the 'fac' function. We don't need to keep the stack frame for 'fac(n)' around, we can just overwrite it with the stack frame of 'fac(n-1)', and so on.We can also have mutually tail-recursive functions, e.g.
uint even(uint n) {
if (n) odd(n-1);
return 1;
}
uint odd(uint n) {
if (n) even(n-1);
return 0;
}
This is harder to spot than direct tail-recursion, but it doesn't matter since tail-recursion isn't particularly special. As long as we implement tail-calls efficiently, then the benefits apply to tail-recursive functions (like 'fac') and mutually tail-recursive functions (like 'even' and 'odd'), as well as many other places which just-so-happen to use tail-calls.Intuitively, any time we find ourselves using a "main loop", or "driver loop", etc. which just calls out to a sequence of functions, possibly passing the return values of one into arguments of the next, that's equivalent to having each of those functions make a tail-call to the next one (i.e. such loops are "trampolines").
It's a bit more complicated than that since there may be destructors (in C++, or C with certain extensions) or stack-frame cleanup which occur between 'foo' returning and the 'return' statement itself. Tail call elimination is only possible when the stack frame can be freed before the final function call; if the address of any local variable escapes the current function then it might not be an option even if the source otherwise appears tail-recursive. TCE is simpler in garbage-collected languages which allocate their stack frames from the heap (and typically lack precise destructors) since these cleanup operations can be left to the GC.
That is not the case. Guaranteed TCE requires deallocating the stack frame before jumping to the target, but that is not possible when an object is allocated in that frame and its address passed to the target function.
In C++ there is also the issue of destructors needing to run after the tail-call returns (in which case it is not really a tail-call).
C/C++ compilers can and do eliminate tail calls where possible, but there's no guarantee like you get from a compiler for a functional language.
Ah of course, I hadn't thought about live aliases to stack-allocated data.
You're right about destructors; I agree that they're not really tail-calls (since we're processing results).
> C/C++ compilers can and do eliminate tail calls where possible, but there's no guarantee like you get from a compiler for a functional language.
Yes, I tend to say "tail-call optimisation" when it's opportunistic (e.g. GCC/Clang), and "tail-call elimination" when it's guaranteed (e.g. Scheme/ML).
If you contribute a 1% CPU savings change to GCC or LLVM you would have a very hard time being carbon-positive over your lifespan.
https://lists.llvm.org/pipermail/llvm-dev/2020-August/144012...
https://groups.google.com/g/llvm-dev/c/RUegaMg-iqc/m/VFyV9cX...
Looks like it requires a Google account now though, I can't get it in incognito.
https://groups.google.com/d/msg/llvm-dev/RUegaMg-iqc/wFAVxa6...
Not bad. Presumably this will benefit every compiler that uses LLVM, not just Clang.
A unit test that tests code that’s cold in practice would make it look like hot code, and be counter productive.
Basically, while the previous outliner split a function into two functions (the hot one literally calling the cold one as needed) this new thing takes a single function and splits it into two parts connected to each other by jumps. The cold part of the function isn't really a function --- it's just a group of basic blocks that happen to be located far away from the other group of basic blocks.
By avoiding the call into the cold function and the return to the hot function, the generated code can be smaller and more register-efficient.
The difference is sampling prod vs sampling separately in test. The arguments for FDO:
- Prod behaviour/data is always "representative", whereas synthetic or recorded data can go out of date quickly.
-PGO test fixtures can contain sensitive user data. Instrumenting production processes doesn't put data in more places.
The benefits of both are huge though. The rule of thumb I've seen is a 20% improvement for FDO over -O3.
It's literally sampling from a representative workload (production) vs a non-representative one (anything synthetic).
- With traditional PGO (ie -fprofile-generate, then -fprofile-use) you first generate a separate instrumented binary that records profile information. You wouldn't generally want to use this binary in production because of the overhead this profile generation incurs.
- With sample driven PGO (ie -fprofile-sample-use) you use external tools to sample profile information from an uninstrumented binary - the same binary you'd use in production.
Ok, I would not have expected that.
I'm curious what percent of functions are this large?
Not that such functions are the right way to structure your program...
Pointer size has little impact on code size. amd64 code can even be smaller than x86 in some cases.
Anything with a big switch would qualify too, such as an emulator.
Interestingly, the largest function all cases seemed to be something on the order of "initialize a large static hashtable."
Edited: I totally forgot about this great paper that shows the size of the top 100 most-executed functions at Google. See Figure 8.
https://people.ucsc.edu/~hlitz/papers/asmdb.pdf
Here's a gigantic function I found laying about: https://gist.github.com/jwbee/2167565e043578000ae489f67a82d6...
Protocol Buffers message decoders tend to be extremely large, since each message has an auto-generated parse function that will tend to inline everything. My local build of protobuf contains 20 functions each larger than 5KB, including a 10KB-long specialization of std::sort.
“memcmp clearly stands out of the correlation between call frequency and function size in Figure 8. It is both extremely frequent, and, at almost 6 KiB of code, 10× larger than memcpy which is conceptually of similar complexity. Examining its layout and execution patterns (Figure 13) suggests that it does suffer from the high amount of fragmentation we observed fleetwide in the previous section. While covering 90% of executed instructions in memcmp only requires two cache lines, getting up to 99% coverage requires 41 lines, or 2.6 KiB of cache capacity. Not only is more than 50% of code cold, but it is also interspersed between the relatively hot regions, and likely unnecessarily brought in by prefetchers. Such bloat is costly – performance counter data collected by GWP indicates that 8.2% of all i-cache misses among the 100 hottest functions are from memcmp alone.
A closer look at the actual code from glibc can explain the execution patterns in Figure 13. It is hand-written in assembly and precompiled, with extensive manual loop unrolling, many conditional cases for the various alignments of the two source arrays, and large jump tables.
In our experience, code usually evolves into similar state from over-reliance on micro-optimization and micro-benchmarking. While writing in assembly can in rare cases be a necessary evil, it prevents the compiler from doing even the most basic feedback-directed code layout optimizations.
[…]
We tested this hypothesis by macro-benchmarking a version of memcmp that is specifically optimized for code size (only 2 i-cache lines) and locality. In short, it only special-cases very small string sizes (to aid the compiler in inlining very fast cases) and falls back to rep cmps for larger compares. Even though it achieves slightly lower throughput numbers than the glibc version in micro-benchmarks, this simple proof-of-concept showed an overall 0.5%-1% end-to-end performance improvement on large-footprint workloads like web search.”
I would guess the assembly version can be improved by moving those cold blocks out of the cache lines of the hot code.
LLVM is fairly well-structured, especially compared to GCC. I don't find it scary but it is a big fat fucker of a codebase.
[0] https://craftinginterpreters.com/ [1] https://compilerbook.com/
I'm also curious if there's been any work to organize code layout to make it more efficient to page stuff out efficiently to reduce memory pressure (so that you're using all code in the pages you are bringing in) & reduce paging thrash (you're not evicting a code page only to bring it back for a few hundred bytes).
It seems like most people who just want to squeeze the last drop of speed out of their code will probably never become aware of it then unless they are specifically seeking it out
The exact machine part is not true. There's nothing about this particular optimization that's machine specific - e.g. as the original post explains, this optimization gives performance boost on Intel and AMD, on Intel due to reduction in iTLB misses, and on AMD due to reduction in L1 and L2 icache misses. i.e. this kind of "working-set" reduction translates to any platform.
> In fact, you may often be taking that 2% or more away from other use cases.
In general, it is correct that profile-guided optimization can theoretically reduce performance, as some of the aggressive optimizations are only done with profile because of inherent trade-off the optimization has (e.g. aggressive inlining which can be detrimental for the performance if hot functions are entirely different).
However, empirically this is not true in most cases, unless you picked really bad training input, and your code has extremely different behavior under different input. Moreover, nowadays with sampled profile, which you can collect from the real, production runs, it's extremely unlikely for this to happen.
So yes, PGO is hard, but if you collect representative profiles it is a massive improvement.
Decreasing the downside means you can apply the hopefully-optimisation more aggressively for more gain so I would expect it to matter.