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




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

Девиртуализация (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




Комментарии (3):

  1. apro
    /#21850576

    Использовались gcc 4.8.2 и clang 3.3.
    последних версиях gcc и clang

    Мягко говоря немного противоречивые утверждения. Или это статья 2015 года?

    • Pancir
      /#21850608

      В репозитории последний комит: Dec 11, 2013.

      Вообще не понимаю зачем это здесь сегодня, 7 лет для IT — вечность.

    • leechong
      /#21850656

      Очень похоже на то, что статья 2015 года. Для владельцев archlinux, конечно льстит такая стабильность, для хейтеров нестабильность это главный минус, но вообще система славится тем, что новые версии программ выходят очень быстро.