fpga-systems-magazine

Распутываем виртуальные методы в SystemVerilog

Главная » Статьи » Языки » SystemVerilog
barkovian
23.10.2022 13:18
4118
0
5.0

PDF версию статьи ищи здесь

Введение

Я - один из тех странных людей, которые время от времени пишут ответы на r/systemverilog .Однажды я увидел там довольно простой с моей точки зрения вопрос: что такое виртуальный метод? Затянувшееся обсуждение показало, что вопрос не такой уж и простой, если вы пришли в SystemVerilog без опыта ООП в других языках. Наследование, переопределения методов, работа ссылочных типов - все эти концепции нужно понимать, чтобы вникнуть в работу виртуальных методов.

Попробуем разобраться.

Обращаясь к стандарту языка, будем иметь в виду 1800-2017 - IEEE Standard for SystemVerilog. Проверяться код будет в четырёх симуляторах: Riviera, VCS, Xcelium, Questa, однако для воспроизведения примеров достаточно EDA Playground без корпоративного аккаунта.

А зачем всё это?

Представим себе драйвер, который принимает пакеты и передаёт их по интерфейсу в виде битового потока. Пакеты могут быть типов data и control, каждый тип имеет свой набор полей, характеризующий пакет. Было бы здорово написать такой код в драйвере, который бы обработал сразу и data и control, да ещё чтобы не менять драйвер, когда у нас появится десяток новых типов пакетов. Такой код может выглядеть так:

class driver;
 task drive_pkt(packet pkt);
 int raw = pkt.to_raw();
 foreach(raw[i]) begin
 // drive bit
 end
 endtask
endclass

Метод drive_pkt может иметь аргументом любого наследника класса packet. Каждый наследник имеет свою реализацию метода to_raw. При добавлении новых типов пакетов нужно только реализовать этот метод в новом классе, а трогать код драйвера необходимости нет.

Чтобы эта схема работала, метод to_raw должен быть виртуальным.

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

Попытка понять первая, прямая

Начнём с определения, которое нам даёт стандарт.

A method of a class may be identified with the keyword virtual. Virtual methods are a basic polymorphic construct. A virtual method shall override a method in all of its base classes, whereas a non-virtual method shall only override a method in that class and its descendants. One way to view this is that there is only one implementation of a virtual method per class hierarchy, and it is always the one in the latest derived class.

Перевод:

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

Вам понятно? Мне - не очень. Но давайте зацепимся за последнее предложение, оно выглядит несложно:

Иными словами, существует только одна реализация виртуального метода в иерархии классов, и это всегда реализация в последнем классе-наследнике.

То есть виртуальный метод класса-наследника переопределяет виртуальный метод класса-родителя, и у какого бы объекта мы этот метод ни вызвали, всё равно будет использована реализация последнего наследника? Проверим.

 
class Foo;
 function void my_common_name();
 $display("Foo");
 endfunction
 
 virtual function void my_virtual_name();
 $display("Foo");
 endfunction
endclass

class Bar extends Foo;
 function void my_common_name();
 $display("Bar");
 endfunction
 
 virtual function void my_virtual_name();
 $display("Bar");
 endfunction
endclass

module tb;
 initial begin
 Foo foo = new();
 Bar bar = new();
 foo.my_virtual_name();
 bar.my_virtual_name();
 end
endmodule

Вывод:

# KERNEL: Foo 
# KERNEL: Bar

Выходит, что всё-таки не одна реализация метода my_virtual_name существует. Так что же, стандарт обманывает нас?

Дело в том, что в стандарте определение написано таким сказочным образом, что может намекать на настоящее поведение виртуальных методов только тем, кто его уже понимает. Поэтому дадим своё определение. >При вызове виртуального метода выбирается реализация, принадлежащая типу объекта, находящемуся по ссылке, а не типу ссылки.

Если всё ещё не понятно, не волнуйтесь. Далее мы подробно разберём что к чему. А пока - пример, демонстрирующий “виртуальность” методов. Возьмём код из предыдущего примера и изменим блок initial.

 initial begin
 Foo foo;
 Bar bar = new();
 foo = bar;
 foo.my_virtual_name();
 foo.my_common_name();
 end
 Вывод:
# KERNEL: Bar
# KERNEL: Foo

