13.5 IP-update optimization

Many primitives do not need an up-to-date IP. The IP-update optimization delays updating the IP until an up-to-date IP is needed. For the squared example, the result is:

<squared>     dup    1->2 
   $7F9F4AC739F7: mov    %r13,%r15
<squared+$8>  *    2->1 
   $7F9F4AC739FA: imul   %r15,%r13
<squared+$10> ;s    1->1 
   $7F9F4AC739FE: mov    (%r14),%rbx
   $7F9F4AC73A01: add    $0x8,%r14
   $7F9F4AC73A05: mov    (%rbx),%rax
   $7F9F4AC73A08: jmp    *%rax

In this case none of the primitives needs an up-to-date IP; the first two don’t access IP at all, while ;s writes to IP itself and therefore does not need an up-to-date IP, either.

If an up-to-date IP is needed, a code fragment is inserted that adds the appropriate number to the IP. There is only a limited amount of such fragments, but that’s good enough in most cases; and if it’s not enough one can insert several such fragments.

An up-to-date IP is typically needed for primitives that access immediate arguments, and before inserting a NEXT in front of a non-relocatable primitive, and at a control-flow join. The IP-update optimization also reduces the need for an up-to-date IP for immediate arguments by providing variants of the most common primitives with immediate arguments that work with an outdated IP (up to some limit).

One might think that the lower number of instructions is the main benefit of this optimization, and this benefit exists, but is relatively small (speedups factors around 1.1 on high-performance hardware on call-dominated benchmarks). But the major benefit on modern high-performance hardware is that in loop-dominated programs the critical data-dependence chain is shortened, resulting in a speedup by a factor of 2 or more for these programs on recent high-performance cores, when the following suboptimization is also performed.

Without IP update optimization branch and friends use a load (3-5 cycles latency) to get the target address. The IP-update optimization changes the IP with an add (at the beginning of the branch code) to point to the target, so the load is now unnecessary. This can be seen in the mymin example:

gforth-fast --opt-ip-updates=0   gforth-fast
<mymin>       2dup    1->3       <mymin>       2dup    1->3    
 add    $0x8,%rbx                                              
 mov    0x8(%r10),%r15            mov    0x8(%r10),%r15        
 mov    %r13,%r9                  mov    %r13,%r9              
<mymin+$8>    <    3->2          <mymin+$8>    <    3->2       
 add    $0x8,%rbx                                              
 cmp    %r9,%r15                  cmp    %r9,%r15              
 setl   %r15b                     setl   %r15b                 
 movzbl %r15b,%r15d               movzbl %r15b,%r15d           
 neg    %r15                      neg    %r15                  
<mymin+$10>   ?branch    2->1    <mymin+$10>   ?branch    2->1 
<mymin+$18>   <mymin+$38>        <mymin+$18>   <mymin+$38>     
 add    $0x10,%rbx                add    $0x38,%rbx            
 mov    -0x8(%rbx),%rsi           mov    (%rbx),%rax           
 test   %r15,%r15                 test   %r15,%r15             
 jne    <to the next add>         jne    <to the next mov>
 mov    (%rsi),%rax                                            
 mov    %rsi,%rbx                                              
 jmp    *%rax                     jmp    *%rax                 
<mymin+$20>   drop    1->1       <mymin+$20>   drop    1->1    
 add    $0x8,%rbx                                              
 mov    0x8(%r10),%r13            mov    0x8(%r10),%r13        
 add    $0x8,%r10                 add    $0x8,%r10             
<mymin+$28>   branch    1->1     <mymin+$28>   branch    1->1  
<mymin+$30>   <mymin+$40>        <mymin+$30>   <mymin+$40>     
 add    $0x10,%rbx                add    $0x8,%rbx             
 mov    -0x8(%rbx),%rbx                                        
 mov    (%rbx),%rax               mov    (%rbx),%rax           
 jmp    *%rax                     jmp    *%rax                 
<mymin+$38>   nip    1->1        <mymin+$38>   nip    1->1     
 add    $0x8,%rbx                 add    $0x8,%r10             
 add    $0x8,%r10                 add    $0x8,%rbx             
<mymin+$40>   ;s    1->1         <mymin+$40>   ;s    1->1      
 mov    (%r14),%rbx               mov    (%r14),%rbx           
 add    $0x8,%r14                 add    $0x8,%r14             
 mov    (%rbx),%rax               mov    (%rbx),%rax           
 jmp    *%rax                     jmp    *%rax                 

The IP update in the nip code is due to having a control-flow join (then) after the nip. The IP is dead at that point, but Gforth’s code generator does not look ahead far enough to know that and leave it away.

One other thing you may notice is that the static superinstruction for < ?branch is not used. That’s because stack caching results in a shorter code sequence than the static superinstruction (which with the current limitations would require the 1->1 variant of 2dup.

The IP-update optimization is controlled with --opt-ip-updates=n, where n=0 turns off the optimization completely, other values are described in more detail in the See option description.

You can read more about the IP-update optimization in: M. Anton Ertl, Bernd Paysan, The Performance Effects of Virtual-Machine Instruction Pointer Updates, In 38th European Conference on Object-Oriented Programming (ECOOP 2024).