Оптимизировать размер деструкторов - PullRequest
2 голосов
/ 18 марта 2020

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

Код предназначен для анализа протокола (MQTT для чего это стоит), где есть Это многочисленные типы пакетов, и все они разные, но имеют некоторые общие части.

В настоящее время, чтобы упростить написание кода, я использую этот шаблон:

  template <PacketType type>
  struct ControlPacket
  {
      FixedHeader<type>    type;
      VariableHeader<type> header;
      Properties<type>     props;
      ... and so on...
  };   

  // Specialize for each type
  template <>
  struct FixedHeader<CONNECT>
  {
     uint8_t typeAndFlags;
     PacketType getType() const { return static_cast<PacketType>(typeAndFlags >> 4); }
     uint8 getFlags() const { return 0; }
     bool parseType(const uint8_t * buffer, int len) 
     { 
         if (len < 1) return false; 
         typeAndFlags = buffer[0]; 
         return true; 
     }
     ...  
  };

  template <>
  struct FixedHeader<PUBLISH>
  {
     uint8_t typeAndFlags;
     PacketType getType() const { return static_cast<PacketType>(typeAndFlags >> 4); }
     uint8 getFlags() const { return typeAndFlags & 0xF; }
     bool parseType(const uint8_t * buffer, int len) 
     { 
         if (len < 1) return false; 
         typeAndFlags = buffer[0];
         if (typeAndFlags & 0x1) return false;  // Example of per packet specific check to perform
         return true; 
     }
     ...  
  };

  ... For all packet types ...

Это работает, и сейчас я пытаюсь уменьшить бинарный эффект всех этих специализаций шаблонов (иначе код почти дублируется в 16 раз)

Итак, я подошел к этой парадигме:

   // Store the most common implementation in a base class
   struct FixedHeaderBase
   {
       uint8_t typeAndFlags;
       virtual PacketType getType() { return static_cast<PacketType(typeAndFlags >> 4); }
       virtual uint8 getFlags() { return 0; } // Most common code here
       virtual bool parseType(const uint8_t * buffer, int len) 
       { 
         if (len < 1) return false; 
         typeAndFlags = buffer[0]; 
         return true; 
       }

       virtual ~FixedHeaderBase() {}
   };

   // So that most class ends up empty
   template <>
   struct FixedHeader<CONNECT> final : public FixedHeaderBase
   {
   };

   // And specialize only the specific classes
   template <>
   struct FixedHeader<PUBLISH> final : public FixedHeaderBase
   {
       uint8 getFlags() const { return typeAndFlags & 0xF; }
       bool parseType(const uint8_t * buffer, int len) 
       { 
         if (!FixedHeaderBase::parseType(buffer, len)) return false; 
         if (typeAndFlags & 0x1) return false;  // Example of per packet specific check to perform
         return true; 
       }
   };

  // Most of the code is shared here
  struct ControlPacketBase
  {
     FixedHeaderBase & type;
     ...etc ...
     virtual bool parsePacket(const uint8_t * packet, int packetLen)
     {
        if (!type.parseType(packet, packetLen)) return false;
        ...etc ...
     }

     ControlPacketBase(FixedHeaderBase & type, etc...) : type(type) {} 
     virtual ~ControlPacketBase() {}
  };

  // This is only there to tell which specific version to use for the generic code
  template <PacketType type>
  struct ControlPacket final : public ControlPacketBase
  {
      FixedHeader<type>    type;
      VariableHeader<type> header;
      Properties<type>     props;
      ... and so on...

      ControlPacket() : ControlPacketBase(type, header, props, etc...) {}
  };   

Это работает довольно хорошо и позволяет сэкономить много используемого двоичного кода. Кстати, я использую final здесь, чтобы компилятор мог девиртуализироваться, и я компилирую без RTTI (очевидно, также с -Os и каждой функцией в своем собственном разделе, который собирает мусор).

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

Обычно Я понимаю, что ControlPacket<CONNECT> необходимо вызвать ~FixedHeader<CONNECT>() и что ControlPacket<PUBLISH> должен вызвать ~FixedHeader<PUBLISH>() после уничтожения.

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

1 Ответ

1 голос
/ 19 марта 2020

Стоит отметить, что это связано с оптимизацией под названием «идентичное свертывание COMDAT» или ICF. Это функция компоновщика, в которой все идентичные функции (т. Е. Пустые функции) объединены в одну.

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


Я собираюсь предположить, что ваша проблема воспроизводится на этом игрушечном примере :

#include <iostream>
#include <memory>
#include <variant>

extern unsigned nondet();

struct Base {
    virtual const char* what() const = 0;

    virtual ~Base() = default;
};

struct A final : Base {
    const char* what() const override {
        return "a";
    }
};

struct B final : Base {
    const char* what() const override {
        return "b";
    }
};

