Как язык Аргентум делает быстрый dynamic_cast и диспетчеризацию методов интерфейсов четырьмя инструкциями процессора

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Зачем вообще искать методы в рантайме

Полиморфизм - один из трёх столпов объектно-ориентированного программирования  - требует, чтобы объекты разных классов по-разному реагировали на одни и те же запросы. Например, вызов метода to_string у объекта Animal и объекта Engine будет приводить к кардинально разным результатам. В общем случае, имея ссылку на объект, мы не знаем точный код который будет реагировать на вызов to_string у объекта по ссылке.

Получается, что код приложения и runtime-библиотека языка должны найти правильную точку входа в правильный метод соответствующий этому классу и методу. 

Необходимое отступление о девиртуализации. Естественно, современные компиляторы пытаются избежать этой дорогой операции по поиску метода, если есть такая возможность. Если тип объекта известен ещё на этапе компиляции компилятор может убрать диспетчеризацию и вызвать нужный метод нужного класса напрямую. Это открывает возможности для инлайна тела этого метода в точку вызова и дальнейших многочисленный оптимизаций. К несчастью есть ситуации, и они довольно многочисленные, когда выполнить девиртуализацию во время компиляции невозможно, и приходится использовать рантайм диспетчеризацию.

Как вызываются виртуальные методы

Для вызова виртуального метода компилятор строит таблицы указателей на методы и применяет разные алгоритмы вычисления индексов в этих таблицах. Случай одиночного наследования и древовидной иерархии классов самый быстрый и прямолинейный.

Рассмотрим пример на псевдокоде немного напоминающем С++):

struct Base {
    virtual void a_method() { puts("hello from Base::a_method"); }
    // virtual ~Base(){}  // эта строка не нужна для псевдокода
};
struct Derived : Base {
    void a_method() override {
        puts("hello from Derived::a_method");
    }
    virtual void additional_method() { puts("hello from additional_method"); }
};
 
// Somewhere at the caller site
void some_function(Base& b) {
   b.a_method();                          // {1}
}

В строке {1} компилятор делает колдунство: В зависимости от настоящего типа объекта на который ссылается переменная b может вызываться или базовый Base::a_method или производный метод Derived::a_method. Это достигается с помощью таблиц указателей на методы и пары инструкций процессора. Например, для процессора x86-64 в Windows ABI этот код выглядит так (пардон за интеловский синтаксис):

mov rcx, b
mov rax, [rcx + offset_to_vmt_ptr]  ; {2}  offset_to_vmt обычно 0
call [rax + offset_to_a_method]     ; {3}

Этот код работает потому что где-то внутри объекта на который ссылается b существует невидимое поле, которое обычно называется vmt_ptr. Это указатель на статическую структуру данных которая содержит указатели на виртуальные методы этого класса.

В строке {2} мы достаем указатель на таблицу виртуальных методов и строке {3} мы загружаем адрес точки входа в метод и вызываем его.

Чтобы все работало, нам потребуются также две таблицы (по одной на каждый класс) с указателями на методы:

Base_VMT   dq Base_a_method
Drived_VMT dq Derived_a_method, Derived_added_method
На схеме: если переменная b ссылается на Base,операции mov и call обратятся по красным красным указателям,если на Derived - по синим.
На схеме: если переменная b ссылается на Base,
операции mov и call обратятся по красным красным указателям,
если на Derived - по синим.

Вся приведенная схема требует, чтобы где-то при создании любого экземпляра класса кто-то положил в поле vmt_ptr адрес одной из двух таблиц. Обычно это делается в конструкторе.

Этот метод вызова прост понятен, удобен в реализации, потребляет очень мало памяти, обеспечивает бесплатность кастов к базовым классам и имеет ничтожно малый оверхед при вызовах. Он используется в Java при наследовании классов, В C++, когда у нас нет множественного наследования, и вообще везде, где его можно применить.

