https://gcc.gnu.org/bugzilla/show_bug.cgi?id=128102
Bug ID: 128102
Summary: [12/13/14/15/16/17 Regression] GCC 12+ keeps a small
state-machine loop rolled, 1.85x slower than GCC 11 on
x86-64
Product: gcc
Version: 17.0
Status: UNCONFIRMED
Severity: normal
Priority: P3
Component: tree-optimization
Assignee: unassigned at gcc dot gnu.org
Reporter: yansendao at 126 dot com
Target Milestone: ---
Created attachment 65933
--> https://gcc.gnu.org/bugzilla/attachment.cgi?id=65933&action=edit
Reduced testcase, self-contained C (no #include)
A reduced testcase (from binascii_a2b_base64() in CPython) is a byte-at-a-time
loop containing a 4-state machine. GCC 11 replicates the loop body through
jump threading, so quad_pos is a compile-time constant on each replicated path
and the switch disappears from the hot loop. GCC 12 and later keep the loop
rolled and execute a 4-way dispatch on every input byte; the loop is then
1.85x slower on x86-64. No PGO or LTO is involved.
Host type: x86_64-pc-linux-gnu
Target type: x86_64-pc-linux-gnu
Build type: x86_64-pc-linux-gnu
GCC version: 17.0.0 20261023 (experimental)
Machine: Hygon C86-4G (OPN:7492H), 2.6 GHz, Linux 5.15.0-25-generic,
binutils 2.38.
Compile command:
gcc -v -save-temps -O3 -o repro b64dec_repro.c
gcc -v output:
Using built-in specs.
COLLECT_GCC=/data/yansendao/tmp/gcc17-install/bin/gcc
COLLECT_LTO_WRAPPER=/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/lto-wrapper
Target: x86_64-pc-linux-gnu
Configured with: /data/yansendao/tmp/gcc17-src/configure
--prefix=/data/yansendao/tmp/gcc17-install --disable-bootstrap
--enable-languages=c --disable-multilib --disable-libsanitizer --disable-libssp
--disable-libquadmath --disable-libgomp --disable-libatomic --disable-nls
--disable-werror --with-system-zlib
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 17.0.0 20261023 (experimental) (GCC)
COLLECT_GCC_OPTIONS='-v' '-save-temps' '-O3' '-o' 'repro' '-mtune=generic'
'-march=x86-64' '-dumpdir' 'repro-'
/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/cc1 -E
-quiet -v -imultiarch x86_64-linux-gnu ../b64dec_repro.c -mtune=generic
-march=x86-64 -O3 -fpch-preprocess -o repro-b64dec_repro.i
ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu"
ignoring nonexistent directory
"/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/../../../../x86_64-pc-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/include
/usr/local/include
/data/yansendao/tmp/gcc17-install/include
/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/include-fixed/x86_64-linux-gnu
/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/include-fixed
/usr/include/x86_64-linux-gnu
/usr/include
End of search list.
COLLECT_GCC_OPTIONS='-v' '-save-temps' '-O3' '-o' 'repro' '-mtune=generic'
'-march=x86-64' '-dumpdir' 'repro-'
/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/cc1
-fpreprocessed repro-b64dec_repro.i -quiet -dumpdir repro- -dumpbase
b64dec_repro.c -dumpbase-ext .c -mtune=generic -march=x86-64 -O3 -version -o
repro-b64dec_repro.s
GNU C23 (GCC) version 17.0.0 20261023 (experimental) (x86_64-pc-linux-gnu)
compiled by GNU C version 11.4.0, GMP version 6.3.0, MPFR version
4.1.0, MPC version 1.3.1, isl version isl-0.24-GMP
GGC heuristics: --param ggc-min-expand=30 --param ggc-min-heapsize=4096
Compiler executable checksum: e53601b82bc8e4f1360421dd3998966a
COLLECT_GCC_OPTIONS='-v' '-save-temps' '-O3' '-o' 'repro' '-mtune=generic'
'-march=x86-64' '-dumpdir' 'repro-'
as -v --64 -o repro-b64dec_repro.o repro-b64dec_repro.s
GNU assembler version 2.38 (x86_64-linux-gnu) using BFD version (GNU
Binutils for Ubuntu) 2.38
COMPILER_PATH=/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/:/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/:/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/:/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/:/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/
LIBRARY_PATH=/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/:/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/../../../../lib64/:/lib/x86_64-linux-gnu/:/lib/../lib64/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib64/:/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-v' '-save-temps' '-O3' '-o' 'repro' '-mtune=generic'
'-march=x86-64' '-dumpdir' 'repro.'
/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/collect2
-plugin
/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/liblto_plugin.so
-plugin-opt=/data/yansendao/tmp/gcc17-install/libexec/gcc/x86_64-pc-linux-gnu/17.0.0/lto-wrapper
-plugin-opt=-fresolution=repro.res -plugin-opt=-pass-through=-lgcc
-plugin-opt=-pass-through=-lgcc_s_asneeded -plugin-opt=-pass-through=-lc
-plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s_asneeded
--eh-frame-hdr -m elf_x86_64 -dynamic-linker /lib64/ld-linux-x86-64.so.2 -o
repro /lib/x86_64-linux-gnu/crt1.o /lib/x86_64-linux-gnu/crti.o
/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/crtbegin.o
-L/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0
-L/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/../../../../lib64
-L/lib/x86_64-linux-gnu -L/lib/../lib64 -L/usr/lib/x86_64-linux-gnu
-L/usr/lib/../lib64
-L/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/../../..
-L/lib -L/usr/lib repro-b64dec_repro.o -lgcc -lgcc_s_asneeded -lc -lgcc
-lgcc_s_asneeded
/data/yansendao/tmp/gcc17-install/lib/gcc/x86_64-pc-linux-gnu/17.0.0/crtend.o
/lib/x86_64-linux-gnu/crtn.o
COLLECT_GCC_OPTIONS='-v' '-save-temps' '-O3' '-o' 'repro' '-mtune=generic'
'-march=x86-64' '-dumpdir' 'repro.'
Compiler output: none (exit status 0, no diagnostics).
Preprocessed source (repro-b64dec_repro.i, from -save-temps):
# 0 "../b64dec_repro.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3
# 0 "<command-line>" 2
# 1 "../b64dec_repro.c"
typedef long unsigned int size_t;
typedef unsigned char u8;
static u8 table_a2b_base64[256];
__attribute__ ((noinline, noclone)) size_t
decode_b64 (const u8 *restrict src, size_t len, u8 *restrict dst)
{
u8 *out = dst;
int quad_pos = 0;
u8 leftchar = 0;
for (size_t i = 0; i < len; i++)
{
u8 this_ch = src[i];
if (this_ch == '=')
continue;
this_ch = table_a2b_base64[this_ch];
if (this_ch >= 64)
continue;
switch (quad_pos)
{
case 0:
quad_pos = 1;
leftchar = this_ch;
break;
case 1:
quad_pos = 2;
*out++ = (u8) ((leftchar << 2) | (this_ch >> 4));
leftchar = this_ch & 0x0f;
break;
case 2:
quad_pos = 3;
*out++ = (u8) ((leftchar << 4) | (this_ch >> 2));
leftchar = this_ch & 0x03;
break;
case 3:
quad_pos = 0;
*out++ = (u8) ((leftchar << 6) | this_ch);
leftchar = 0;
break;
}
}
return (size_t) (out - dst);
}
int
main (void)
{
enum { N = 1 << 22, REPS = 20 };
static u8 src[N], dst[N];
static const char b64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t sum = 0;
for (int i = 0; i < 256; i++)
{
int v = 255;
if (i >= 'A' && i <= 'Z') v = i - 'A';
else if (i >= 'a' && i <= 'z') v = i - 'a' + 26;
else if (i >= '0' && i <= '9') v = i - '0' + 52;
else if (i == '+') v = 62;
else if (i == '/') v = 63;
table_a2b_base64[i] = (u8) v;
}
for (size_t i = 0; i < N; i++)
src[i] = (u8) b64[i & 63];
for (int r = 0; r < REPS; r++)
sum += decode_b64 (src, N, dst);
unsigned long long t0 = __builtin_ia32_rdtsc ();
for (int r = 0; r < REPS; r++)
sum += decode_b64 (src, N, dst);
unsigned long long t1 = __builtin_ia32_rdtsc ();
__builtin_printf ("%llu cycles for %d bytes, %.2f cycles/byte
(sum=%zu)\n",
t1 - t0, REPS * N, (double) (t1 - t0) / (REPS *
(double) N),
sum);
return 0;
}
Measurement (each binary run 3 times, pinned with numactl -m 5 -N 5,
rdtsc-timed, 84 MiB of input per run):
compiler decode_b64 size cmp $0x3d cycles/byte vs GCC
11
GCC 11.4.0 0x2c3 11 2.23
1.00
GCC 12.3.0 (Ubuntu) 0xdb 1 3.93
1.76x
GCC 12.3.0 (vanilla) 0xc3 1 3.72
1.67x
GCC 12.5.0 0xc3 1 3.67
1.65x
GCC 13.4.0 0xdb 1 3.57
1.60x
GCC 17.0.0 (trunk) 0xe3 1 4.12
1.85x
cmp $0x3d is the "this_ch == '='" test, executed once per input byte.
GCC 11 contains 11 copies of it because the loop body has been replicated and
the state variable is constant on every replicated path. GCC 12+ contains
exactly one copy, i.e. one 4-way dispatch per byte.
The same difference is present at -O2: GCC 11 2.83 cycles/byte,
GCC 12.3 4.47, GCC 17 4.07.
The code generated by GCC 12.3.0 (Ubuntu 12.3.0-1ubuntu1~22.04.3), by a
self-built vanilla 12.3.0, 12.5.0 and 13.4.0 is structurally identical to the
GCC 17 output below.
Disassembly
-----------
GCC 11.4.0: the switch is gone from the loop body; the decoder advances
through states 1 -> 2 -> 3 in straight-line code:
12f6: movzbl (%r9,%rdx,1),%ecx
12fb: cmp $0x3d,%cl
12fe: je 1420
1304: movzbl (%r11,%rcx,1),%ecx
1309: cmp $0x3f,%cl
130c: ja 1420
1312: push %rbx
1313: cmp $0x2,%edi ; only checked on entry to the
1316: je 14c8 ; replicated body
131c: cmp $0x3,%edi
131f: je 13e1
1325: cmp $0x1,%edi
1328: je 135c
132a: lea 0x1(%rdx),%rdi
132e: cmp %rdi,%r8
1331: jbe 1445
1337: movzbl 0x1(%r9,%rdx,1),%esi
133d: cmp $0x3d,%sil
...
135c: mov %ecx,%ebx ; state 1 -> 2, no dispatch
135e: shl $0x2,%esi
1365: shr $0x4,%bl
1368: or %ebx,%esi
136e: mov %sil,(%rax)
...
GCC 17.0.0 trunk: a 4-way dispatch is executed for every byte.
0000000000401300 <decode_b64>:
401300: mov %rdx,%r9
401303: test %rsi,%rsi
401306: je 4013e0 <decode_b64+0xe0>
40130c: lea (%rdi,%rsi,1),%r8
401310: mov %rdi,%rdx
401313: mov %r9,%rax
401316: xor %esi,%esi
401318: xor %edi,%edi
40131a: jmp 401369 <decode_b64+0x69>
401320: cmp $0x1,%edi
401323: jne 4013d0 <decode_b64+0xd0>
401329: mov %ecx,%edi
40132b: shl $0x2,%esi
40132e: add $0x1,%rax
401332: shr $0x4,%dil
401336: or %edi,%esi
401338: mov $0x2,%edi
40133d: mov %sil,-0x1(%rax)
401341: mov %ecx,%esi
401343: and $0xf,%esi
401360: add $0x1,%rdx
401364: cmp %rdx,%r8
401367: je 4013a1 <decode_b64+0xa1>
401369: movzbl (%rdx),%ecx
40136c: cmp $0x3d,%cl
40136f: je 401360 <decode_b64+0x60>
401371: movzbl 0xc04060(%rcx),%ecx
401378: cmp $0x3f,%cl
40137b: ja 401360 <decode_b64+0x60>
40137d: cmp $0x2,%edi ; dispatch on quad_pos,
401380: je 4013a8 <decode_b64+0xa8> ; every iteration
401382: cmp $0x3,%edi
401385: jne 401320 <decode_b64+0x20>
401387: shl $0x6,%esi
40138a: add $0x1,%rdx
40138e: add $0x1,%rax
401392: xor %edi,%edi
...
4013a8: mov %ecx,%edi
4013aa: shl $0x4,%esi
4013ad: add $0x1,%rax
4013b1: shr $0x2,%dil
4013b5: or %edi,%esi
4013b7: mov $0x3,%edi
4013bc: mov %sil,-0x1(%rax)
4013c0: mov %ecx,%esi
4013c2: and $0x3,%esi
4013c5: jmp 401360 <decode_b64+0x60>
Where the trees diverge
------------------------
-fdump-tree-all shows that in GCC 11 the duplication happens in the GIMPLE
jump threader: the number of "this_ch == 61" tests in the function is 2 up to
modref1, 5 after thread1, and 13 after thread2 (threading through the loop
back edge). In GCC 12.3 and GCC 17 the count stays at 2 through the whole
pipeline; only switchlower1 makes a second copy. The state-machine loop is
never threaded in GCC 12+.
I could not find a single -f option that disables the GCC 11 transformation:
-fno-unroll-loops, -fno-peel-loops and -fno-tree-loop-optimize all still
produce the 0x2c3 / 11-copy version, which points at jump threading rather
than loop unrolling.
Workaround / partial recovery
-----------------------------
-funroll-loops helps only partially and is far from GCC 11:
compiler cycles/byte (-O3) cycles/byte (-O3 -funroll-loops)
GCC 11.4.0 2.23 2.28
GCC 12.3.0 3.72 3.36
GCC 17.0.0 4.12 3.37
Real-world impact
-----------------
CPython 3.12 built with --enable-optimizations --with-lto on this machine:
binascii.a2b_base64 over 1 MB takes 21.4 ms with GCC 11.4 and 47.2 ms with
GCC 12.3 (2.2x); the pyperformance base64_large benchmark is 1.64x slower.
I have not bisected this to a specific commit; if that would help, I can do so.