Девиртуализация в последних версиях gcc и clang

от автора

Что это вообще такое

Девиртуализация (devirtualization) — оптимизация виртуальных функций. Если компилятор точно знает тип объекта, он может вызывать его виртуальные функции напрямую, не используя таблицу виртуальных функций.
В этой статье мы проверим насколько хорошо с этой задачей справляются компиляторы gcc и clang.

Тестирование

Все тесты производились на Arch Linux x86-64. Использовались gcc 4.8.2 и clang 3.3.

вывод gcc -v

Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-unknown-linux-gnu/4.8.2/lto-wrapper
Target: x86_64-unknown-linux-gnu
Configured with: /build/gcc-multilib/src/gcc-4.8.2/configure —prefix=/usr —libdir=/usr/lib —libexecdir=/usr/lib —mandir=/usr/share/man —infodir=/usr/share/info —with-bugurl=https://bugs.archlinux.org/ —enable-languages=c,c++,ada,fortran,go,lto,objc,obj-c++ —enable-shared —enable-threads=posix —with-system-zlib —enable-__cxa_atexit —disable-libunwind-exceptions —enable-clocale=gnu —disable-libstdcxx-pch —disable-libssp —enable-gnu-unique-object —enable-linker-build-id —enable-cloog-backend=isl —disable-cloog-version-check —enable-lto —enable-plugin —with-linker-hash-style=gnu —enable-multilib —disable-werror —enable-checking=release
Thread model: posix
gcc version 4.8.2 (GCC)

вывод clang -v

clang version 3.3 (tags/RELEASE_33/final)
Target: x86_64-unknown-linux-gnu
Thread model: posix

Чтобы было проще разбираться в дизассемблированном коде, использовался флаг -nostartfiles. Если его указать, то компилятор не будет генерировать код, вызывающий функцию main с нужными параметрами. Функция, которая получает управление первой, называется _start.

В коде, который мы будем компилировать, содержится два класса:

  • класс A — абстрактный класс с трёмя методами: increment(), decrement() и get()
    class A { public: 	virtual ~A() { 	} 	virtual void increment() = 0; 	virtual void decrement() = 0; 	virtual int get() = 0; };
  • класс B — класс наследующийся от А и реализующий все абстрактные методы
    class B : public A { public: 	B() : x(0) { 	} 	virtual void increment() { 		x++; 	} 	virtual void decrement() { 		x--; 	} 	virtual int get() { 		return x; 	} private: 	int x; };
Версия 1

Всё в одном файле.

код

class A { public: 	virtual ~A() { 	} 	virtual void increment() = 0; 	virtual void decrement() = 0; 	virtual int get() = 0; };  class B : public A { public: 	B() : x(0) { 	} 	virtual void increment() { 		x++; 	} 	virtual void decrement() { 		x--; 	} 	virtual int get() { 		return x; 	} private: 	int x; };   extern "C" {  int printf(const char * format, ...); void exit(int status);  void _start() { 	B b; 	b.increment(); 	b.increment(); 	b.decrement(); 	printf("%d\n", b.get()); 	exit(0); }  } 

Результат: gcc с флагами -O1, -O2, -O3, -Os и clang с флагами -O2, -O3, -Os произвели девиртуализацию и поняли, что второй аргумент функции printf всегда равен 1. Код, сгенерированный с помощью gcc -O1:

<_start>:     sub    rsp,0x8      ; вызов printf     mov    esi,0x1       ; записываем значение b.get() в ESI     mov    edi,0x4003a2  ; записываем адрес строки "%s\n" в EDI     mov    eax,0x0     call   400360 <printf@plt>  ; вызываем printf      ; вызов exit     mov    edi,0x0            ; записываем код ошибки в регистр EDI     call   400370 <exit@plt>  ; вызываем exit

Версия 2

Всё в одном файле, вызываем виртуальные методы через указатель на базовый класс

код

class A { public: 	virtual ~A() { 	} 	virtual void increment() = 0; 	virtual void decrement() = 0; 	virtual int get() = 0; };  class B : public A { public: 	B() : x(0) { 	} 	virtual void increment() { 		x++; 	} 	virtual void decrement() { 		x--; 	} 	virtual int get() { 		return x; 	} private: 	int x; };   extern "C" {  int printf(const char * format, ...); void exit(int status);  void _start() { 	A * a = new B; 	a->increment(); 	a->increment(); 	a->decrement(); 	printf("%d\n", a->get()); 	exit(0); }  } 