Сложное наследование

К сожалению в реальных приложениях каждый класс распадается на несколько ортогональных ролей (serializable, drawable, physical, collidable, evaluable), иногда роли образуют группы с другими ролями (SceneItem: drawable, collidable). И все эти роли, классы и группы не складываются в единую древовидную иерархию наследования классов: Не всякий графический элемент сериализуемый, но есть и такие. Не всякий элемент поддерживающий коллизии работает с физикой, но некоторые - да. Поэтому Все современные языки программирования так или иначе разрешили разные виды множественного наследования в своих иерархиях классов.

В Java, Swift, C# вы можете унаследовать реализацию от одного класса и реализовать множество интерфейсов. С++ разрешает множественное наследование хотя это и вносит дополнительную сложность, когда один и тот же базовый класс может быть унаследован по разным веткам, поэтому в языке появились виртуальные базовые классы. Rust реализует множественное наследование как реализацию трейтов. Go формально не использует термин наследование и заменяет его делегацией интерфейса и композицией состояния, но если это крякает как наследование... В общем, сегодня мы можем сказать что все современные языки программирования отступили от принципа одиночного наследования и древовидной организации классов.

Как сегодня реализуется вызов метода при сложном наследовании

В разных языках пошли разными путями:

В Swift и Rust ссылка на реализацию протокола/трейта - это структура,состоящая из двух сырых указателей - один указывает на структуру с данными, а другой на таблицу виртуальных методов (witness table in Swift, vtable in Rust). За счет удвоения размера каждой ссылки Rust и Swift позволяют вызывать методы интерфейсов с той же скоростью что и обычные виртуальные методы классов.

Go - тоже хранит каждую ссылку на интерфейс как два указателя, но строит таблицы методов динамически при первом использовании и складывает результаты в глобальный хеш-мап.

В Java и Kotlin при первом вызове метода производятся линейный поиск в списке реализованных интерфейсов, результат поиска кэшируется. Если в какой-то точке вызова встречается небольшое количество (1-2) разных классов, JIT компилятор строит специальный оптимизированный код диспетчера, но если появляется новый класс, происходит возврат к линейному поиску.

Пожалуй самый замороченный подход используется в C++: каждый экземпляр класса содержит множество указателей на таблицы виртуальных методов. Каждый каст к базовому классу или производному классу, если они не могут быть сведены в дерево, приводит к перемещению указателя по памяти, с тем чтобы указатель на объект приведенный к типу T указывал на таблицу виртуальных методов этого типа T в этом объекте. Это позволяет вызывать виртуальные методы с одинаковой скоростью и при одиночном и при множественном наследовании.

Каждый подход является компромиссом между занимаемой памятью, сложностью вызова метода и сложностью работы с указателями.

Подход Java/Kotlin позволяет оптимизировать прохождение бенчмарков, кешируя последние вызванные методы и выполнять "рантайм-девиртуализацию" там, где это возможно. Для общего случая настоящей динамической диспетчеризации сильно полиморфных методов интерфейсов все сводится к линейному поиску в списках имен интерфейсов. Это оправдано, если класс реализует один-два интерфейса, но в общем случае весьма затратно.

Go, Rust и Swift вызывают методы быстро, но за счет удвоения размера каждого указателя, что может быстро исчерпать регистровый файл, предназначенный для передачи параметров при вызовах методов и при работе с локальными/временными ссылками. А это в свою очередь приведет к разливу регистров в стек. К тому же это усложняет касты ссылок между типами (трейтами/протоколами/интерфейсами), которые в Swift делаются унаследованным из Objective C кодом динамической диспетчеризации (с поиском по словарям идентификаторов протоколов), а в Rust такой каст отсутствует вовсе и реализуется написанным вручную методами `as_trait_NNN`. Swift имеет механизм подавления виртуализации через инстанцирование шаблонной функции для каждой реализации протокола (ключевые слова "some" против "any") этот механизм не работает для настоящих полиморфных контейнеров. В Rust механизм подавления виртуализации включен постоянно и выключается ключевым словом "dyn". 