std::unique_ptr<Base> parse(unsigned v) {
    if (v == 0) {
        return std::make_unique<A>();
    } else if (v == 1) {
        return std::make_unique<B>();
    } else {
        __builtin_unreachable();
    }
}

const char* what(const Base& b) {
    return b.what();  // virtual dispatch
}

const char* what(const std::unique_ptr<Base>& b) {
    return what(*b);
}

int main() {
    unsigned v = nondet();
    auto packet = parse(v);

    std::cout << what(packet) << std::endl;
}

Разборка показывает, что A::~A и B::~B имеют (несколько) списков, даже если они пусты и идентичны. Это с = default и final.

Если удаляется virtual, тогда эти пустые определения go удаляются, и мы достигаем цели - но теперь, когда unique_ptr удаляет объект, мы вызываем undefined поведение.

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


Бесполезно: Первый вариант - использовать shared_ptr. Это работает, потому что shared_ptr на самом деле печатает свою функцию удаления (см. этот вопрос ), поэтому ни в коем случае не удаляет через базу. Другими словами, когда вы делаете shared_ptr<T>(u) для некоторого u, полученного из T, shared_ptr сохраняет указатель функции непосредственно на U::~U.

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


Полезно: альтернатива - избежать виртуальной отправки для управления временем жизни и использовать variant. Не совсем правильно делать такое общее утверждение, но , как правило, , вы можете добиться меньшего кода и даже некоторого ускорения с диспетчеризацией тегов, так как вы избегаете указания vtables и динамического c размещения.

Это требует самого большого изменения в вашем коде, потому что объект, представляющий ваш пакет, должен взаимодействовать по-другому (это больше не отношение is-a):

#include <iostream>

#include <boost/variant.hpp>

extern unsigned nondet();

struct Base {
    ~Base() = default;
};

struct A final : Base {
    const char* what() const {
        return "a";
    }
};

struct B final : Base {
    const char* what() const {
        return "b";
    }
};

typedef boost::variant<A, B> packet_t;

packet_t parse(unsigned v) {
    if (v == 0) {
        return A();
    } else if (v == 1) {
        return B();
    } else {
        __builtin_unreachable();
    }
}

const char* what(const packet_t& p) {
    return boost::apply_visitor([](const auto& v){
        return v.what();
    }, p);
}

int main() {
    unsigned v = nondet();
    auto packet = parse(v);

    std::cout << what(packet) << std::endl;
}

Я использовал Boost.Variant, потому что он выдает самый маленький код . Досадно, что std::variant настаивает на создании некоторых незначительных, но существующих vtables для реализации самого себя - я чувствую, что это немного противоречит цели, хотя даже с вариантом vtables код остается намного меньше в целом.

Я хочу указать хороший результат современных оптимизирующих компиляторов. Обратите внимание на итоговую реализацию what:

what(boost::variant<A, B> const&):
        mov     eax, DWORD PTR [rdi]
        cdq
        cmp     eax, edx
        mov     edx, OFFSET FLAT:.LC1
        mov     eax, OFFSET FLAT:.LC0
        cmove   rax, rdx
        ret

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

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

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


Наконец, как расширение вышеприведенного: вы всегда можете поддерживать некоторые виртуальные функции в базовом классе. Это может предложить лучшее из обоих миров, если стоимость vtables приемлема для вас:

#include <iostream>

#include <boost/variant.hpp>

extern unsigned nondet();

struct Base {
    virtual const char* what() const = 0;

    ~Base() = default;
};

struct A final : Base {
    const char* what() const override {
        return "a";
    }
};

struct B final : Base {
    const char* what() const override {
        return "b";
    }
};

typedef boost::variant<A, B> packet_t;

packet_t parse(unsigned v) {
    if (v == 0) {
        return A();
    } else if (v == 1) {
        return B();
    } else {
        __builtin_unreachable();
    }
}

const Base& to_base(const packet_t& p) {
    return *boost::apply_visitor([](const auto& v){
        return static_cast<const Base*>(&v);
    }, p);
}

const char* what(const Base& b) {
    return b.what();  // virtual dispatch
}

const char* what(const packet_t& p) {
    return what(to_base(p));
}

int main() {
    unsigned v = nondet();
    auto packet = parse(v);

    std::cout << what(packet) << std::endl;
}

Это дает довольно компактный код .

Что мы имеем здесь является виртуальным базовым классом (но без виртуального деструктора, так как он не нужен) и функцией to_base, которая может принять вариант и вернуть вам общий базовый интерфейс. (И в такой иерархии, как ваша, у вас может быть несколько таких для каждой базы.)

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

Опять же, я хочу указать на определение визит, на этот раз в to_base:

to_base(boost::variant<A, B> const&):
        lea     rax, [rdi+8]
        ret

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


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

Реализация варианта - это совсем другой вопрос. :)

...