Результат: clang с флагами -O2, -O3, -Os генерирует такой же код, что и в варианте 1. gcc ведёт себя странно: с флагами -O1, -O2, -O3, -Os он генерирует такой код:

<_start>:     push   rbx      ; выделение памяти     mov    edi,0x10            ; кол-во байт (16)     call   400560 <_Znwm@plt>  ; вызываем функцию, выделяющую память (возвращает указатель в RAX)     mov    rbx,rax             ; сохраняем указатель на экземпляр класса в RBX      ; конструктор     mov    QWORD PTR [rax],0x4006d0  ; инициализируем таблицу виртуальных функций     mov    DWORD PTR [rax+0x8],0x1   ; инициализируем поле x единицей (первый вызов increment заинлайнился)      ; второй вызов increment     mov    rdi,rax                     ; записываем указатель на экземпляр класса в RDI     call   4005ca <_ZN1B9incrementEv>  ; вызываем increment      ; вызов decrement     mov    rax,QWORD PTR [rbx]   ; записываем указатель на таблицу виртуальных функций в RAX     mov    rdi,rbx               ; записываем указатель на экземпляр класса в RDI     call   QWORD PTR [rax+0x18]  ; вызываем decrement через таблицу виртуальных функций      ; вызов get     mov    rax,QWORD PTR [rbx]   ; записываем указатель на таблицу виртуальных функций в RAX     mov    rdi,rbx               ; записываем указатель на экземпляр класса в RDI     call   QWORD PTR [rax+0x20]  ; вызываем get через таблицу виртуальных функций (результат в EAX)      ; вызов printf     mov    esi,eax              ; записываем значение b.get() в ESI     mov    edi,0x400620         ; записываем адрес строки "%s\n" в EDI     mov    eax,0x0     call   400520 <printf@plt>  ; вызываем printf      ; вызов exit     mov    edi,0x0            ; записываем код ошибки в регистр EDI     call   400370 <exit@plt>  ; вызываем exit

Версия 3

Для каждого класса отдельный .hpp и .cpp файл

код

a.hpp

#pragma once  class A { public: 	virtual ~A();  	virtual void increment() = 0; 	virtual void decrement() = 0; 	virtual int get() = 0; }; 

a.cpp

#include "a.hpp"  A::~A() { } 

b.hpp

#pragma once  #include "a.hpp"  class B : public A { public: 	B(); 	virtual void increment(); 	virtual void decrement(); 	virtual int get(); private: 	int x; }; 

b.cpp

#include "b.hpp"  B::B() : x(0) { }  void B::increment() { 	x++; }  void B::decrement() { 	x--; }  int B::get() { 	return x; } 

test.cpp

#include "b.hpp"  extern "C" {  int printf(const char * format, ...); void exit(int status);  void _start() { 	B b; 	b.increment(); 	b.increment(); 	b.decrement(); 	printf("%d\n", b.get()); 	exit(0); }  } 

Результат: оба компилятора успешно девиртуализировали все функции, но не смогли их заинлайнить, так как они находятся в разных единицах трансляции:

<_start>:     push   rbx     sub    rsp,0x10     ; выделяем пямять на стеке      ; вызов конструктора     lea    rbx,[rsp]           ; сохраняем указатель на экземпляр класса в RBX     mov    rdi,rbx             ; записываем указатель на экземпляр класса в RDI     call   400720 <_ZN1BC1Ev>  ; вызываем конструктор      ; вызов increment     mov    rdi,rbx                     ; записываем указатель на экземпляр класса в RDI     call   400740 <_ZN1B9incrementEv>  ; вызываем increment      ; вызов increment     lea    rdi,[rsp]                   ; записываем указатель на экземпляр класса в RDI     call   400740 <_ZN1B9incrementEv>  ; вызываем increment      ; вызов decrement     lea    rdi,[rsp]                   ; записываем указатель на экземпляр класса в RDI     call   400750 <_ZN1B9decrementEv>  ; вызываем decrement      ; вызов get     lea    rdi,[rsp]             ; записываем указатель на экземпляр класса в RDI     call   400760 <_ZN1B3getEv>  ; вызываем get      ; вызов printf     mov    edi,0x400820         ; записываем адрес строки "%s\n" в EDI     mov    esi,eax              ; записываем значение b.get() в ESI     xor    al,al     call   4005d0 <printf@plt>  ; вызываем printf      ; вызов exit     xor    edi,edi            ; записываем код ошибки в регистр EDI     call   4005e0 <exit@plt>  ; вызываем exit

Версия 4