C++ не расходует дополнительную память в каждом сыром указателе, и непосредственно вызов метода при множественном наследовании так же быстр, как при одиночном наследовании, однако сложность никуда не делась: такой подход приводит к существенному усложнению структуры объекта, усложнению кода методов - нужны thunks, к усложнению кода конструкторов и всех операций приведения типов. В парадигме С++ эти операции не столь часты, их сложностью можно пренебречь, но если попытаться перенять этот подход в системах с интроспекцией, или автоматическим управлением памятью, то каждая операция с объектом требующая доступа к заголовку объекта, хранящему маркерные слова, счетчики и флаги потребуют static_cast<void*> который во-первых, не бесплатен, во-вторых, несовместим с виртуальным наследованием. А такая операция потребуется при каждом копировании и удалении ссылки на объект, или при каждом сканировании объекта в случае GC. Именно поэтому в С++ смарт-поинтеры хранят отдельный указатель на счетчики и маркеры, расходуя память совсем как в Rust/Swift. Кстати, безопасный dynamic_cast в C++ требует поиска в RTTI данных, то есть по сложности сравним с таковым в Swift/Java/Go.

Подводя итог раздела - проблемы диспетчеризации методов при множественном наследовании есть, и существующие решения оставляют желать лучшего.

Подход Аргентума

Пример программы на Аргентуме с классами и интерфейсами (синтаксис напоминает Java)

//
// Объявляем какой-то интерфейс
//
interface Drawable {
    width() int;
    height() int;
    paint(c Canvas);
}

//
// Реализуем этот интерфейс в каком-нибудь классе
//
class Rectangle {
    + Drawable{
        width() int { right - left }
        height() int { bottom - top }
        paint(c Canvas) {
            c.setFill(color);
            c.drawRect(left, top, right, bottom);
        }
    }
    + Serializable { ... }  // Реализуем еще...
    + Focusable { ... }    // ...несколько интерфейсов
    left = 0;       // Поля
    right = 0;
    top = 0;
    bottom = 0;
}

// Создаем экземпляр.
r = Rectangle;

// Вызываем методы интерфейса
w = Window.initCentered("Hello", r.width(), r.height());
r.paint(w);

В точке вызова r.paint(w); компилятор построит код:

; rdx - pointer to `w`
; rcx - pointer to `r`
mov rax, Drawable_interface_id + Drawable_paint_method_index
call [rсx]

У каждого класса первое поле - это указатель на диспетчерскую функцию. У нашего Rectangle эта функция будет такой:

Rectangle_dispatch:
	movzx r10, ax
	pext rax, rax, Rectangle_magic_number
	mov rax, Rectangle_interface_table[rax*8]
	jmp [rax + r10*8]

Не все современные процессоры умеют в pext, поэтому пока заменяем на bextr или:

    and rax, Rectangle_magic_mask
    shr rax, Rectangle_magic_offset

Кроме диспетчерской функции, Rectangle будет иметь набор таблиц:

Rectangle_interface_table:
    dq Rectangle_Drawable_method_table
    dq empty
    dq Rectangle_Serializable_method_table
    dq Rectangle_Focusable_method_table

Rectangle_Drawable_method_table:
    dq Rectangle_Drawable_width   ; method entry points
    dq Rectangle_Drawable_height
    dq Rectangle_Drawable_paint
Rectangle_Serializable_method_table dq ...
Rectangle_Focusable_method_table    dq ...

Как и почему это работает

На стадии компиляции каждый интерфейс получает случайно выбранный уникальный 48-битный идентификатор (он хранится в 48 битах 64-битного машинного слова с нулями в младших 16 битах - для индекса метода).

