https://gcc.gnu.org/bugzilla/show_bug.cgi?id=126678

            Bug ID: 126678
           Summary: Defaulted operator== on an iota_view value type makes
                    the enclosing loop   be estimated ~7x colder than an
                    identical hand-written operator==
           Product: gcc
           Version: 16.1.0
            Status: UNCONFIRMED
          Severity: normal
          Priority: P3
         Component: c++
          Assignee: unassigned at gcc dot gnu.org
          Reporter: philodej at gmail dot com
  Target Milestone: ---

Given a single-member "strong index" type used as the value type of a           
  std::ranges::iota_view, replacing a hand-written                              

      bool operator==(const Idx& r) const noexcept { return v == r.v; }         

  with the equivalent                                                           

      bool operator==(const Idx&) const noexcept = default;                     

  makes GCC estimate the loop that iterates that view roughly 7x colder,        
  which then degrades code generation in the loop body. The two forms are       
  semantically identical and produce identical code for the operator itself     
  (it is inlined away in the early inliner either way), so the difference is    
  entirely in downstream analysis of the caller.                                

  Idx::operator== is what terminates the iota_view loop, so it sits on the      
  loop's back edge.                                                             

  Observed at -O3 (see the reproducer for exact flags and expected output):     

    - -fopt-info-inline-all reports the callee being inlined into the loop
      as the same clone at the same size in both variants, but with a ~7x
      difference in estimated time.

    - As a consequence the register allocator treats the defaulted variant's
      loop as cold: it keeps loop-carried values in caller-saved registers
      and spills/reloads five of them around a call inside the loop, where
      the hand-written variant promotes them to callee-saved registers and
      spills only one.

    - -fopt-info-loop-all additionally attributes the loop to a different
      source location in the two variants -- the loop itself for the
      hand-written form, but the operator== line for the defaulted one.
      This one shows at -O2 as well and does not depend on any inlining
      parameter, which suggests the loop's profile / trip-count information
      is being lost rather than the cost model merely disagreeing.

  The last point is probably the most useful starting place: it looks like a
  profile/location attribution problem rather than an inliner tuning issue.

Compiler Explorer: https://godbolt.org/z/4EKjWzT7q

The difference is in the register-pressure handling around the Calc::update 
call inside the loop:

  explicit (time 260.9) — spills only the accumulator:
  asm
  .L17:   movsd   QWORD PTR [rsp+8], xmm2     ; save accumulator
          call    "Calc::update(double, double)"
          movsd   xmm2, QWORD PTR [rsp+8]     ; restore
          jmp     .L15

  2 memory ops. It kept rbp/r13/rbx/r12 as callee-saved registers across 
  the loop, so the loop-carried values survive the call for free.

  defaulted (time 37.9) — spills five values:
  asm
  .L17:   mov     QWORD PTR [rsp+24], rcx     ; save 'this'
          mov     DWORD PTR [rsp+20], r8d     ; save n
          mov     DWORD PTR [rsp+16], esi     ; save loop counter
          mov     QWORD PTR [rsp], rdi        ; save &calc[k]
          movsd   QWORD PTR [rsp+8], xmm2     ; save accumulator
          call    "Calc::update(double, double)"
          mov     rcx, QWORD PTR [rsp+24]     ; restore x5
          mov     r8d, DWORD PTR [rsp+20]     
          mov     esi, DWORD PTR [rsp+16]
          movsd   xmm2, QWORD PTR [rsp+8]
          mov     rdi, QWORD PTR [rsp]
          jmp     .L15

  10 memory ops instead of 2 — every iteration that takes the Calc::update 
  path pays 8 extra loads/stores.

  Because the defaulted variant's loop is estimated as cold (time 37.9), 
  the register allocator doesn't bother promoting the loop-carried values 
  to callee-saved registers; it uses caller-saved ones 
  (rcx/r8/rsi/rdi/r9/r10/r11) and spills them around every call. 
  The explicit variant, seen as hot, sets up callee-saved registers 
  in the prologue and the call costs almost nothing.

----

  Not a duplicate of PR 108953. That bug is about the defaulted operator's own
  body being suboptimal -- multiple member comparisons not being coalesced into
  wider loads, for a 7-member struct, contrasted against memcmp and clang.

  Here the type has a single int16_t member, the defaulted operator's codegen
is
  byte-identical to the hand-written one, and it is inlined away in the early
  inliner in both variants. Nothing about the operator's body differs. The
  regression is entirely in downstream analysis of the *caller*.
  Fixing the coalescing in PR 108953 would not affect this case.

Reply via email to