Что это вообще такое
Девиртуализация (devirtualization) — оптимизация виртуальных функций. Если компилятор точно знает тип объекта, он может вызывать его виртуальные функции напрямую, не используя таблицу виртуальных функций.
В этой статье мы проверим насколько хорошо с этой задачей справляются компиляторы gcc и clang.
Тестирование
Все тесты производились на Arch Linux x86-64. Использовались gcc 4.8.2 и clang 3.3.
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)
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 файл
#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, вызываем виртуальные методы через указатель на базовый класс
#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/
Добавить комментарий