При вызове метода интерфейса вызывающая сторона вызывает диспетчер корректного класса, передавая в качестве параметра идентификатор интерфейса и 16-битный индекс метода в интерфейсе.

Диспетчер должен различить по этим идентификаторам интерфейсы, реализованные в данном классе. При этом не важно, сколько классов и сколько интерфейсов существует в приложении. Их может быть сотни и тысячи. Важно различить только интерфейсы данного класса, которых единицы или в худшем случае, десятки.

Если интерфейс один, диспетчер пропускает выбор интерфейса и сразу передает управление по индексу метода.

MyClass1_dispatcher:
	movzx rax, ax
    jmp MyClass1_InterfaceX_methods[rax*8]

Если класс реализует два интерфейса, их идентификаторы гарантировано будут различаться одним битом в одном из 48 разрядов их идентификаторов. Задача компилятора найти этот разряд и построить диспетчер, который проверяет этот бит:

MyClass2_dispatcher:
	movzx r10, ax              ; забираем индекс метода в r10
	shr rax, BIT_POSITION  ; можно заметить одной ...
	and RAX, 1             ; ...инструкцией pext/bextr
    ; загружаем указатель на одну из двух таблиц методов
	mov rax, MyClass2_itable[rax*8]
	jmp [rax + r10*8]      ; переходим к началу метода
MyClass2_itable:
	dq MyClass2_InterfaceX_mtable
	dq MyClass2_InterfaceY_mtable
MyClass2_InterfaceX_mtable:
	dq MyClass2_InterfaceX_methodA
	dq MyClass2_InterfaceX_methodB
MyClass2_InterfaceY_mtable:
	dq MyClass2_InterfaceY_methodA

В случае трёх методов нам понадобится двухбитный селектор. При выборе трёх случайных 48-битных чисел в среднем будет присутствовать 17.6 уникальных селекторов из двух смежных битов. Таким образом, вышеупомянутый подход будет работать с очень большой вероятностью. Большее количество интерфейсов потребует большего размера селектора.

Пример:

Допустим у нас есть класс, реализующий пять разных интерфейсов Идентификаторы этих интерфейсов имеют уникальную последовательность из трех бит по смещению 4.

Интерфейс

ID (16-ричный)

ID двоичный (первые N бит)

IA

f78745bed893

0100010110111110110110001 001 0011

IB

9b5aed351b36

1110110100110101000110110 011 0110

IC

08d460f812a6

0110000011111000000100101 010 0110

ID

6d0a3a225df6

0011101000100010010111011 111 0110

IE

54d4c7d9bd0f

1100011111011001101111010 000 1111

Диспетчер такого класса будет иметь вид:

class_N_disp:
   movzx r10, ax
   shr rax, 4
   and rax, 7
   mov rax, class_N_itable[rax*8]
   jmp [rax + r10*8]
class_N_itable dq clN_IE, clN_IA, clN_IC, clN_IB, empty, empty, empty, clN_ID
clN_IE dq classN_interfaceIE_method1...
clN_IA...

Естественно могут возникать ситуации, когда в случайно встретившихся идентификаторах интерфейсов невозможно выделить селектор, имеющий уникальное значение для каждого интерфейса. Каковы вероятности успеха для базового алгоритма диспетчера:

Количество интерфейсов в классе

Ширина селектора в битах

Неиспользованных слотов таблице интерфейсов

Среднее количество уникальных селекторов  48-битных идентификаторах интерфейсов

3

2

1

17.6250000000

4

2

0

4.4062500000

5

3

3

9.4335937500

6

3

2

3.5375976563

7

3

1

0.8843994141

8

3

0

0.1105499268

9

4

7

2.7184523642

10

4

6

1.1893229093

11

4

5

0.4459960910

12

4

4

0.1393737784

Начиная с семи интерфейсов в одном классе вероятность нахождения непрерывной селекторной группы бит значительно падает. Мы можем исправить это:

  • Используя более широкие таблицы (+1 бит)

  • Или разрешив селекторам не быть непрерывными  

  • Или введя новые уровни таблиц.

Широкие таблицы

Пример класса с восемью интерфейсами:

Интерфейс

ID 16-ричный

ID двоичный (младшие биты)

IA

36d9b3d6c5ad

101100111101011011000101101 0110 1

IB

6a26145ca3bf

000101000101110010100011101 1111 1

IC

c4552089b037

001000001000100110110000001 1011

ID

917286d627e4

100001101101011000100111111 0010

IE

889a043c83da

000001000011110010000011110 1101

IF

6b30d1399472

110100010011100110010100011 1001

IG

5939e20bb90b

111000100000101110111001000 0101

IH

850d80997bcf

100000001001100101111011110 0111 1

Среди идентификаторов интерфейсов нет 3-битного уникального селектора, но есть 4-битный в позиции 1.

За счет роста размера таблицы на в среднем 15 машинных слов мы получаем значительно лучшие вероятности нахождения подходящих селекторов вплоть до случая когда класс реализует 13 интерфейсов.

Разрывы в селекторах

Зачастую 48-битные идентификаторы интерфейсов содержат необходимые селекторы, но они находятся не в смежных битах. Идеальным вариантом было бы использовать инструкцию pext, которая может выдергивать произвольные биты из регистра по маске, но такая инструкция присутствует не во всех процессорах а кое-где исполняется за неприличные 300 тактов. Поэтому попробуем обойтись более дешевым и распространенным вариантом: N смежных бит + один отдельно стоящий бит.

Такая последовательность может быть выделена добавлением всего одной операции add:

Выражение

Бинарное значение, в котором:
ABC показывают нужные биты,
X - мусорные биты с произвольным значением

идентификатор интерфейса id

xABxxxxCxx

mask

0110000100

id & mask

0AB0000C00

adder

0000111100

(id & mask) + adder

0ABCxxxx00

((id & mask) + adder) >> offset

0000000ABC

Код, выполняющий такое выделение битов:

movzx r10, ax
and rax, 184h
add rax, 1ch  ; extra `add` instruction
shr rax, 5
mov rax, class_N_itable[rax*8]
jmp [rax + r10*8]

Одновременное использование дополнительной инструкции add и +1bit, позволяет уверенно строить диспетчеры для классов с 20+ интерфейсами, что превышает все практические сценарии диспетчеризаци.

Применение инструкции pext позволит еще поднять вероятности и уменьшить размеры таблиц не выходя за лимит четырех инструкций.

Вообще, задача поиска perfect hash function вычисляющейся с минимальными затратами ресурсов может иметь множество решений, но выделение битовой маски - является самым простым из них.

Как этот подход ускоряет dynamic_cast в языке Аргентум.

Каждая таблица методов в элементе с индексом 0 имеет метод, который сравнивает идентификатор интерфейса, использованного для диспетчеризации с реальным интерфейсом, реализованным этой таблицей, и возвращает или this-объект или null.

Если нам надо проверить наличие интерфейса X у какого-то объекта, мы вызываем метод с индексом 0 и 48-битным идентификатором интерфейса X у этого объекта.

Если интерфейс реализован в данном классе, то пройдя через выделение селектора и обращение к таблице интерфейсов, он попадет в метод с индексом 0, где его идентификатор совпадает с константной закодированной в этом методе. И поэтому для него метод с индексом 0 вернет this.

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

Кстати, поэтому неиспользованные входы талиц интерфейсов заполнены не нулями, а ссылками на специальную таблицу методов с единственным элементом, который всегда возвращает nullptr.

Таким образом dynamic_cast к интерфейсу в Аргентуме всегда занимает 3 машинные инструкции и выполняется за 10 машинных инструкций:

  • 3 инструкции вызова метода 0 указанного интерфейса с передачей параметра (может быть сокращен до 2)

  • 4 инструкции диспетчера

  • 3 инструкции метода0: сравнение, cmov, ret. (может быть сокращен до 2 если возвращать zf)

Сравнение с существующими языками

Любая ссылка в Аргентуме - это просто указатель на начало объекта. Одно машинное слово.

  • По сравнению с Swift/Rust нет оверхеда связанного с указателями удвоенной ширины, нет переполнения и разливания регистровых файлов.

  • По сравнению с static_cast в С++ нет оверхеда на перемещение this-указателя по объекту (с проверками на nullptr).

В каждом объекте есть только одно связанное с диспетчеризацией поле - это указатель на диспетчер.

  • По сравнению с C++ нет оверхеда на многочисленные указатели на разные VMT и смещения виртуальных баз внутри данных объекта, и нет оверхеда при создании таких объектов.

По сравнению с простым вызовом виртуального метода с одиночным наследованием мы имеем четыре дополнительные инструкции.

  • Это на порядки дешевле диспетчеризации в Java.

  • Это близко к С++, в котором при множественном наследовании мы часто попадаем на необходимость корректировать this на величину смещения, хранящегося в VMT. Такая коррекция выполняется в С++ автоматически сгенерированным thunk-кодом, который сравним по сложности с четырьмя инструкциями диспетчера.

  • Rust, Go и Swift выигрывают эти четыре инструкции в операции вызова, но проигрывают по две инструкции в каждой операции передачи, сохранения и загрузки ссылки из-за ее удвоенного размера. А эти операции выполняются чаще чем вызов.

Аргентум поддерживает dynamic_cast к интерфейсам, который занимает три машинные инструкции в коде программы и выполняется за 10 инструкций.

  • Это на порядки дешевле, чем в Swift, Java, Go и dynamic_cast в C++.

  • Rust такой инструкции не имеет.

Кстати, этот метод диспетчеризации подходит и для случая динамической дозарузки модулей, приносящих в AppDomain новые классы и интерфейсы:

  • При добавлении нового интерфейса он получит следующий случайно сгенерированный уникальный 48-битный идентификатор. Перестраивать существующие диспетчеры и таблицы не потребуется.

  • То же самое можно сказать про классы: их добавление в приложение потребует только генерации их собственных диспетчеров и таблиц, и не затронет уже существующие.

В отличие от многих других особенностей Аргентума обусловленных архитектурой языка (отсутствие утечек памяти, отсутствия GC, отсутствие шареного изменяемого состояния, гонок, дедлоков и т.д), описанный здесь способ диспетчеризации интерфейсных методов может быть позаимствован и применен в других языках.

Ссылки:

  • Язык программирования Аргентум: https://aglang.org

  • Windows demo: https://github.com/karol11/argentum/releases

Источник: https://habr.com/ru/articles/753650/


Интересные статьи

Интересные статьи

Аналитические системы должны эффективно обрабатывать сложные пользовательские запросы к десяткам и сотням терабайт данных (пета-?). Продвинутый оптимизатор запросов является важнейшим компонентом любо...
Привет, Хабр! Меня зовут Юля и я дизайнер диалоговых интерфейсов в команде Just AI. В этой статье я расскажу о том, какая ответственность возложена на этап дизайна, как сценарий бота помогает в процес...
Самый быстрый форматер кодаВ статье подробно поговорим о самом быстром форматере кода. Подробно покажем, как интегрировать форматер в любой проект, настроим форматирование по сохранению в редакторах к...
Ничего лишнего: материнская плата, видеокарта и ROM-BIOS Меня давно волнует вопрос, как подступиться к разработке на голом железе, на чистом си. Хотелось понять, каким же образом идёт запуск BIOS, ...
На Хабре уже несколько раз писали про мощные процессоры от Huawei, китайского производителя электроники, который находится под мощнейшими санкциями США. Эта компания не так давно представила собст...