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

Полиморфизм - один из трёх столпов объектно-ориентированного программирования  - требует, чтобы объекты разных классов по-разному реагировали на одни и те же запросы. Например, вызов метода 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-битный индекс метода в интерфейсе.

Диспетчер должен различить по этим идентификаторам интерфейсы, реализованные в данном классе. При этом не важно, сколько классов и сколько интерфейсов существует в приложении. Их может быть сотни и тысячи. Важно различить только интерфейсы данного класса, которых единицы или в худшем случае, десятки. Причем благодаря строгой типизации мы имеем гарантию, что придут только правильные идентификаторы интерфейсов (про 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

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 в языке Аргентум.

// Кстати о синтаксисе
// 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, отсутствие шареного изменяемого состояния, гонок, дедлоков и т.д), описанный здесь способ диспетчеризации интерфейсных методов может быть позаимствован и применен в других языках.

Ссылки:

Комментарии (17)


  1. Ritan
    09.08.2023 23:01

    Стоит отметить, что в c++ один или несколько предков могут находиться в других единицах трансляции и/или их исходный код вообще не доступен при компиляции наследника. Подход с уникальными id интерфейсов в таком случае, насколько я понимаю, невозможен


    1. IvanPetrof
      09.08.2023 23:01

      Тело предка может находиться в других единицах трансляции, но заголовки всех предков присутствуют. Иначе от них нельзя унаследоваться.


      1. qw1
        09.08.2023 23:01

        Заголовки тут не сильно помогут, потому что в них не указаны "magic numbers" интерфейсов, и мы не знаем, какие были выбраны "magic numbers" в уже оттранслированных модулях.


        Подход работает только для компиляции всего модуля целиком.


  1. Panzerschrek
    09.08.2023 23:01
    +1

    Не совсем понятно, как работают виртуальные функции.
    Я правильно понимаю, что функция-диспетчер - это по факту просто хитрый switch по ID интерфейса/номеру функции?

    Как работает передача аргументов через функцию-деспетчер? Некоторые аргументы ведь передаются через регистры, которые эта функция для своих внутренних нужд может тоже использовать. Используется ли тогда их сохранение/восстановление? Или функция-диспетчер использует только те регистры, которые не используются для передачи аргументов? Или же вообще, виртуальные функции используют нестандартное соглашение о вызовах, которое бы исключало использование некоторых регистров для передачи аргументов?

    На счёт необходимости реализации множества интерфейсов: в Rust эту проблему обходят тем, что в подавляющем случае там полиморфизм статический. Редко когда надо действительно таскать объекты вроде dyn A + B + C ... + Z. В C++ аналогично - в современном C++ не любят увлекаться с разветвлёнными иерархиями и множественным наследованием, как раз из-за потенциальной неоптимальности такого подхода.


    В статье также не увидел описания механизма работы наследования в данном языке? Имеется ли возможность унаследоваться от более чем одного не-интерфейса и как следствие необходимость коррекции this при виртуальном вызове?


    1. kotan-11 Автор
      09.08.2023 23:01

      Я правильно понимаю, что функция-диспетчер - это по факту просто хитрый switch по ID интерфейса/номеру функции?

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

      Как работает передача аргументов через функцию-деспетчер?

      Насквозь. Диспетчер использует для своих целей только регистры, которые в соответстсвии с ABI целевой платформы считаются временными/мусором. Наличие диспетчера никак не влияет на вызывающую и вызываемую стороны.

      Редко когда надо действительно таскать объекты вроде dyn A + B + C

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

      Имеется ли возможность унаследоваться от более чем одного не-интерфейса?

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

      Поэтому в Аргентуме коррекция this не нужна.

      Спасибо за хорошие вопросы.


      1. Panzerschrek
        09.08.2023 23:01

        С передачей параметров понял. Хитро, но возможно несколько заморочено - надо писать код метода dispatch под каждую платформу, используя только "мусорные" на ней регистры.

        На счёт полиморфизма - да, он есть в том же браузере, но у меня сомнения, что там классы по 10 интерфейсов (утрировано) реализуют. Максимум 1-2.

        На счёт модели наследования - у меня в Ü подход тоже схожий с Java - можно иметь не более одно полноценного класса-предка и реализовывать сколько угодно интерфейсов. При этом коррекция this всё же нужна, ибо в классе на каждого предка есть свой указатель на таблицу виртуальных функций. При этом коррекция нужна только при вызове через интерфейс. Если просто базовый (не-интерфейсный класс) используется для вызова, то this берётся как есть, т. к. гарантируется, что базовый класс всегда лежит по смещению 0.


      1. boldape
        09.08.2023 23:01

        Я вам ещё один хороший вопрос задам, а что на счёт сравнения вашей диспетчеризации с С++, но только наложите на с++ код те же ограничения что и у вас в языке - нет виртуального наследования, наследовать можно только одну реализацию и сколько угодно интерфейсов (классов без данных с виртуальными методами). Мне асемблер обычно не интересен и я туда не гляжу, но даётся мне что для компиляторов это первичный сценарий для оптимизаций и ставлю на то что в с++ для такого случая быстрее чем у вас.


        1. kotan-11 Автор
          09.08.2023 23:01

          В таком сценарии в С++-классе все равно будут по одному vmt_ptr на каждый отдельный реализованный интерфейс. А значит будет движение this-указателя при кастах (c проверками на nullptr) и сложная настройка объекта в конструкторе с заполнением всех этих vmt_ptr-полей. Сохранится даже двойной размер всех смарт-поинтеров. Единственное чего возможно удастся избежать - это генерация thunk-функций, но и то только до первого наследования от такого класса.


          1. qw1
            09.08.2023 23:01

            Тот случай, когда своё выстраданное решение хочется защитить любой ценой.


            Тут "движение this-указателя при кастах" (по сути, ADD RCX, offset) и "генерация thunk-функций" — это ужас-ужас, а каждый(!) виртуальный вызов выполнять не напрямую, а через функцию-диспетчер, в которой творится вычислительная магия с расшифровкой переданного дескриптора функции — это отлично.


          1. boldape
            09.08.2023 23:01

            будут по одному vmt_ptr на каждый отдельный реализованный интерфейс

            Да.

            будет движение this-указателя при кастах

            Да, это движение тривиально, смещение на компайл тайм константу + касты редки. Ну т.е. когда вы пишите код который наследует вот таким образом, то вы и объекты храните в контейнерах сразу через базовый указатель, а иначе зачем это все.

            двойной размер всех смарт-поинтеров

            Это я не понял о чем вы, любой юник поинтер всегда один указатель, любой шаред поинтер зависит от реализации, но все с чем я реально сталкивался всегда 2 указателя(объект + делетер) + счётчик. И это так сделано совсем по другим причинам, это такой дизайн шаред поинтера.

            сложная настройка объекта в конструкторе с заполнением всех этих vmt_ptr-полей

            Нет, эта часть тривиальна, очередной (базовый) конструктор отработал перезапишится первый ВМТ(и то один раз после конструктора базового не интерфейса) и посестится очередной вмт, опять же по фиксированным смещениям, но соглашусь это для каждого объекта, хотя обычно конструкторы вот в таких случаях совсем не тривиальны и так.

            Вообще сравнивать надо яблоки с яблоками, если у вас ограничения то и в других языках нужно смотреть на реализацию с учётом таких же ограничений.

            И ещё, вы тут много пишете про бест практисы, но я не очень понимаю как это у вас сочетается с оптимизацией перформанса динамик кастов. Я может отстал от жизни, но всегда считал динамик каст хаком, нарушением инкапсуляции, но готов посмотреть на ваш пример с рантайм "интроспекцией" (ну хак хаком же) где это по вашему уместно. Сразу скажу что в тех же растах у вас просто будет несколько контейнеров (а)рс где один и тот же объект боксится несколько раз по разным крэйтам и никаких динамик кастов не потребуется


  1. tzlom
    09.08.2023 23:01
    +1

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

    тут не очевидный баланс, копирование двух указателей уже на SSE будет одной инструкцией, с другой стороны вызов у вас происходит через диспечеризатор, который не только несколько инструкций, но и переход в другую часть кода с потерей кеша инструкций

    И не понятно что с кодом загружаемым из dll, какие там будут id ?


    1. kotan-11 Автор
      09.08.2023 23:01

      копирование двух указателей уже на SSE будет одной инструкцией

      SSE регистры не могут хранить указатели. Если компилятор умеет предавать fat ptr в регистрах, это будет две дополнительные инструкции и переполнение регистрового файла, как описано в статье. Если не умеет и следует ABI для структур (как например MSVC), то каждая передача параметра-ссылки в функцию превращается в резервирование стека, два movaps, lea, и учитывая, что при вызове каждого метода надо передавать this, это становится дороже подхода Аргентума на каждом вызове.

      переход в другую часть кода с потерей кеша инструкций

      Вызов метода - это вообще всегда переход в другую часть кода.

      Кстати, удвоение размера каждого указателя в программе - это рост размера данных и сделовательно рост количества кеш-промахов, который, в зависимости от связанности структур данных может доходить до 200%. Поэтому подход с двойными указателями с кешем не дружит.

      И не понятно что с кодом загружаемым из dll, какие там будут id ?

      Код загружаемый из DLL должен следовать соглашениям ABI платформы. Я пока не видел ABI, стандартизирующий вызов виртуальных методов. Поэтому правильно написанная DLL/o не должна выставлять наружу ООП API, если не хочет зависимости от конкретной версии компилятора с конкретными настройками сборки.


      1. tzlom
        09.08.2023 23:01

        SSE регистры не могут хранить указатели

        Конечно могут, регистр это просто данные. Да, нет инструкций для переходов по данным из SSE/AVX регистров,но если вам нужно копировать / передавать кучу удвоенных указателей - это возможно через векторные регистры. Правда есть вопросы на сколько часто это приходится делать и вообще есть ли в этом смысл.

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

        Да, я тут глянул в godbolt.org е на вопрос передачи объекта за интерфейсом в расте, и что то я двух указателей не вижу, у вас есть пример? Мне кажется что вы как-то смешали передачу указателя на объект с вызовом интерфейса объекта.

        Вызов метода - это вообще всегда переход в другую часть кода.

        Кстати, удвоение размера каждого указателя в программе - это рост размера данных и сделовательно рост количества кеш-промахов, который, в зависимости от связанности структур данных может доходить до 200%. Поэтому подход с двойными указателями с кешем не дружит.

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

        Код загружаемый из DLL должен следовать соглашениям ABI платформы. Я пока не видел ABI, стандартизирующий вызов виртуальных методов. Поэтому правильно написанная DLL/o не должна выставлять наружу ООП API, если не хочет зависимости от конкретной версии компилятора с конкретными настройками сборки.

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

        P.S.
        В принципе это всё теоритезирования, что на самом деле быстрее показать может быть только замер, но разумеется сделать его корректно не просто.


        1. qw1
          09.08.2023 23:01
          +1

          Мне кажется что вы как-то смешали передачу указателя на объект с вызовом интерфейса объекта

          Вызов метода интерфейса подразумевает передачу объекта, т.к. реализации метода нужен this.


  1. qw1
    09.08.2023 23:01
    +1

    Если пойти дальше, мы знаем все интерфейсы, которые есть в компилируемой программе. Просто все методы всех интерфейсов (ну, сколько их там будет? 200-500?) размещаем в vtbl, как будто каждый объект реализует каждый интерфейс, а если не реализует, на его адресе ставим заглушку. Сколько у нас классов? Пускай будет 20000 классов. На 500 методов каждого класса, это 80MB указателей в vtpr. Зато всё прозрачно и понятно, никакой магии.


    Подход автора, инвертирующий адрес метода и индекс, я боюсь, не будет понят современными процессорами, которые имеют множество хрупких механизмов предсказаний по треку регистра RIP. Тут же, все вызовы проходят через единую точку-диспетчер, с хендлом метода в RAX. Я думаю, процессоры этому трюку не обучены, и точность предсказаний сильно упадёт, а с ним и скорость выполнения.


    1. kotan-11 Автор
      09.08.2023 23:01

      Каждый вызов виртуального метода в Rust/Swift/C++ сегодня происходит через `call [RAX+...]` процессор справляется.


      1. qw1
        09.08.2023 23:01

        Справляется, потому что предсказывает, что в какой-то конкретной точке, где расположен этот CALL, в 99% будет переход по адресу 0x1234560. А у вас в диспетчере на таком CALL будут переходы вообще во всем методам класса. И чтобы сделать предсказание, нужно учитывать, из какой точки вызван диспетчер. Процессоры этому не обучены.