Хотя ссылка имеет тип Foo, она указывает на объект типа Bar, чей метод my_virtual_name() и был вызван, в то время как невиртуальный my_common_name() был вызван согласно типу ссылки.

Попытка понять вторая, обходная

Крайне важно понять разницу между ссылочными типами (reference type) и типами значения (value type).

При объявлении целочисленной переменной

int x = 4;

переменная x это и есть число 4. Мы можем присвоить ей другое значение, тогда эта переменная станет другим числом. Оператор присваивания в данном случае можно считать записью числа справа в ячейку памяти слева.

Всё сложнее при объявлении объекта.

Foo foo = new();

Здесь foo - это не объект, а ссылка на объект. Объект типа Foo находится где-то в памяти, а ссылка foo указывает на это место в памяти. Тип ссылки не обязан совпадать с типом объекта, на который она указывает. Это может быть также объект любого типа, который располагается ниже в иерархии классов. То есть, любой тип-наследник.

 Foo foo;
 Bar bar = new();
 foo = bar;

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

Foo foo = new();
Bar bar;
bar = foo; // compilation error

Можно провести такую аналогию: ссылка - это коробка, а объект в эту коробку помещается оператором присваивания. Наследник базового класса - это объект поменьше, и коробка у него тоже поменьше. В примере выше foo = bar значит “достать объект из коробки bar и положить его в коробку foo”. Выполнить bar = foo невозможно, потому что в коробке foo в данный момент лежит слишком большой объект, он не влезет в bar.

В чём отличие виртуальных методов от невиртуальных в нашей аналогии? При вызове невиртуального метода симулятор смотрит только на коробку, а при вызове виртуального – на содержимое коробки.

Чуть больше деталей и чуть меньше коробок

Немного пояснений для тех, кому интересно происходящее под капотом.

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

Если метод виртуальный, то во время компиляции для раннего связывания компилятору нужно знать тип объекта по ссылке, что возможно далеко не всегда. Поэтому для каждого класса с виртуальными методами создаётся таблица виртуальных функций (vtable), содержащая пары индекс - адрес метода. В сам класс добавляется указатель на эту таблицу (vpointer). Имя метода в точке вызова превращается в индекс внутри таблицы. Используя указатель на таблицу и индекс внутри таблицы, симулятор находит адрес виртуального метода.

У меня нет полной уверенности, что виртуальные методы в SystemVerilog работают именно так, однако примерно так они работают в C++ и подобных языках. Учитывая близость SystemVerillog к C++, можно с большой долей уверенности считать, что данное объяснение релевантно.

Более запутанные случаи

Вызов метода из объекта

Надеюсь, что с вызовом виртуальных методов через ссылки теперь всё встало на свои места. Что насчёт вызова виртуальных методов внутри самого объекта?

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

virtual class foo;
 virtual function void my_name();
 $display("foo");
 endfunction
 
 function new();
 my_name();
 endfunction
 
 function wen();
 my_name();
 endfunction
endclass
 
class bar extends foo;
 virtual function void my_name();
 $display("bar");
 endfunction
 
 function new();
 super.new();
 endfunction 
 
 function wen();
 super.wen();
 endfunction
endclass
 
module top;
 initial begin
 automatic bar x = new();
 x.wen();
 end
endmodule

Что, по-вашему, будет выведено? Посмотрите, с каким из симуляторов вы согласны.

Simulator Output
VCS bar
bar
Questa foo
bar
Xcelium bar
bar
Riviera foo
bar

Вызов x.wen() во всех симуляторах напечатал bar, как и ожидалось. Что же случилось при вызове конструктора?

По правилам работы виртуальных методов, должен был быть вызван метод my_name() из класса bar, однако на момент вызова мы находились в конструкторе класса foo, а класс bar ещё даже не начал создаваться, что делает ситуацию неоднозначной.

Если вам кажется, что VCS и Xcelium просто оказались чуточку умнее, я буду вынужден вас расстроить. Это поведение опасно. Давайте добавим ещё один класс baz.

class baz;
 int x = 1;
endclass
И изменим класс bar.

class bar extends foo;
 baz baz_o = new();
 
 virtual function void my_name();
 $display("bar");
 $display(baz_o.x);
 endfunction

Полный код: https://edaplayground.com/x/MFhz

