Hello everyone,
I’ve recently been working on a machine-level inliner for AMDGPU and it’s been suggested that I start an RFC to discuss alternative solutions.
We have an increasing number of use-cases in the AMDGPU backend where we’d like to experiment with inlining in the backend, after register allocation, but some of them involve things that are not upstream yet so I’m a bit limited in what I can discuss here. Therefore I’ll focus on the use case that my current patch stack is dealing with (whole wave functions). What all the use cases seem to have in common is non-trivial register usage in the AMDGPU backend, which is currently difficult to explain to the register allocator, liveness analysis and other passes - hence the desire to put things in separate functions, let the backend work with the representation that it understands, and only inline late in the pipeline when things won’t be moved around so much.
Why do we have whole wave functions to begin with?
Instructions on the AMDGPU are run by a group of threads executing in lockstep (aka a wave). Oftentimes only a subset of the threads are running, the others being masked away via the EXEC register; but sometimes we really want to execute code with all the threads enabled (e.g. in order to do efficient reductions) [1].
The usual way to express this is via the WWM (Whole Wave Mode) intrinsics (llvm.amdgcn.strict.wwm, llvm.amdgcn.set.inactive etc), which are used by the SIWholeQuadMode pass to figure out regions of code that need to run in WWM. But of course these aren’t formally regions, so sometimes code gets moved around and then our imaginary regions can get split in unfortunate ways. Furthermore, the placement of these intrinsics in non-obvious, and especially where a WWM region should begin is a bit subtle. There are also concerns about the robustness of WWM regions that contain complex control flow.
For one of our simpler use cases, where we only need to run a code snippet in WWM at the end of a shader, we introduced an alternative mechanism, the llvm.amdgcn.init.whole.wave intrinsic. This starts the whole function in WWM, then switches to the original EXEC mask, and then reconverges at the end of the function to run the WWM section. This sidesteps issues with the WWM intrinsics, but it still leads to increased register usage. We have tried to mitigate this by introducing the llvm.amdgcn.dead intrinsic, which has helped but not entirely fixed the problem.
Which leads us to the whole wave functions (the amdgpu_gfx_whole_wave calling convention). These are meant to contain a WWM section and are called via a special intrinsic (llvm.amdgcn.call.whole.wave). Since it’s a separate function, it won’t be broken up by early optimizations, and because IPRA is enabled by default on AMDGPU we get pretty good register allocation too. In the long term, if the whole wave functions + inlining achieves good performance, we would like to consider replacing all the other WWM and init.whole.wave intrinsics with this mechanism (I would feel much better if we already had some real-world performance numbers, but alas we currently don’t and there’s probably a lot of optimization work left to do).
Some notes about the implementation of the inliner
Adding a machine-level inliner is not exactly trivial. A lot of things are tied to the MachineFunction (e.g. MachineMemOperands) and need to be re-created in the caller. The frame info and machine function info also need to be updated. Most of this isn’t very difficult conceptually, it’s just work. Another issue that @shiltian pointed out is that if we inline machine functions, then the MIR and the IR become out of sync, which can be a problem if we emit any kind of information based on the IR (which we currently do e.g. in the form of the IR-based callgraph being dumped into the ELF file and which will obviously become outdated after inlining).
There are also some complications with the pass manager which warrant some discussion. In the current flow, the whole MIR pipeline is run one function at a time – each MachineFunction is generated, optimized and emitted in one go, and then we move on to the next (in call-graph order because that’s enabled by default on AMDGPU). However, if we want to do inlining, we need to have access to the MIR of the callees while processing the caller. This means we need to pause compilation for the callees at the time of inlining, then process all the callers, and only then can we free the MIR of the callees. In the new pass manager, this is handled by making the inliner a ModulePass. This means the MachineFunctions are all generated, then the inliner is run (which removes any fully inlined MachineFunctions[2] and marks them via ShouldNotProcessFunctionPassesAnalysis so they don’t get processed by future function passes), and then the rest of the pipeline is run on each of the functions that are left. In the legacy pass manager, the support is a bit more involved since there’s no built-in mechanism for doing this. I also didn’t want to disturb other targets or introduce new concepts into legacy infra, so instead I added a custom inlining-aware function pass manager used only on AMDGPU and only when inlining is enabled [3].
Alternative designs (that we haven’t prototyped yet)
One alternative that has been discussed internally is to introduce the concept of regions (similar to MLIR regions). This would make the WWM boundaries crystal clear but would probably involve a lot of heavy lifting to support properly. Another option that @arsenm suggested was to use WWM begin/end intrinsics with tokens. This sounds like an improvement over the current intrinsics, but I’m not sure how intuitive it is or how well it generalizes to other use cases [4].
What do you think? I hope this gives a good enough picture of the context. Should we explore other alternatives, or is the inliner palatable? Are there any other issues with the inliner that you would like to bring up, or anything that you consider a blocker?
Thanks for reading and I’m looking forward to your comments!
Footnotes:
[1] ISPC uses the ‘unmasked’ concept to represent these areas of code: Intel® ISPC User’s Guide. Note that ‘unmasked’ can also be applied to entire functions (which is kind of similar to our whole wave functions).
[2] At the moment this is all of them, but it in the future we should be able to support partial inlining.
[3] This still needs a tiny change to the infra, in order to make it possible for the function pass manager to be inherited. But otherwise it’s a pretty cool solution because it lets us keep only the MIR for the whole wave functions in memory; everything else is processed in one go, as before.
[4] As another example, consider dynamic VGPRs: Newer AMDGPU generations allow us to change the number of allocated VGPRs at runtime via the s_alloc_vgpr instruction. We currently take advantage of this by changing the allocation at function boundaries - the register allocator runs one function at a time, decides how many registers each function needs, and we call s_alloc_vgpr with a safe value before jumping to the next function (we use information about the raytracing pipeline to decide when to allocate down, since allocating back up might introduce a lot of stalls). It would be nice if we could inline some of these functions after regalloc. Doing things the other way around (i.e. identifying regions where it’s possible and desirable to change the VGPR allocation) would be much more involved.