Repository navigation
Optimize movz/movk on ARM64 #34732
Description
Since ARM64 has an ISA with fixed 32-bit instruction width, the move instructions have space for 16-bit unsigned immediate. To move bigger immediate value, we need to move the value in multiple steps using chunks of 16-bits (movz/movk). Due to this, multiple mov instructions are generated to load a single bigger value in register in contrast to x64 where a single mov can load bigger immediate.
I wanted to see if it has been already evaluated or if it is feasible to store such immediate values in a literal pool and load the values from the pool using ldr instead of moving using multi-step movz/movk. I know this would involve accessing memory instead and might end up being slower. In fact, the examples I have tried on clang/gcc, they use movz/movk mostly.
I have also noticed cases where we try to move 2 immediates back to back that are just bytes apart from each other. E.g. Below is the code generated for Vector4.Add(Vector4, Vector4) method where it tries to load the parameters before performing fadd.
D29D9900 movz x0, #0xecc8
F2A785E0 movk x0, #0x3c2f LSL #16
F2C051C0 movk x0, #654 LSL #32
F9400000 ldr x0, [x0] # <==== loads from address 0x6543c2fecc8
FD400410 ldr d16, [x0,#8]
D29D9800 movz x0, #0xecc0
F2A785E0 movk x0, #0x3c2f LSL #16
F2C051C0 movk x0, #654 LSL #32 # <==== loads from address 0x6543c2fecc0
F9400000 ldr x0, [x0]
FD400411 ldr d17, [x0,#8]
0E31D610 fadd v16.2s, v16.2s, v17.2sThe 2nd set of move instructions can be optimized by doing sub x0, x0, 8.
Just to get the stats to see how often we need to generate higher immediate values, I ran crossgen tool on all (or most) of the libraries and dumped the assembly code produced from it for ARM64. Then I extracted out the methods and the portion of code that uses movz/movk pair to load the immediates. There were total 191028 methods crossgened out of which 4578 methods contained pair of movz/movk. In all there were 11856 groups of movz/movk instructions.
You can see the summary in movs.txt.
Some of the questions I am thinking about on seeing the above results:
- Some of the
Utf8Parsermethods loads constant multiple times and can it be optimized to reuse the value? - Is it possible to re-evaluate constant propagation for certain scenarios where the value of constant is big and since it gets propagated, we end up creating these mov sequence multiple times?
- Should we tweak our inline heuristics because of the amount of code generated for ARM64 is more than that for Intel?
- Can we eliminate some of the
movz/movkby doing arithmetic manipulation, the way I mentioned in above example? - And finally, is it beneficial to use literal pool table for certain scenarios?
References:
- https://dinfuehr.github.io/blog/encoding-of-immediate-values-on-aarch64/
- https://azeria-labs.com/memory-instructions-load-and-store-part-4/
- http://www.keil.com/support/man/docs/armasm/armasm_dom1359731147760.htm
category:cq
theme:optimization
skill-level:intermediate
cost:medium
Activity
Dotnet-GitSync-Bot commentedon Apr 9, 2020
I couldn't figure out the best area label to add to this issue. Please help me learn by adding exactly one area label.
kunalspathak commentedon Apr 9, 2020
@TamarChristinaArm , @BruceForstall , @sdmaclea, @dotnet/jit-contrib
TamarChristinaArm commentedon Apr 9, 2020
Hmm I wouldn't add yet another indirection here with the literal pool. I'm assuming that the address it's building is the address of the managed heap?
Since it's loading an address into
x0and then load a value based on that address.So isn't
0x6543c2fecc8the address of a value in the heap?You don't need this if it kept the value live longer. I think this is what you meant with
no# 2in your list right?The block above could be
So essentially you pick an anchor use offsets based on that.
If you know the PC value during assembling you can also use
adrp + add.If Indeed the code above is addressing some kind of heap, and if the heap can be aligned to a 4K boundary then you can do
to replace the three movs. On modern cores the latencies would be the same though, but might be easier to share
x0this way if you can't do it via the addressing mode.sdmaclea commentedon Apr 9, 2020
So in general I would suspect moving mov/movk to literal pool would reduce performance.
We would know the PC during live JIT, but not during crossgen. We need relocatable code. The loader could fix up these non-relocatable entries but I would doubt it is worth it.
sdmaclea commentedon Apr 9, 2020
I thought I had added
movnto themov/movkmix. It helped a bit with code density with negative constants.TamarChristinaArm commentedon Apr 9, 2020
Is there no support for relocations?
CarolEidt commentedon Apr 9, 2020
The register allocator attempts to reuse constants within a
BasicBlockbut it has no notion of reusing a partial constant - i.e. if we have the same high bits in one constant as another, we could in theory avoid redefining those high bits, but that would require a different kind of analysis, and some way to communicate the partial-reuse state of these constants.sdmaclea commentedon Apr 9, 2020
There is some support. I am not sure how extensive it is. It would require relocation table entries which would bloat/complicate the code and slow loading. The relocation table would require making the loaded code writable, not share with other processes, and run relocation code. It could be expensive from a memory usage perspective.
That was why I guessed it would work better for live JIT in later optimization tiers.
You mentioned constants using PC relative constants to reach the heap. It seems like that would be a new relocation feature....
briansull commentedon Apr 9, 2020
The CSE phase currently doesn't try to CSE identical large constants, or constants that are within a short distance to each other. It is something that I want to look into.
kunalspathak commentedon Apr 10, 2020
@briansull - Once we do this, will it optimize these patterns? Here it loads the same MethodTable address twice to create different array size.
CarolEidt commentedon Apr 10, 2020
@kunalspathak - that's a case where the register allocator might have reused the constant, except that it's only opportunistic and it doesn't do any analysis ahead of time to figure out that in order to reuse in this case it would need to allocate a callee-save register. I wonder if it might be useful to add some analysis for that (i.e. how often would it help, and what would the additional analysis cost in throughput)?
briansull commentedon Apr 10, 2020
In the above case I believe that we compute the constant into a scratch register that is killed by the call to the JitHelper, so it doesn't get reused.
With the CSE approach we have heuristics that try to balance the cost of allocating a new local variable, (in a caller saved register) and possibly having to spill and reload an extra register in the prolog and epilog to hold this constant value in a register across a call.
These CSE heuristics just look at a few things and it doesn't do any global analysis. So if the heuristic finds that 80% of the cases that match the current CSE candidate are profitable, it would make a CSE.
TamarChristinaArm commentedon Apr 14, 2020
But it sounds like whatever you do with CSE you'd still need this changed right? You don't want it to spill and reload it after the call when it had plenty of free registers available. (In this case also not move it to some non-caller saved registers if it would have allocated to that register to begin with.)
Out of pure curiosity, is most things such as this implemented as a single pass? e.g. can the register allocator tell that there's a function call coming up and which ABI it needs?
Not saying it should for this case. Just wondering what information I can reasonably expect the RA to know.
briansull commentedon Apr 14, 2020
When we make it into a CSE we assign the constant into a GT_LCl_VAR which the register allocator knows how to handle properly. It will typically correctly pick either a caller cased or scratch register to hold the CSE value, depending upon the liveness requirements.
briansull commentedon Apr 14, 2020
I will investigate this issue further, so I have assigned this issue to myself.
CarolEidt commentedon Apr 14, 2020
The register allocator, in its build phase, will record which values are live across a call, and will attempt to put them in callee-save registers. Note, though, that we don't have any form of rematerialization (#6264) to allow the constant to be reconstructed if it's spilled, so if it doesn't get a callee-save register it will be spilled and restored from the stack.
kunalspathak commentedon Apr 15, 2020
Another example taken from Rand
We generate asm code:
TamarChristinaArm commentedon Apr 16, 2020
You can just read from
x0both times, so you don't need/want amovthere :)kunalspathak commentedon Apr 16, 2020
True. :)
briansull commentedon Jun 25, 2020
The change #39096 will fix this issue
briansull commentedon Jul 29, 2020
Fixed with #39096