Что теперь произойдёт при исполнении? Согласно пункту 8.7 стандарта создание объекта происходит в следующем порядке: 1. Конструктор класса вызывает конструктор базового класса. 2. После завершения работы конструктора базового класса инициализируются поля класса. 3. После этого происходит дальнейшее исполнение кода конструктора класса.
class bar extends foo;
 baz baz_o = new();
 function new();
 super.new();
 // baz_o = new() executes here
 endfunction 

То есть, на момент вызова super.new() поле baz_o ещё не было инициализировано и равно null. Если запустить симуляцию в VCS, то она упадёт с ошибкой Null Object Access. Та же участь ожидает и Xcelium. Riviera и Questa чувствуют себя прекрасно, так как и не пытаются обратиться к чужой переменной.

В С++ механизм виртуальных методов выключен в конструкторе для защиты именно от такой опасности. Следуют ли разработчики Riviera и Questa этой практике осознанно, или просто так получилось, я не знаю.

Так какой же симулятор поступил правильно? Который следовал стандарту буквально, или который не дал выстрелить в ногу? Точно можно сказать, что поступили неправильно: 1. стандарт, который выложил такие прекрасные грабли, никак не указав на случай, когда виртуальные методы просто не могут работать как должны; 2. разработчик, который на эти грабли радостно наступил.

То есть просто не нужно вызывать виртуальные методы в конструкторе и всё у вас будет хорошо.

Но что же делать, если вызвать виртуальный метод в конструкторе очень хочется? Как правило, такая задача возникает при инициализации объекта. Раз виртуальный метод нельзя использовать в конструкторе, нужно его оттуда вынести. Вопрос лишь в том, куда. Рассмотрим два возможных варианта.

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

bar x = new();
x.my_name();

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

Безопаснее будет добавить в класс статический метод и создавать объект с его помощью, а конструктор сделать protected.

class bar extends foo;
 static function bar create();
 bar x = new();
 x.my_name();
 endfunction

 protected function new();

 // ...

 endfunction

endclass
 

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

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

Меняем сигнатуру метода

При переопределении метода его входные аргументы и возвращаемый тип не обязаны точно совпадать с родительскими. Ниже слегка изменённый пример из пункта 8.20 стандарта.

typedef int T; // T and int are matching data types.
typedef bit signed [31:0] MyInt;

class Foo; endclass
class Bar extends Foo; endclass

class C;
 virtual function Foo some_method(int a); endfunction
endclass

class D extends C;
 virtual function Bar some_method(T a); endfunction
endclass

class E #(type Y = logic) extends C;
 virtual function Bar some_method(Y a); endfunction
endclass

module tb;
 initial begin
 // E#() ee = new(); Error: logic and int are not matching types
 E#(MyInt) e = new();
 end
endmodule

При переопределении допускается заменить тип возвращаемого значения либо на наследника, если это класс, либо на совпадающий тип (matchig type). Если грубо, то совпадающий тип - это тот же тип под другим именем (сравните int и MyInt), но есть нюансы со структурами. Подробно об этом в пункте 6.22.1 стандарта.

Тип входных аргументов можно заменить на совпадающий тип.

Допускается также при переопределении добавлять и опускать ключевое слово virtual. https://edaplayground.com/x/GZbg

class A;
 function void my_name();
 $display("A");
 endfunction
endclass

class B extends A;
 virtual function void my_name();
 $display("B");
 endfunction
endclass

class C extends B;
 function void my_name();
 $display("C");
 endfunction
endclass

class D extends C;
 function void my_name();
 $display("D");
 endfunction
endclass

module automatic tb;
 initial begin
 A a = new();
 B b = new();
 C c = new();
 D d = new();
 a = b;
 a.my_name();
 c = d;
 c.my_name();
 end
endmodule

В классе A метод не был виртуальным и при использовании ссылки типа A будет вести себя как невиртуальный. Однако после его переопределения виртуальным в классе B он станет виртуальным во всех наследниках, независимо от наличия модификатора virtual.

Вывод:

# KERNEL: A 
# KERNEL: D

С точки зрения чистоты кода, лучше virtual не опускать: не заставляйте других просматривать всю иерархию в поисках одного слова. Исключение составляют, быть может, совершенно очевидные случаи типа фаз UVM. И так все знают, что они виртуальные.

Вызов конкретной реализации метода