Для каждого класса отдельный .hpp и .cpp файл, LTO (Link Time Optimization, она же Interprocedural optimization, флаг -flto)
Код тот же, что и в предыдущем примере
Результат: clang девиртуализировал и заинлайнил все методы (ассемблерный код как в примере 1), gcc по какой-то причине заинлайнил всё кроме конструктора:

<_start>:     push   rbx     sub    rsp,0x10  ; выделяем пямять на стеке      ; вызов конструктора     mov    rdi,rsp                  ; записываем указатель на экземпляр класса в регистр RDI     call   400660 <_ZN1BC1Ev.2444>  ; вызываем конструктор      ; вычисление значения поля x     mov    eax,DWORD PTR [rsp+0x8]  ; загружаем старое значение поля x (0)     lea    esi,[rax+0x1]            ; увеличиваем его не 1     mov    DWORD PTR [rsp+0x8],esi  ; записываем результат      ; вызов printf     mov    edi,0x400700         ; записываем адрес строки "%s\n" в EDI     mov    eax,0x0              ; записываем значение b.get() в ESI     call   4005f0 <printf@plt>  ; вызываем printf      ; вызов exit     mov    edi,0x0            ; записываем код ошибки в регистр EDI     call   400620 <exit@plt>  ; вызываем exit
Версия 5

Для каждого класса отдельный .hpp и .cpp файл, LTO, вызываем виртуальные методы через указатель на базовый класс

код

a.hpp

#pragma once  class A { public: 	virtual ~A();  	virtual void increment() = 0; 	virtual void decrement() = 0; 	virtual int get() = 0; }; 

a.cpp

#include "a.hpp"  A::~A() { } 

b.hpp

#pragma once  #include "a.hpp"  class B : public A { public: 	B(); 	virtual void increment(); 	virtual void decrement(); 	virtual int get(); private: 	int x; }; 

b.cpp

#include "b.hpp"  B::B() : x(0) { }  void B::increment() { 	x++; }  void B::decrement() { 	x--; }  int B::get() { 	return x; } 

test.cpp

#include "b.hpp"  extern "C" {  int printf(const char * format, ...); void exit(int status);  void _start() { 	A * a = new B; 	a->increment(); 	a->increment(); 	a->decrement(); 	printf("%d\n", a->get()); 	exit(0); }  } 

Результат: и gcc, и clang смогли девиртуализировать только первый вызов increment:

<_start>:     push   rbx      ; выделение памяти     mov    edi,0x10            ; кол-во байт (16)     call   400480 <_Znwm@plt>  ; вызываем функцию, выделяющую память (возвращает указатель в RAX)     mov    rbx,rax             ; сохраняем указатель на экземпляр класса в RBX      ; конструктор     mov    QWORD PTR [rbx],0x4005b0  ; инициализируем таблицу виртуальных функций     mov    DWORD PTR [rbx+0x8],0x0   ; инициализируем поле x      ; первый вызов increment     mov    rdi,rbx                     ; записываем указатель на экземпляр класса в RDI     call   400520 <_ZN1B9incrementEv>  ; вызываем increment      ; второй вызов increment     mov    rax,QWORD PTR [rbx]   ; записываем указатель на таблицу виртуальных функций в RAX     mov    rdi,rbx               ; записываем указатель на экземпляр класса в RDI     call   QWORD PTR [rax+0x10]  ; вызываем increment      ; вызов decrement     mov    rax,QWORD PTR [rbx]   ; записываем указатель на таблицу виртуальных функций в RAX     mov    rdi,rbx               ; записываем указатель на экземпляр класса в RDI     call   QWORD PTR [rax+0x18]  ; вызываем decrement      ; вызов get     mov    rax,QWORD PTR [rbx]   ; записываем указатель на таблицу виртуальных функций в RAX     mov    rdi,rbx               ; записываем указатель на экземпляр класса в RDI     call   QWORD PTR [rax+0x20]  ; вызываем get      ; вызов printf     mov    edi,0x400570         ; записываем адрес строки "%s\n" в EDI     mov    esi,eax              ; записываем значение b.get() в ESI     xor    al,al     call   400490 <printf@plt>  ; вызываем printf      ; вызов exit     xor    edi,edi            ; записываем код ошибки в регистр EDI     pop    rbx     jmp    4004a0 <exit@plt>  ; вызываем exit

Выводы

  • Наилучший результат достигается когда все классы в одной единице трансляции
  • Во всех тестах результаты clang не хуже или лучше результатов gcc

Исходники: github.com/alkedr/devirtualize-test

ссылка на оригинал статьи https://habr.com/ru/post/511174/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *