Зачем вообще искать методы в рантайме
Полиморфизм — один из трёх столпов объектно-ориентированного программирования — требует, чтобы объекты разных классов по-разному реагировали на одни и те же запросы. Например, вызов метода 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
операции 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-битный индекс метода в интерфейсе.
Диспетчер должен различить по этим идентификаторам интерфейсы, реализованные в данном классе. При этом не важно, сколько классов и сколько интерфейсов существует в приложении. Их может быть сотни и тысячи. Важно различить только интерфейсы данного класса, которых единицы или в худшем случае, десятки. Причем благодаря строгой типизации мы имеем гарантию, что придут только правильные идентификаторы интерфейсов (про dynamic_cast, который нам это обеспечивает в рантайме — ниже).
Если интерфейс один, диспетчер пропускает выбор интерфейса и сразу передает управление по индексу метода.
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 1 |
|
ID |
917286d627e4 |
100001101101011000100111111 0010 0 |
|
IE |
889a043c83da |
000001000011110010000011110 1101 0 |
|
IF |
6b30d1399472 |
110100010011100110010100011 1001 0 |
|
IG |
5939e20bb90b |
111000100000101110111001000 0101 1 |
|
IH |
850d80997bcf |
100000001001100101111011110 0111 1 |
Среди идентификаторов интерфейсов нет 3-битного уникального селектора, но есть 4-битный в позиции 1.
За счет роста размера таблицы на в среднем 15 машинных слов мы получаем значительно лучшие вероятности нахождения подходящих селекторов вплоть до случая когда класс реализует 13 интерфейсов.
Разрывы в селекторах
Зачастую 48-битные идентификаторы интерфейсов содержат необходимые селекторы, но они находятся не в смежных битах. Идеальным вариантом было бы использовать инструкцию pext, которая может выдергивать произвольные биты из регистра по маске, но такая инструкция присутствует не во всех процессорах а кое-где исполняется за неприличные 300 тактов. Поэтому попробуем обойтись более дешевым и распространенным вариантом: N смежных бит + один отдельно стоящий бит.
Такая последовательность может быть выделена добавлением всего одной операции add:
|
Выражение |
Бинарное значение, в котором: |
|
идентификатор интерфейса |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Код, выполняющий такое выделение битов:
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 в языке Аргентум.
// Кстати о синтаксисе // Dynamic_cast в Аргентуме имеет вид: // выражение ~ Тип и порождает optional(Тип), // например: someRandomOject ~ MyPreciousType ? _.callItsMetod(); // Читается как: прокастить `someRandomOject` к типу `MyPreciousType`, // и если получилось, вызвать у _него_ метод `callItsMetod`.
Каждая таблица методов в Аргентуме имеет служебный метод с индексом 0, который сравнивает идентификатор интерфейса, использованного для диспетчеризации с реальным интерфейсом, реализованным этой таблицей, и возвращает или this-объект или null.
Если нам надо проверить наличие интерфейса X у какого-то объекта, мы вызываем метод с индексом 0 и 48-битным идентификатором этого интерфейса X у этого объекта.
Если интерфейс реализован в данном классе, то пройдя через выделение селектора и обращение к таблице интерфейсов, мы попадем в метод с индексом 0, где идентификатор X совпадает с константной закодированной в этом методе. И поэтому метод с индексом 0 вернет this.
Если же интерфейс X не реализован, то пройдя через выделение селектора, мы попадем в единственную интерфейсную таблицу и единственный метод проверки, в котором он вообще мог быть. И следовательно единственное сравнение идентификатора X и идентификатора реального интерфейса, которому посвящена эта таблица методов позволит установить неудачность dynamic_cast-а.
Кстати, именно из-за dynamic cast-а неиспользованные входы талиц интерфейсов заполнены не нулями, а ссылками на специальную таблицу методов с единственным элементом, который всегда возвращает nullptr.
Таким образом dynamic_cast к интерфейсу в Аргентуме всегда занимает 3 машинные инструкции и выполняется за 10 машинных инструкций:
-
3 инструкции вызова метода 0 указанного интерфейса с передачей параметра (может быть сокращен до 2)
-
4 инструкции диспетчера
-
3 инструкции
метода0: сравнение,cmov,ret. (может быть сокращен до 2 если возвращатьzf)
Сравнение с существующими языками
Любая ссылка в Аргентуме — это просто указатель на начало объекта. Одно машинное слово.
-
По сравнению с Swift/Rust/Go в Аргентуме нет оверхеда связанного с указателями удвоенной ширины, нет переполнения и разливания регистровых файлов.
К примеру в x86/64/Win ABI для передачи парамеров используется всего 4 регистра — две ссылки Swift займут их все, и уже третий параметр функции пойдет через память. -
По сравнению со
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/
Добавить комментарий