Несмотря на уверения стандарта о существовании только одной реализации метода, и на вызов метода согласно типу объекта, существует случай, когда вызывается реализация не объекта, а родителя. И это просто super.

https://edaplayground.com/x/WbsT

class Foo;
 virtual function void my_name();
 $display("Foo");
 endfunction
endclass

class Bar extends Foo;
 virtual function void my_name();
 super.my_name();
 $display("Bar");
 endfunction
endclass

class Baz extends Bar;
 virtual function void my_name();
 super.my_name();
 $display("Baz");
 endfunction
endclass

module tb;
 initial begin
 Foo foo;
 Baz baz = new();
 foo = baz;
 foo.my_name();
 end
endmodule

Точно так же, как и в невиртуальных методах, будет вызвана реализация родителя.

# KERNEL: Foo 
# KERNEL: Bar 
# KERNEL: Baz

При переопределении метода иногда бывает необходимо вызвать реализацию не super, а super.super. Это бывает, когда прародитель выполняет инициализацию, а поведение родителя мы хотим переопределить и вообще не вызывать. Писать super.super нельзя, однако задача решается легко.

class Baz extends Bar;
 virtual function void my_name();
 Foo::my_name();
 $display("Baz");
 endfunction
endclass

Вывод, с которым согласны все симуляторы.

# KERNEL: Foo 
# KERNEL: Baz

То есть оператор :: позволяет вызвать реализацию конкретного класса. Но сейчас мы находились в том же методе, чью реализацию выбирали. А можно ли вызвать реализацию метода вне этого метода? Добавим в класс Foo невиртуальную функцию и попытаемся вызвать Foo::my_name.

class Foo;
 virtual function void my_name();
 $display("Foo");
 endfunction
 
 function try_to_choose();
 Foo::my_name();
 endfunction
endclass

class Bar extends Foo;
 virtual function void my_name();
 super.my_name();
 $display("Bar");
 endfunction
endclass

class Baz extends Bar;
 virtual function void my_name();
 super.my_name();
 $display("Baz");
 endfunction
endclass

module tb;
 initial begin
 Foo foo;
 Baz baz = new();
 foo = baz;
 foo.try_to_choose();
 end
endmodule
 
Simulator Output
VCS Foo
Bar
Baz
Questa Foo
Xcelium Foo
Riviera Foo
Bar
Baz

Мнения разделились. Questa и Xcelium вызвали метод указанного класса, тогда как VCS и Riviera вспомнили, что метод-то ещё и виртуальный, и вызвали его как таковой. С одной стороны, это ближе к правилам работы виртуальных методов, а с другой - совсем не то, чего хотел автор. Впрочем, делать метод виртуальным и полагаться на вызов его конкретной реализации - не самая лучшая идея. Нужно выбрать что-то одно.

Применение виртуальных методов

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

Полиморфизм

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

virtual class packet;
 pure virtual function int to_raw();
endclass

class data_pkt extends packet;
 virtual function int to_raw();
 // convert to integer
 endfunction
endclass
 
class control_pkt extends packet;
 virtual function int to_raw();
 // convert to integer
 endfunction
endclass
 
class driver;
 task drive_okt(packet pkt);
 int raw = pkt.to_raw();
 foreach(raw[i]) begin
 // drive bit
 end
 endtask
endclass

Сейчас вам должно быть ясно, почему метод to_raw должен быть виртуальным и как будет работать драйвер. Отметим детали, которых мы не касались ранее.

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

packet pkt = new();

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

Метод to_raw в базовом классе packet объявлен как pure virtual. Это значит, что класс не предоставляет реализацию этого метода, однако его неабстрактные наследники обязаны иметь реализацию этого метода. Иными словами, реализацию обязан предоставить первый неабстрактный наследник в иерархии.

Переопределение поведения

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

Обе задачи можно решить с помощью полиморфизма и фабрики. Попробуем подменить транзакции. https://edaplayground.com/x/aKcd

import uvm_pkg::*;
`include "uvm_macros.svh"

class transaction extends uvm_object;
 `uvm_object_utils(transaction)
 
 rand bit [3:0] body;
 
 function new(string name = "transaction");
 super.new(name);
 endfunction
 
 virtual function bit parity();
 return ^(body);
 endfunction
endclass

class error_transaction extends transaction;
 `uvm_object_utils(error_transaction)
 
 function new(string name = "error_transaction"); endfunction
 
 virtual function bit parity();
 return !super.parity();
 endfunction
endclass

module tb;
 int count = 0;
 always #1 begin
 automatic transaction t = transaction::type_id::create();
 void'(t.randomize());
 $display("Body: %b; Parity: %0d", t.body, t.parity());
 // do something with transaction
 count++;
 end
 
 initial begin
 wait(count == 4);
 $display("Inject error");
 transaction::type_id::set_type_override(error_transaction::get_type());
 wait(count == 6);
 $display("Return to normal");
 transaction::type_id::set_type_override(transaction::get_type(), 1);
 wait(count == 8);
 $finish();
 end
endmodule

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

# KERNEL: Body: 0110; Parity: 0 
# KERNEL: Body: 0010; Parity: 1 
# KERNEL: Body: 1101; Parity: 1 
# KERNEL: Body: 0000; Parity: 0 
# KERNEL: Inject error 
# KERNEL: Body: 0011; Parity: 1 
# KERNEL: Body: 0110; Parity: 1 
# KERNEL: Return to normal 
# KERNEL: UVM_WARNING @ 6: reporter [TYPDUP] Original and override type arguments are identical: transaction 
# KERNEL: UVM_INFO @ 6: reporter [TPREGR] Original object type 'transaction' already registered to produce 'error_transaction'. Replacing with override to produce type 'transaction'. 
# KERNEL: Body: 1001; Parity: 0 
# KERNEL: Body: 0000; Parity: 0 

Ключевой момент в том, что работающий с транзакциями код менять не понадобилось. Да, в таком простом примере это было бы несложно, но в большом тестбенче всё становится не столь однозначно.

Фабрика немного возмущается при возвращении оригинального типа. Это ожидаемо для UVM-1.2, а вот UVM-1.1d не сможет вернуть тип обратно из-за бага в фабрике.

Задача с драйвером решается аналогично: наследуемся от базового драйвера, переопределяем нужные методы, которые должны быть виртуальными, используем фабрику для переопределения типа создаваемого драйвера. Оригинальный код агента менять в данном случае не нужно, он полностью переиспользуется. Переопределить тип в фабрике можно откуда угодно. Хорошие места для этого - базовый тест и окружение (environment).

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

Интерфейсные классы

Это обширная и непростая тема, заслуживающая отдельной статьи. Поэтому мы коснёмся её лишь вкратце.

Интерфейсный класс представляет собой набор чисто виртуальных методов. Отличие от абстрактного класса заключается в том, что интерфейсный класс не имеет свойств (только методы), и не наследуется, а реализуется. Каждый класс может наследовать только один класс, однако реализовывать любое количество интерфейсных классов.

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

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

За подробностями можно обратиться к пункту 8.26 стандарта, а примеры использования можно увидеть в этой замечательной статье.

Где использовать не стоит

Рассмотрим, наконец, случаи, где использование виртуальных методов будет только мешать.

Во-первых, стандарт допускает использовать невиртуальные методы в качестве выражений wait и @

wait(obj.method());
@(obj.method())

Стандарт явно не запрещает использовать таким образом виртуальные методы, поэтому у меня нет претензий к Xcelium, Questa и Riviera, которые компилируют эти выражения с виртуальным методами. Отказался лишь VCS. Но можно обернуть виртуальный метод в невиртуальный, и тогда компиляция пройдёт.

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

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

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

Если вы разрабатываете VIP на продажу, то встаёт вопрос лицензирования. Если проверка лицензии реализована в методе класса, то очень бы не хотелось, чтобы кто-то переопределил её на return 1.

А вот в Java вообще всё виртуальное…

Виртуальные методы позволяют нам использовать гибкость полиморфизма, а вот ситуаций, где методы должны быть строго невиртуальными, кажется, совсем немного. Так может стоит все методы сразу объявлять виртуальными? Это отличная тема для холивара, но я сам придерживаюсь положительного ответа на этот вопрос.

В моей практике мне ни разу ещё не приходилось убирать модификатор virtual, но приходилось его добавлять. Ну кому и зачем, думал я, может понадобиться переопределять вот этот вот метод? В результате я сам, полугодом позже переопределяю этот метод, позабыв о его невиртуальности в базовом классе и получаю неожиданное поведение тестбенча. А можно было бы и не тратить время на отладку, сразу объявив метод виртуальным.

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

Эксперименты

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

Попробуем выяснить, насколько всё плохо.

class foo;
 virtual function int virtual_add(int a, int b);
 return a + b + 1;
 endfunction
 
 function int nonvirtual_add(int a, int b);
 return a + b + 1;
 endfunction
endclass

class bar extends foo;
 virtual function int virtual_add(int a, int b);
 return a + b + 2;
 endfunction
 
 function int nonvirtual_add(int a, int b);
 return a + b + 2;
 endfunction
endclass

class tester;
 static function void do_test();
 foo handle;
 longint N = 5000000; // iterations
 longint M = 2; // rounds
 if ($urandom_range(1, 0) == 0) begin
 foo t = new();
 handle = t;
 // handle = foo::new(); Xcelium fails to compile this o_o
 end else begin
 bar t = new();
 handle = t;
 end
 $display("Test virtual");
 repeat(M) begin
 $system("echo $(($(date +%s%N)/1000000)) >./st"); // that's how we get milliseconds
 repeat(N) begin
 int c = handle.virtual_add(1, 2); 
 end
 $system("echo $(($(date +%s%N)/1000000)) >./end");
 $system("s=`cat ./st`; e=`cat ./end`; echo `expr $e - $s`");
 end
 
 $display("Test nonvirtual");
 repeat(M) begin
 $system("echo $(($(date +%s%N)/1000000)) >./st");
 repeat(N) begin
 int c = handle.nonvirtual_add(1, 2);
 end
 $system("echo $(($(date +%s%N)/1000000)) >./end");
 $system("s=`cat ./st`; e=`cat ./end`; echo `expr $e - $s`");
 end
 endfunction
endclass

module tb;
 initial begin
 tester::do_test();
 end 
endmodule 

Сразу же сделаем несколько пояснений по коду.

  • Достаточно умный компилятор в некоторых случаях может заметить, что в момент вызова виртуального метода тип класса известен однозначно. В этом случае позднее связывание можно заменить ранним. Чтобы исключить такую оптимизацию, мы выбираем класс случайным образом.
  • Класс выбирается до начала эксперимента. Во-первых, если класс выбирается перед каждым вызовом метода, скорее всего по смыслу нужен именно виртуальный метод. Во-вторых, не станем раздувать и того большую статью и оставим вариации эксперимента очумелым ручкам.

На момент написания статьи у меня нет возможности запускать все симуляторы на одном и том же железе, поэтому я приведу только относительные результаты измерений.

Simulator Virtual slower, %
Riviera 7
Questa 64
VCS 1
Xcelium 10

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

Как можно объяснить высокую эффективность? Рандомизировали объект мы только единожды, что упрощает задачу на аппаратном уровне. Код нужного метода может храниться в кэше, а предсказание условных переходов быстро выбирает этот метод. Это же может объяснить и проблемы Квесты: она работала через виртуализацию. Теоретически, при компиляции вызов виртуальной функции мог быть заменён на две инлайн (встроенных) функции с простым условным ветвлением. Более подробное исследование внутренностей симуляторов выходит за рамки этой статьи.

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

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

Заключение

  • Виртуальный метод - это метод, реализация которого выбирается соответственно типу объекта, а не типу ссылки.
  • Виртуальные методы позволяют добавлять и изменять функционал существующего кода, не вмешиваясь в него.
  • Виртуальные методы не стоит использовать в конструкторе, в выражениях wait() и @(), а также там, где есть зависимость от конкретной реализации метода.
  • Из виртуального метода можно вызвать его предыдущие реализации с помощью оператора :: и super.
  • На вызов виртуального метода требуется больше времени, чем на вызов невиртуального. Однако эта разница не столь велика, чтобы заботиться о ней раньше времени.

 

PDF версию статьи ищи здесь

 Мотивировать автора     

  Поддержать FPGA комьюнити     

Оставить комментарий/отзыв

 

4118
0
5.0

Всего комментариев : 0
avatar

FPGA-Systems – это живое, постоянно обновляемое и растущее сообщество.
Хочешь быть в курсе всех новостей и актуальных событий в области?
Подпишись на рассылку

ePN