Введение


Когда на IT-форумах задают вопрос «Быстрее ли язык программирования X языка Y», это обычно вызывает потоки эмоций и считается некорректным. С родни вопросу про религию или предпочтение той или иной политической партии. Действительно, язык — это способ выражения мысли, идеи. В данном случае идеи программной системы. Он не быстр и не медлен. Он может быть более или менее лаконичным, более или менее точным. А скорость определяется не столько языком, сколько конечным кодом, который генерирует компилятор этого языка. Или скоростью интерпретатора в случае интерпретируемого языка.

Но это всё философия. А на практике обычно есть практическая задача разработки ПО. И, действительно, реализовать это ПО можно на десятке разных языков программирования. Поэтому, хоть это и «религиозный вопрос» в случае публичного обсуждения, вопрос этот часто возникает в голове IT-специалиста, стоящего перед конкретной задачей. «Сколько времени мне потребуется для реализации задачи на языке X и какие у полученного ПО будут характеристики, в том числе скоростные. По сравнению с реализацией этой задачи на языке Y». Понятное дело, точного ответа на этот вопрос нет, специалист опирается на свой личный опыт и отвечает как-то типа «с вероятностью 95%, написанная на ассемблере, эта задача будет работать быстрее, чем на php». Но, положа руку на сердце, опыт этот редко базируется на точных цифрах реальных задач, которые сам этот специалист реализовал. Нет, ну кто в здравом уме будет писать сложное ПО сначала на php, а потом его же переписывать на ассемблере, только чтобы измерить характеристики? В основном ограничиваются синтетическими тестами типа сортировки массива, построения и обхода бинарного дерева и тому подобных.

Я, как специалист, пишущий 90% на C++, часто натыкаюсь на «холливарные» темы сравнения этого языка с другими. И один из них — это прародитель — язык C. На том же quora.com часто поднимают этот вопрос «А быстрее ли язык C языка C++» (что некорректно, как я объяснил выше), или «А почему ядро Linux или тонна GNU утилит пишется на C а не на C++» (что является вполне корректным вопросом). На второй вопрос я для себя ответил так:

  • Освоение языка C требует на порядок меньших усилий, значит, больше людей могут поучаствовать в разработке этого ПО.
  • Сложные действия, потенциально затратные по памяти или скорости на языке C займут, вероятно, больше строчек кода и потребуют усилия от автора. А значит, неоптимальность в программе легче будет заметить по ходу написания или ревью. Программа на языке C++ может быть куда более лаконична и, с виду, проста в понимании. Но заметить, что за перегрузкой оператора «+», к примеру, скрывается запуск космического корабля к луне, заметить будет сложнее.

Так как язык C является частью языка C++, мне по ходу моих каждодневных задач приходится решать, выразить ли какую-то часть логики «более в C стиле» (с работой с «сырыми» указателями, очисткой памяти через memset, передачей контекста через void*), или типобезопасно в C++ стиле (указатели обёрнуты в unique_ptr / shared_ptr, память зачищается нормально написанными конструкторами, контекст передаётся как типизированный объект: либо указателем на базовый класс с виртуальными функциями, либо вообще как шаблон).

Задачка


Для того, чтобы чуть более основательно ответить себе на этот вопрос, я решил написать ещё один (да-да, тоже немного синтетический) тест — кодирование данных методом Хаффмана. Навела на мысль статья «Алгоритм Хаффмана на пальцах» (https://habrahabr.ru/post/144200/).

Сначала я реализовал кодирование на чистом C. Если помните, для его реализации требуется очередь с приоритетом, потому как там для построения дерева кодирования нужно быстро находить символы, упорядоченные по числу их повторений. Алгоритмические подробности я опущу, отсылая читателя по ссылке выше (пардон за тавтологию). Собственно, на этом всё бы и закончилось, и не было бы никакой статьи, потому что кодирование я реализовывал только в качестве тренировки в алгоритмике. Но по ходу работы я заметил, как же быстро компилируется программа на C по сравнению с подобного размера исходниками на C++. И упомянул об этом коллеге по работе. Высказав предположение, что компиляция на C++ включает, наверное, ещё множество способов оптимизации. Так что подобно написанный код на C++, наверное, должен быть быстрее — там же будет работать магия самых-самых гуру в области написания оптимизирующих компиляторов. Ухмыльнувшись, коллега ответил: «Проверь».

И тогда я переписал кодирование Хаффмана на C++. Для чистоты эксперимента я не менял основополагающих принципов, к примеру, не вставлял пользовательский распределитель памяти. Это можно сделать и в C (более «кастомно») и в C++ (более «нативно»). В чём же тут тогда «C++-ность»?

Очередь с приоритетом


Первое, что логично выразить через шаблоны в C++, это очередь с приоритетом. На C она представлена в виде структуры, главный элемент которой — указатель на массив указателей на узлы с данными:

struct priority_queue
{
    // A number of active (carrying data) nodes currently in the queue
    unsigned int size;
    // A total number of nodes in "nodes" array
    unsigned int capacity;
    // An array of pointers to nodes
    struct priority_queue_node** nodes;
};

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

struct priority_queue_node
{
    unsigned int weight;
};

Так как очередь не занимается управлением памятью под сами узлы, ей незачем знать, из чего реально состоит узел. Всё, что требуется для работы, это получить его вес: ((struct priority_queue_node*) node_ptr)>weight. Добавление узла в очередь с учётом возможного перевыделения памяти выглядит несколько громоздко:

int priority_queue_push(struct priority_queue* queue, struct priority_queue_node* node)
{
    if (queue->size >= queue->capacity)
    {   
        int new_capacity = queue->capacity * 2;
        if (new_capacity == 0)
            new_capacity = 1;
        struct priority_queue_node** new_nodes = (struct priority_queue_node**) malloc(sizeof(struct priority_queue_node*) * new_capacity);
        if (! new_nodes)
        {
            return 0;
        }
        memcpy(new_nodes, queue->nodes, sizeof(struct priority_queue_node*) * queue->size);
        if (queue->capacity)
            free(queue->nodes);
        queue->nodes = new_nodes;
        queue->capacity = new_capacity;
    }

    queue->nodes[queue->size++] = node;
    heapify(queue);
    return 1;
}

Создание очереди и её удаление с обработкой всех ошибок — тоже много строчек кода по сравнению с C++ версией, что ожидаемо. Собственно, версия очереди на C++ выглядит так (внимание — на представление данных):

template <class T> class priority_queue
{
    struct node
    {   
        unsigned int m_weight;
        T m_data;
    };

    using node_ptr = std::unique_ptr<node>;

    std::size_t m_capacity;
    std::size_t m_size;
    std::unique_ptr<node_ptr[]> m_nodes;

    void heapify() noexcept;
    void increase_capacity();
public:
    explicit priority_queue(std::size_t capacity = 16) ;
    // …
};

Налицо такое же расположение элементов узла в памяти, как и в C версии — сначала идёт вес, затем полезная нагрузка узла, которая, как будет показано ниже, состоит из целочисленного скаляра — высоты дерева, содержащегося в этом узле, и указателя на корень этого дерева. Сами узлы в этой очереди приоритетов хранятся также — указатель m_nodes указывает на массив указателей на узлы.

Для сравнения — положить новый элемент в очередь теперь выглядит более лаконично и типобезопасно (перевыделение памяти — в отдельном методе increase_capacity, что не меняет сути):

template <class U>
push(unsigned int weight, U&& obj)
{   
    if (m_size >= m_capacity)
        increase_capacity();

    m_nodes[m_size++].reset(new node({weight, std::forward<U>(obj)}));
    heapify();
}

void increase_capacity()
{
    const auto new_capacity = m_capacity ? m_capacity * 2 : 1;
    std::unique_ptr<node_ptr[]> new_nodes(new node_ptr[new_capacity]);

    for (auto src = m_nodes.get(), dest = new_nodes.get(); src != m_nodes.get() + m_size; ++src, ++dest)
        *dest = std::move(*src);

    m_nodes = std::move(new_nodes);
    m_capacity = new_capacity;
}

Дерево символов (дерево кодирования)


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

#define NODE_TYPE_TERM 1
#define NODE_TYPE_NODE 2

struct char_node_base
{
    int type;
};

struct char_node_terminal
{
    struct char_node_base base;
    char c;
};

struct char_node
{
    struct char_node_base base;
    struct char_node_base* left;
    struct char_node_base* right;
};

А чтобы положить корень такого дерева в очередь с приоритетом, определена структура с, требуемым очередью, членом — хранителем веса узла:
struct char_node_root
{
    struct priority_queue_node pq_node;
    int height;
    struct char_node_base* node;
};

На C++ всё это выражается несколько элегантнее:
struct char_node_base
{
    virtual ~char_node_base() = default;
};

using char_node_ptr = std::unique_ptr<char_node_base>;

struct char_node_terminal : char_node_base
{
    const unsigned char m_c;
    char_node_terminal(char c) noexcept : m_c(c) {}
};

struct char_node : char_node_base
{
    char_node_ptr m_left;
    char_node_ptr m_right;
};

struct nodes_root
{
    int m_height;
    char_node_ptr m_node;
};

Здесь видно ключевое преимущество C++ — для корректного удаления этого дерева не нужно делать ничего. Просто удалить корень, авто указатели всё сделают сами. В C же для этой задачи написано изрядное количество кода.

Заполнение очереди и построение дерева


Этот шаг вообще не отличается для C и C++ реализации. Сначала просчитывается число повторений каждого символа во входном блоке данных, и заполняется табличка из 256 байт. Потом в очередь с приоритетом кладутся микро-деревья, состоящие только из одного терминального узла. Далее узлы объединяются путём последовательного извлечения из очереди пары, ближайшей к её вершине, и вставкой туда промежуточного узла, содержащего извлечённые прежде.

На C (здесь — без проверки на ошибки) это выглядит следующим образом:

static struct priority_queue* build_priority_queue(
    char* buffer, unsigned int size)
{
    unsigned char table[256];

    memset(table, 0, sizeof(table));

    for (unsigned int i = 0; i < size; ++i)
        if (table[(unsigned char)buffer[i]] != 255)
            ++table[(unsigned char)buffer[i]];

    struct priority_queue* queue = priority_queue_create(16);

    for (unsigned short i = 0; i < 256; ++i)
    {
        if (table[i])
        {
            struct char_node_root* node = (struct char_node_root*) malloc(sizeof(struct char_node_root));

            struct char_node_terminal* term = (struct char_node_terminal*) malloc(sizeof(struct char_node_terminal));

            term->base.type = NODE_TYPE_TERM;
            term->c = (char)i;
            node->node = (struct char_node_base*) term;
            node->height = 0;
            node->pq_node.weight = table[i];
            priority_queue_push(queue, (struct priority_queue_node*) node);
        }
    }
    return queue;
}

static struct char_node_root* queue_to_tree(struct priority_queue* queue)
{
    while (priority_queue_size(queue) > 1)
    {
        struct char_node_root* node1 = (struct char_node_root*) priority_queue_pop(queue);
        struct char_node_root* node2 = (struct char_node_root*) priority_queue_pop(queue);
        struct char_node_base* int_node1 = node1->node;
        struct char_node_base* int_node2 = node2->node;

        struct char_node* join_node = (struct char_node*) malloc(sizeof(struct char_node));
        join_node->base.type = NODE_TYPE_NODE;
        join_node->left = int_node1;
        join_node->right = int_node2;

        int new_weight = node1->pq_node.weight;
        if (new_weight + node2->pq_node.weight <= 65535)
            new_weight += node2->pq_node.weight;
        else
            new_weight = 65535;
        node1->pq_node.weight = new_weight;

        if (node1->height > node2->height)
            ++node1->height;
        else
            node1->height = node2->height + 1;
        free(node2);

        node1->node = (struct char_node_base*) join_node;
        priority_queue_push(queue, (struct priority_queue_node*) node1);
    }

    return (struct char_node_root*) priority_queue_pop(queue);
}

На C++ — ещё короче и красивее при том, что любые ошибки выделения памяти будут обработаны корректно благодаря исключениям и применению авто указателей:

void fill_priority_queue(
    const unsigned char* buffer,
    std::size_t buffer_size,
    queue_t& queue)
{
    unsigned char counts_table[256]{};

    for (auto ptr = buffer; ptr != buffer + buffer_size; ++ptr)
        if (counts_table[*ptr] != 255)
            ++counts_table[*ptr];

    for (unsigned short i = 0; i != 256; ++i)
        if (counts_table[i])
            queue.push(counts_table[i], nodes_root {0, char_node_ptr(new char_node_terminal(i))});
}

void queue_to_tree(queue_t& queue)
{
    while (queue.size() > 1)
    {
        auto old_root1_node = std::move(queue.top());
        const auto old_root1_weight = queue.top_weight();
        queue.pop();
        auto old_root2_node = std::move(queue.top());
        const auto old_root2_weight = queue.top_weight();
        queue.pop();

        auto joined_node = std::unique_ptr<char_node>(new char_node);
        joined_node->m_left = std::move(old_root1_node.m_node);
        joined_node->m_right = std::move(old_root2_node.m_node);

        const auto new_weight = std::min(old_root1_weight + old_root2_weight, 65535U);
        const auto new_height = std::max(old_root1_node.m_height, old_root2_node.m_height) + 1;
        queue.push(new_weight, nodes_root {new_height, std::move(joined_node)});
    }
}

Таблица кодирования


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

В C версии последовательность бит представляется тривиальной структурой, состоящей из указателя на массив байт и счётчика реально заполненных бит. Каких-то специальных операций для работы с ней нет. На этапе построения таблицы кодирования для каждого символа заводится такая структурка, куда копируется, найденная для текущего символа, последовательность бит. Таким образом, таблица кодирования — это просто массив этих структур для каждого символа.

struct bits_line
{
    unsigned char bits_count;
    unsigned char* bits;
};

static int build_encoding_map_node(struct char_node_base* node, struct bits_line* bits_table, unsigned char* bits_pattern, int bits_count)
{
    if (node->type == NODE_TYPE_TERM)
    {
        unsigned char index = (unsigned char)((struct char_node_terminal*)node)->c;
        bits_table[index].bits_count = bits_count;
        bits_table[index].bits = (unsigned char*) malloc(bytes_count_from_bits(bits_count + 1));
        if (! bits_table[index].bits)
            return 0;
        memcpy(bits_table[index].bits, bits_pattern, bytes_count_from_bits(bits_count));
        return 1;
    }

    static const unsigned char bit_mask[] = {1, 2, 4, 8, 16, 32, 64, 128};
    bits_pattern[bits_count >> 3] &= ~bit_mask[bits_count & 7];
    if (! build_encoding_map_node(((struct char_node*)node)->left, bits_table, bits_pattern, bits_count + 1))
        return 0;
    bits_pattern[bits_count >> 3] |= bit_mask[bits_count & 7];
    if (! build_encoding_map_node(((struct char_node*)node)->right, bits_table, bits_pattern, bits_count + 1))
        return 0;

    return 1;
}

В C++ версии битовый массив удобнее представить полноценным классом, который будет не просто управлять ресурсами, но и поддерживать, нужную далее, операцию добавления другой битовой последовательности.

using unique_bytes_ptr = std::unique_ptr<unsigned char[]>;

class bit_ostream
{
    std::size_t m_capacity;
    unsigned long m_bits_count = 0;
    unique_bytes_ptr m_data;
public:
    explicit bit_ostream(std::size_t initial_capacity = 0) noexcept
        : m_capacity(initial_capacity)
    {
    }

    bit_ostream& push(const unsigned char* bits, unsigned long const bits_count)
    {
        if (bits_count == 0)
            return *this;

        const auto new_bits_count = m_bits_count + bits_count;
        if (covered_bytes(new_bits_count) + 1 > m_capacity || m_bits_count == 0)
        {
            decltype(m_capacity) new_capacity = m_capacity * 2;
            const auto cov_bytes = static_cast<decltype(m_capacity)>(covered_bytes(new_bits_count) + 1);
            if (new_capacity < cov_bytes)
                new_capacity = cov_bytes;
            unique_bytes_ptr new_data(new unsigned char[new_capacity]);
            std::memcpy(new_data.get(), m_data.get(), covered_bytes(m_bits_count));
            m_capacity = new_capacity;
            m_data = std::move(new_data);
        }

        unsigned char* curr = m_data.get() + (m_bits_count >> 3);
        if ((m_bits_count & 7) == 0)
        {
            // All it's simple when current output data size is integer number of bytes
            std::memcpy(curr, bits, covered_bytes(bits_count));
        }
        else
        {
            const unsigned char shift = m_bits_count & 7;
            for (auto bytes_count = covered_bytes(bits_count); bytes_count > 0; ++curr, ++bits, --bytes_count)
            {
                unsigned short val = static_cast<unsigned short>(*bits) << shift;
                val |= static_cast<unsigned short>(*curr & g_bits_fill_mask[shift]);
                *curr = static_cast<unsigned char>(val & 0xff);
                *(curr + 1) = static_cast<unsigned char>(val >> 8);
            }
        }
        m_bits_count += bits_count;

        assert(covered_bytes(m_bits_count) <= m_capacity);
        return *this;
    }

    bit_ostream& push(const bit_ostream& other)
    {
        return push(other.data(), other.bits_count());
    }

    bit_ostream& clear_tail() noexcept
    {
        if (m_bits_count & 7)
            m_data.get()[m_bits_count >> 3] &= g_bits_fill_mask[m_bits_count & 7];

        return *this;
    }

    unsigned long bits_count() const noexcept { return m_bits_count; }
    bool empty() const noexcept { return ! m_bits_count; }
    unsigned char* data() noexcept { return m_data.get(); }
    const unsigned char* data() const noexcept { return m_data.get(); }
};

template <class T>
constexpr inline std::size_t covered_bytes(T bits_count) noexcept
{
    return (bits_count >> 3) + (bits_count & 7 ? 1 : 0); 
}

Соберём всё воедино


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

  1. Запомнить точку во времени ts1 с помощью rdtsc.
  2. Заполнить очередь с приоритетом символами по числу их повторений.
  3. Построить дерево символов с помощью этой очереди. Удалить саму очередь.
  4. Вычислить число циклов t1, прошедшее с момента ts1, и запомнить следующую точку ts2.
  5. Построить таблицу кодирования, обходя дерево кодирования. Уничтожить дерево кодирования.
  6. Вычислить число циклов t2, прошедшее с момента ts2, и запомнить следующую точку ts3.
  7. Осуществить собственно кодирование входного потока, заменяя каждый входной символ последовательностью бит из таблицы кодирования.
  8. Вычислить число циклов t3, прошедшее с момента ts3.

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

Замеры и оптимизация


Нетерпеливый читатель, проглядевший столько кода выше, уже ёрзает на стуле, задаваясь вопросом: «Ну так что там получилось?». Попробуем запустить обе версии, скомпилированного компилятором gcc-5.4.0 с уровнем оптимизации «O3», упаковщика на файле размером около 31 Мб. Нужно отметить, что для кодирования можно выбирать разные размеры блока данных из входного файла. По умолчанию это 64 Кб. То есть, осуществляется кодирование 31 Мб / 64 Кб блоков, а все показатели времени суммируются.

> build-c/pack-c -m3 ../sample-1.dat data-c.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1053432 (0.754 seconds), t1 = 209957, t2 = 31023, t3 = 811377.

> build-cpp/pack-cpp -m3 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1182005 (0.846 seconds), t1 = 228527, t2 = 52680, t3 = 894081

На опцию «-m3» можно не обращать внимание, это просто переключатель, означающий тестовый режим.

Ну что-ж, как-то не очень весело. То есть, C++ не дался бесплатно, провал по производительности порядка 12%. Все три этапа выполняются дольше, чем в C версии. А если размер блока выбрать поменьше, скажем, 1 Кб?

> build-c/pack-c -m3 -b1024 ../sample-1.dat data-c.dat
File packing is done. Read 31962362 bytes, written 31160081 bytes. Total ticks = 9397894 (6.731 seconds), t1 = 5320910, t2 = 1943422, t3 = 2094688.

> build-cpp/pack-cpp -m3 -b1024 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 31160081 bytes. Total ticks = 11586220 (8.3 seconds), t1 = 6399593, t2 = 3125111, t3 = 1663035

Понятное дело, просело всё, потому что теперь нужно гораздо чаще перестраивать дерево кодирования. Но C опять вырвался вперёд — аж на 23%!

Оптимизация «на глазок»


Что не так с C++ реализацией? Вроде один компилятор, один и тот же оптимизатор там. По счётчикам циклов выше видно, что самый большой вклад дают шаги, где начинается манипуляция с битами. Класс bit_ostream получился хороший. Но при наполнении таблицы кодирования так ли хорош его, чрезмерно нагруженный для составления таблицы, метод push? Ведь положить массив бит в изначально пустой объект должно быть куда проще, чем весь код в том методе. Да и таблица кодирования, составленная из 256 сущностей этого класса, занимает гораздо больше места, чем 256 структур bits_line из C версии. Попробуем сделать вариант этого класса для таблицы кодирования.

class small_bit_ostream
{
    unique_bytes_ptr m_data;
    unsigned short m_bits_count = 0;
public:
    small_bit_ostream& push(const unsigned char* bits, const unsigned short bits_count)
    {   
        const auto cov_bytes {covered_bytes(bits_count)};
        m_data.reset(new unsigned char[cov_bytes]);
        std::memcpy(m_data.get(), bits, cov_bytes);
        m_bits_count = bits_count;
        return *this;
    }   

    unsigned long bits_count() const noexcept { return m_bits_count; }
    bool empty() const noexcept { return ! m_bits_count; }
    unsigned char* data() noexcept { return m_data.get(); }
    const unsigned char* data() const noexcept { return m_data.get(); }
};

Просто. Красиво. Ничего лишнего. Даёт ли это хоть что-то? (Не тронутую C версию не привожу тут.)

> build-cpp/pack-cpp -m3 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1173692 (0.84 seconds), t1 = 229942, t2 = 46677, t3 = 890323

> build-cpp/pack-cpp -m3 -b1024 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 31160081 bytes. Total ticks = 11198578 (8.02 seconds), t1 = 6404650, t2 = 2752852, t3 = 1641317

Ну что, для большого блока улучшение — на уровне погрешности. Для маленького блока улучшение чуть заметнее — теперь C++ хуже всего на 19%. Видно по показателю t2, что заполнение таблицы стало лучше работать.

Профилирование


Начнём с проверки, как поживают кэши CPU. Запустим обе версии ПО под valgrind'ом с инструментарием «cachegrind». Вот краткий вывод для C версии.

==2794== I   refs:      2,313,382,347
==2794== I1  misses:           14,482
==2794== LLi misses:            1,492
==2794== I1  miss rate:          0.00%
==2794== LLi miss rate:          0.00%
==2794== 
==2794== D   refs:        601,604,444  (472,330,278 rd   + 129,274,166 wr)
==2794== D1  misses:        3,966,884  (  2,279,553 rd   +   1,687,331 wr)
==2794== LLd misses:            7,030  (      3,034 rd   +       3,996 wr)
==2794== D1  miss rate:           0.7% (        0.5%     +         1.3%  )
==2794== LLd miss rate:           0.0% (        0.0%     +         0.0%  )
==2794== 
==2794== LL refs:           3,981,366  (  2,294,035 rd   +   1,687,331 wr)
==2794== LL misses:             8,522  (      4,526 rd   +       3,996 wr)
==2794== LL miss rate:            0.0% (        0.0%     +         0.0%  )
==2794== 
==2794== Branches:        299,244,261  (298,085,895 cond +   1,158,366 ind)
==2794== Mispredicts:       8,779,093  (  8,778,920 cond +         173 ind)
==2794== Mispred rate:            2.9% (        2.9%     +         0.0%   )

А вот и вывод для C++ версии с теми же параметрами:

==2994== I   refs:      2,464,681,889
==2994== I1  misses:            2,032
==2994== LLi misses:            1,888
==2994== I1  miss rate:          0.00%
==2994== LLi miss rate:          0.00%
==2994== 
==2994== D   refs:        633,267,329  (491,590,332 rd   + 141,676,997 wr)
==2994== D1  misses:        3,992,071  (  2,298,593 rd   +   1,693,478 wr)
==2994== LLd misses:            8,292  (      3,173 rd   +       5,119 wr)
==2994== D1  miss rate:           0.6% (        0.5%     +         1.2%  )
==2994== LLd miss rate:           0.0% (        0.0%     +         0.0%  )
==2994== 
==2994== LL refs:           3,994,103  (  2,300,625 rd   +   1,693,478 wr)
==2994== LL misses:            10,180  (      5,061 rd   +       5,119 wr)
==2994== LL miss rate:            0.0% (        0.0%     +         0.0%  )
==2994== 
==2994== Branches:        348,146,710  (346,241,481 cond +   1,905,229 ind)
==2994== Mispredicts:       6,977,260  (  6,792,066 cond +     185,194 ind)
==2994== Mispred rate:            2.0% (        2.0%     +         9.7%   )

Можно заметить, что по попаданию в кэш и по данным и по инструкциям C++ не хуже, а иногда даже и лучше, чем C код. Предсказание переходов вообще лучше работает. А почему же он проваливается слегка? Очевидно, что в нём просто выполняется больше инструкцкий — 2 464 млн. против 2 313. Что и даёт примерно ту разницу в производительности, что была заметна при использовании больших блоков.

При анализе инструментарием «callgrind» видно, что много инструкций тратится на работу с кучей — malloc и free. Но помимо этого в C++ версии встречаются ещё и значительные, с точки зрения инструкций, упоминания операторов new и delete. А всегда ли они нужны? Те самые операции с битовыми массивами, что отдельно упоминались выше, реализованы с использованием авто указателя unique_ptr, для которого память выделяется с помощью new[]. Вспомним, что данный оператор внутри обращается к C-шному malloc, а затем инициализирует каждый объект в созданном массиве. То есть в нашем случае заполняет массив нулями. А зачем это программе? Класс bit_ostream сразу после получения массива заполняет его битами, дописывая их в конец. И хранит счётчик записанных бит. Ему вовсе не нужно, чтобы массив байт был предварительно очищен. Попробуем написать простейший адаптер для управления памятью через malloc / free, но с использованием unique_ptr, чтобы так же удобно не думать о её очистке.

struct free_deleter
{
    void operator()(void* p) const noexcept { std::free(p); }
};

template <class T> inline T* allocate_with_malloc(std::size_t size)
{
    T* res = static_cast<T*>(std::malloc(sizeof(T) * size));
    if (! res)
        throw std::bad_alloc();
    return res;
}

template <class T>
using unique_malloc_array_ptr = std::unique_ptr<T[], free_deleter>;

template <class T>
inline unique_malloc_array_ptr<T> unique_allocate_with_malloc(std::size_t size)
{
    return unique_malloc_array_ptr<T>(allocate_with_malloc<T>(size));
}

// Typedefs for byte arrays
using unique_bytes_ptr = unique_malloc_array_ptr<std::uint8_t>;

inline unique_bytes_ptr allocate_bytes(std::size_t size)
{
    return unique_bytes_ptr(unique_allocate_with_malloc<std::uint8_t>(size));
}

Проверим предположение на том же файле с кодированием большими и малыми блоками (как и прежде, C версия не менялась, так что её не привожу).

> build-cpp/pack-cpp -m3 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1042665 (0.746 seconds), t1 = 250480, t2 = 45393, t3 = 740163

> build-cpp/pack-cpp -m3 -b1024 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 31160081 bytes. Total ticks = 11068384 (7.93 seconds), t1 = 6488100, t2 = 2694562, t3 = 1501027

Ну что-ж, на больших блоках C++ реализация обогнала C! На малых всё пока что хуже, хотя лучше, чем в предыдущем эксперименте. Количество инструкций на всю программу — 2 430 млн. вместо 2 464. Количество обращений к данным тоже сократилось с 633 млн. до 536. Понятно, что на малых блоках новая реализация практически осталась как была — там же в основном играет роль построение дерева кодирования, а его код не менялся.

Ещё пару капелек


Обратим внимание на очередь с приоритетом, которую так красиво и лаконично удалось реализовать на C++. Она, как и всё остальное, использует авто указатели для управления памятью. Есть один главный указатель m_nodes, который указывает на массив указателей на узлы. В ходе выполнения любой изменяющей операции содержимое конечных указателей переставляется, как правило, выражением ptr1 = std::move(ptr2). Что тут «зарыто»? Указатель ptr1 должен проверить, что он ни на что не указывает, в противном случае удалить ресурс. Указатель ptr2 должен обнулиться после того, как ресурс у него заберут. Да, это малое количество инструкций, тут почти не о чем разговаривать. Но! Во всех операциях с очередью строго известно, когда и что на что указывает. Поэтому таких проверок и обнулений делать не надо. А копирование сырых указателей занимает одну (!) инструкцию. Давайте заменим конечные указатели в очереди с приоритетом на сырые и прогоним тесты ещё раз.

> build-cpp/pack-cpp -m3 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1008990 (0.722 seconds), t1 = 221001, t2 = 44870, t3 = 736557

> build-cpp/pack-cpp -m3 -b1024 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 31160081 bytes. Total ticks = 10683068 (7.65 seconds), t1 = 6101534, t2 = 2689178, t3 = 1505929

Ну что-ж, на больших блоках C++ быстрее на 4,3%, на малых — медленнее всего на 13,6%. Инструкций теперь 2 413 млн., обращений к данным — 531 млн.

Заключение


К каким мыслям я пришёл по ходу такого сравнительного анализа на примере задачки по кодированию Хаффмана?

  1. Сначала обе версии программы я реализовал «в лоб», не особенно задумываясь о каких-то специфических оптимизациях. Но так получилось, что C версия сразу получилась быстрее, а C++ я «дотягивал» до неё, изучал, профилировал и т. п. В результате я получил в среднем такое же быстрое решение. (Я думаю, что и этап построения дерева кодирования можно «допилить», чтобы он не «провисал» по скорости.) Но я хотел отметить то, что сам процесс написания программы на C «вёл меня» по пути создания быстрого кода, а в случае C++ пришлось заниматься дальнейшим анализом.
  2. Тем, кто хорошо знает C++, писать на нём гораздо удобнее, чем на C. Программы получаются лаконичнее и они лишены множества потенциальных ошибок, которые можно сделать на C. По ходу написания и отладки C программы я столкнулся с множеством (сравнивая с C++) случаев некорректного использования указателей, обращения к очищенной памяти, неверного преобразования типов. В хорошо типизированной C++ программе по максимуму действует правило — компилируется, значит корректна. Удаление сложных структур вместе с механизмом исключений — вообще сила, потому что здесь не нужно ни строчки пользовательского кода (разве что вывести сообщение об ошибке), а программа корректно обрабатывает такие ситуации «из коробки».
  3. Заметить, что что-то работает медленно в ходе профилирования очень сложно, потому что это субъективная оценка. У меня было две реализации, и я мог сравнить эквивалентные шаги. Но в реальности будет только одна реализация, потому что в начале выбрали для неё язык «XYZ». Как понять, быстро она работает, или медленно? Сравнить то не с чем. Если бы я написал только C++ реализацию и в конце замерил, что она обрабатывает 31 Мб за 0.85 секунды, я бы сразу считал, что это эталон скорости!
  4. Что же касается лично моих предпочтений при написании программ в будущем, то здесь подход следующий. Если мне нужно написать «молотилку», действия которой в основном будут состоять из миллионов похожих действий, то лучше писать её в C стиле, чтобы увидеть/реализовать самостоятельно все, пусть даже самые незначительные, шаги. Ведь каждая лишняя инструкция тут на миллионах повторений даст просадку. Если же я пишу сложную управляющую логику с очень разнообразными действиями, не сводящимися к «повторить вычисления A, B, C миллион раз», то лучше взять на вооружение всю мощь типизированного C++. Потому что конечное время работы такой программы уже не будет сводиться к вопросу «а за сколько времени она обработает терабайт данных», управляющая логика будет зависеть от множества внешних факторов и сценариев использования. И говорить о точных скоростных характеристиках не придётся. А вот корректность такой логики «из коробки» будет в сто крат ценнее, потому что никому не захочется вычищать из неё баги в C версии до скончания веков. И иметь в конце концов «жёсткую» версию кода, которую невозможно изменить, адаптировать под новые нужды, потому что, где бы ни тронул, всё посыпется. И начинай отладку сначала.


P.S. Исходный код можно взять тут: http://corosan.ru/data-exp/haffman_pack_for_article.tar.bz2

Update 1. По просьбам в комментариях я проверил работу упаковщиков, собранных компилятором clang-3.8.0. Результаты таковы.

C версия:
> build-c/pack-c -m3 ../sample-1.dat data-c.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1139373 (0.816 seconds), t1 = 206305, t2 = 29559, t3 = 902493.

Неоптимизированная C++ версия:
> build-cpp/pack-cpp -m3 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1417576 (1.01 seconds), t1 = 223441, t2 = 53057, t3 = 1134400

Оптимизированная C++ версия:
> build-cpp/pack-cpp -m3 ../sample-1.dat data-cpp.dat
File packing is done. Read 31962362 bytes, written 32031809 bytes. Total ticks = 1174028 (0.84 seconds), t1 = 215059, t2 = 59738, t3 = 892821

Относительный расклад сил не меняется, а в абсолютном плане clang генерирует код чуть-чуть медленнее, чем gcc.

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


  1. firk
    28.01.2018 13:59
    -2

    На второй вопрос я для себя ответил так:

    На самом деле ответ на второй вопрос кроется в первом пункте заключения. А именно — при прочих равных написание на C сразу ведёт к эффективному коду без излишеств, по сравнению с C++, хоть и занимает, вероятно, больше времени. Простой выбор между качеством результата и скоростью разработки. C++ в этом плане занимает нишу, среднюю между C и PHP — в последнем эффективность продукта по умолчанию вообще не учитывается в угоду финансовой эффективности разработки.


    1. saterenko
      29.01.2018 11:17

      Вы хотя бы пишите за что ставите автору комментария минусы… Я тоже согласен с тем, что написано в комментарии… Более того автор статьи об этом же пишет: "...C «вёл меня» по пути создания быстрого кода, а в случае C++ пришлось заниматься дальнейшим анализом...". В C нет STL с кучей плюшек, поэтому под задачу, как правило, создаются свои, более оптимальные структуры. Но за это приходится расплачиваться более «длинным» кодом и большим временем на отладку.


      1. YetAnotherSlava
        29.01.2018 16:20
        +1

        Выполняю пожелание и пишу, за что минусы:
        За heartbleed, за goto fail, за poodle, и за всё прочее в том же духе. А еще за 5 слоёв виртуализации над JS: 1) js-песочница рабочего процесса браузера, 2) пользовательские права процесса, 3) ring-3, 4) ring-0, 5) гипервизор, на случай если враг прорвался в ring-0.

        И всё это написано на быстрой сишечке. Настолько быстрой, что как в анекдоте про секретаршу со скоростью набора 600 знаков в минуту «но такая фигня получается!».


        1. saterenko
          29.01.2018 16:32

          Т.е. тот факт, что почти весь линукс, одна из самых надёжных и используемых на серверах операционок написана на сях, это мы не принимаем во внимание? На то, что куча высоконагруженных проектов используют nginx, написанный на сях мы тоже не обращаем внимание? И таких проектов много… Все перечисленные вами проблемы возможны только на C и невозможны на C++ и других языках? Другие языки исключают человеческий фактор и невнимательность?

          Но вы ответили на мой вопрос, я так и подумал: зелень с завышенным самомнением и стадным инстинктом — если минус поставили, значит и я поставлю…


      1. 0xd34df00d
        29.01.2018 21:47

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

        Минусы не ставил, если что.


        1. saterenko
          30.01.2018 10:46

          Мой опыт показывает, что в крупных проектах, во-первых, чистое кодирование занимает максимум 10% времени, всё остальное время уходит на продумывание и тестировании логики, т.е. даже если предположить, что на плюсах код пишется в два раза быстее (что не так для опытного программиста), выигрыш в рамках проекта получается несущественный. Во-вторых, ни разу за 18 лет работы не было, чтобы мне сказали: «Ок, давай теперь займёмся оптимизацией, у тебя есть 3 месяца», код всегда оставался в том виде, как был написан изначально, максимум — это рефакторинг для оптимизации архитектуры кода, но не оптимизации скорости его работы.

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

          Ещё один момент — обучение. Когда вы решаете задачу на плюсах, например, пишете LRU кеш, у вас уже есть std::unordered_map и std::list, которые вы будете использовать из коробки. В сях у вас их нет, т.е. вы пойдёте в гугл с вопросом типа «c fastest hash table algo», вы потратите больше времени, но вы будете знать как устроены эти структуры, какие они бывают, что оптимальнее использовать на чисел, что для строк, какая у них скорость работы и т.п.

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

          А вообще, язык — это лопата, каждый для своей задачи и каждый для своего пользователя. Программировать надо на том, на чём нравится ;)


          1. Antervis
            30.01.2018 21:07

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

            зависит от назначения конкретного участка кода. Рутину проще реализовать на более высокоуровневом и безопасном с++. Какие-нибудь SIMD-боттлнеки примерно одинаково быстро пишутся на асме/си/с++. Просто рутины количественно больше.


            1. saterenko
              31.01.2018 09:32

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


            1. 0xd34df00d
              31.01.2018 20:54

              Какие-нибудь SIMD-боттлнеки примерно одинаково быстро пишутся на асме/си/с++.

              У меня есть опыт написания SIMD-боттлнеков с темплейтами и прочими плюсофишками, чтобы некоторый общий код (обрабатывающий невыровненный кусок в начале, например) сделать единожды и типобезопасно.


          1. 0xd34df00d
            31.01.2018 20:59
            +1

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

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

            Во-вторых, ни разу за 18 лет работы не было, чтобы мне сказали: «Ок, давай теперь займёмся оптимизацией, у тебя есть 3 месяца», код всегда оставался в том виде, как был написан изначально, максимум — это рефакторинг для оптимизации архитектуры кода, но не оптимизации скорости его работы.

            А у меня такое было. От области зависит.

            И опыт, когда все данные в 384 гига оперативки не влезали, а хотелось их всё-таки обрабатывать на одной машине, поэтому я рефакторил код, тоже был.

            Далее, когда вы пишите на голых сях, у вас код уже более оптимальный

            С такой магический язык?

            вы меньше времени тратите на оптимизацию

            Но ведь на С я пишу дольше. Значит ли это, что на оптимизацию я таки трачу больше времени?

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

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

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

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

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

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

            Не так глубоко, как на С? А почему?


            1. saterenko
              01.02.2018 11:37

              С такой магический язык?

              Нет ни какой магии, это утверждение автора статьи, которое я поддержал. Отсутствие stl с реализованным структурами требует собственного написания этих структур или использования сторонних библиотек, которые выбираются на основе их эффективности для решения задачи.
              Но ведь на С я пишу дольше. Значит ли это, что на оптимизацию я таки трачу больше времени?

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

              Это в идеальном мире. Я собеседовал плюсовиков, нередко встечались такие, что не могли объяснить внутреннее устройство std::list, std::map, std::unordered_map, отличие последних двух…
              Не так глубоко, как на С? А почему?

              Я уже несколько раз писал, в C нет stl с кучей структур, вам приходится в них разбираться…

              Я пишу не о профессионалах, профессионалу всё равно на чём писать, на C или С++. У любого фаната C есть куча наработок по структурам данных и т.п., он реализует LRU-кеш одинаково быстро и на C и на С++. Я пишу о начинающих или тех, у кого не очень большой опыт.


              1. roman_kashitsyn
                01.02.2018 14:52
                +1

                Я уже несколько раз писал, в C нет stl с кучей структур, вам приходится в них разбираться…

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


                Сколько раз вы реализовывали RB-дерево самостоятельно? Часто хэш-таблицы пишете? Очереди с приоритетом? Сортировку с гарантированным O(n log(n)) (qsort не даёт никаких гарантий, если что)? В плюсах это всё на расстоянии одного инклюда. Опять же, относительно просто самому реализовать структуру данных, которую можно будет использовать повторно (я лично очень люблю flat containers).


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


                1. saterenko
                  01.02.2018 15:13

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

                  Конечно, проще использовать готовые структуры чем искать новые… Что и приводит к тому, что программисты меньше погружаются в программирование, хуже знают структуры, алгоритмы и т.п. Не все, но как мне кажется, многие… Не могу сказать, что это плохо, просто так случилось :)

                  Сколько раз вы реализовывали RB-дерево самостоятельно? Часто хэш-таблицы пишете? Очереди с приоритетом? Сортировку с гарантированным O(n log(n)) (qsort не даёт никаких гарантий, если что)? В плюсах это всё на расстоянии одного инклюда. Опять же, относительно просто самому реализовать структуру данных, которую можно будет использовать повторно (я лично очень люблю flat containers).

                  RB ни разу, один раз AVL, потом нашёл JudyArrays, которая меня полностью удовлетворила. Хеш-таблицы чаще пишу, штук 5-10 наверное уже написал, чаще для тестирования своих и чужих идей. Очереди с приоритетом не нужны были, не писал. По сортировке писал Radix Sort, Merge Sort, всякие пузырьки и перестановки не считаю, это было давно )))

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

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


  1. Vplusplus
    28.01.2018 14:35
    +1

    Спасибо за хорошую статью и подробной разбор кода и отличий в подходе к программированию на C/C++.


  1. Gorthauer87
    28.01.2018 14:38
    +5

    Интересно, а как бы rust себя а этом случае повёл.


    1. PsyHaSTe
      29.01.2018 15:34

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


      1. Corosan Автор
        30.01.2018 08:44

        Да, пожалуйста, было бы интересно посмотреть на результаты. Данные тут: corosan.ru/data-exp/sample-1.dat


  1. tronix286
    28.01.2018 15:09
    +9

    Самое главное, когда пишешь на C++, не скатиться при оптимизации на чистый Си. А то иногда получается, что файл имеет расширение .cpp, и это все, что он имеет от плюсов -)


    1. Grief
      29.01.2018 03:02

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


      1. LeonidY
        30.01.2018 00:13
        -1

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

        Если написано func1(), то простой поиск по большинству C++ приложений показывает как минимум 2-3 функции с таким именем, и даже число и тип параметров совпадает — inheritance, sir!


  1. tzlom
    28.01.2018 15:33

    А почему битовый массив самодельный? std::vector это тоже битовый массив


    1. khim
      28.01.2018 18:07
      +1

      Вы видимо имели в виду std::vector<bool>?

      Всё, что про него нужно знать: не используйте. Точка. Он пытается имитировать указатели на биты — и в результате делает это всё равно не слишком корректно, но работает медленно и плохо.


      1. MikalaiR
        28.01.2018 20:11

        Ну так есть же std::bitset


        1. Corosan Автор
          28.01.2018 20:18

          У него фиксированный размер, задаваемый при компиляции.


      1. mikhaelkh
        28.01.2018 22:11

        От этого совета мало толку, нет вариантов замены.


        1. khim
          28.01.2018 22:28

          Потому что замена зависит от того, что именно вам от этой замены нужно.

          Беда в том, что std::vector<bool> пытается решать сразу много задач — и все решает плохо.

          При этом написать замену, решающую конкретно вашу, ограниченную задачу хорошо — обычно несложно, 150-200 строк кода, может чуть больше…

          Так что… покажите задачу — можно будет обсуждать замену…


          1. mikhaelkh
            28.01.2018 23:05

            Он решает задачу экономии памяти весьма хорошо. Для конкретных задач он и не проектировался. Не всё в STL должно быть одинаково полезно.


            1. khim
              29.01.2018 08:47

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


  1. joedm
    28.01.2018 15:41
    +1

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


    1. Antervis
      28.01.2018 19:12

      не соглашусь. Сравнение си с с++ так же легитимно, как и сравнение ассемблера с си — первый дает небольшой прирост в производительности ценой многократного увеличения объема кода, времени разработки и мат. ожидания числа ошибок. Фактического ускорения может даже и не быть, если человек оптимизирует слабее компилятора. При этом надо понимать, что некоторые оптимизации (чисто алгоритмические) типа использования правильного типа контейнера, стратегии аллокации и т.д. реализуются на с++ куда проще и как правило дают больший выигрыш, чем низкоуровневые микрооптимизации.


    1. Corosan Автор
      28.01.2018 19:40

      Попробовал, добавил внизу статьи Update 1.


      1. kafeman
        28.01.2018 21:46

        в абсолютном плане clang генерирует код чуть-чуть медленнее, чем gcc.
        Не понял, кто медленный. Сам компилятор, или код, им скомпилированный?


        1. Corosan Автор
          28.01.2018 23:38

          Код, полученный в результате работы clang'а медленнее, чем код, полученный от gcc.


          1. 0xd34df00d
            29.01.2018 21:50

            Неожиданный для меня результат. Почти на всех моих задачах clang оказывается быстрее gcc, иногда на пару процентов, иногда на четверть, хоть ты сотни гигабайт в памяти для random forest'ов ворочай, хоть ищи одну из N подстрок в строке на полкилобайта.


            1. Corosan Автор
              30.01.2018 08:43

              На рабочих проектах у меня clang, как ни странно, тоже слегка проседает по отношению к gcc. Я имею ввиду именно генерируемый им код. Сам компилятор, при этом, компилирует быстрее. И сообщения об ошибках у него более точные. Поэтому мы его в continious integration гоняем.


              1. firk
                30.01.2018 13:53

                И сообщения об ошибках у него более точные.

                Что значит более точные? Если речь про то, что он на каждую ошибку/предупреждение выдает строк по 5-10 текста в консоль, так это наоборот раздражает и мешает.


            1. VlK
              30.01.2018 11:05

              Почему странный? Признаться, GCC и раньше был всегда быстрее в смысле оптимизаций. Я пользую clang ради примочек статического анализатора, сами же проекты всегда собираю на GCC, и всегда с профитом.

              Мы тут давеча соревновались с коллегой в вопросах Rust vs чистый Си, и я выигрывал стабильно 5-10% просто в счет того, что GCC ведет в оптимизациях.


              1. 0xd34df00d
                31.01.2018 21:01

                Странный потому, что, опять же, в моём опыте clang генерирует более оптимальный код последние этак пару лет.

                Мне прям уж интересно стало, что я делаю не так, что у меня gcc оказывается позади :)


                1. VlK
                  01.02.2018 11:23

                  Ну, мало ли… Может быть, вы не сравниваете последние версии компиляторов, может, не всю батарею оптимизаций GCC разворачиваете, может, у вас архитектура целевая какая-то необычная для GCC.

                  Но вот ей богу, один и тот же прожект, с одними и теми же бенчмарками у меня стабильно показывает от 5% преимущество за GCC.

                  Опять же, повторюсь, речь идет о чистом Си, плюсы — другая история.


    1. Bonart
      29.01.2018 01:00

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

      Неужели? Написать работающую версию на плюсах проще. А оптимизация еще не факт что вообще понадобится.


      1. LeonidY
        30.01.2018 00:17

        Если не важна оптимизация — написать на Python еще проще и быстрее.


        1. Bonart
          30.01.2018 01:51

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


  1. Xop
    28.01.2018 16:29
    -2

    Если же я пишу сложную управляющую логику с очень разнообразными действиями, не сводящимися к «повторить вычисления A, B, C миллион раз», то лучше взять на вооружение всю мощь типизированного C++.

    А может быть тогда имеет смысл взять что-то еще более высокоуровневое, чем C++? Что-то не требуещее компиляции по несколько минут (более-менее сложного кода), имеющее кучу удобных библиотек, позволяющее легко писать тесты? Не то, чтобы я был противником C++ — сам пишу на нем уже лет 15, но вот для своих личных экспериментов последние пару лет реально тянет писать например на связке Python + C.


    1. khim
      28.01.2018 18:13
      +3

      Python, вы уж извините, это три порядка (десятичных) замедления. Разница в 1.5-2 раза (при использовании C++ или Go) — это одно, а три порядка — это другое.


      1. Xop
        28.01.2018 19:05

        Если задача — запускать в нужном порядке по определенным условиям число-молотилки или какие-нибудь IO (реализованные опять же на C) — то 2 порядка замедления питона погоды не сделают. Наглядный пример — Keras, который является очень удобной оберткой над в том числе Tensorflow, половина которого опять же на питоне (вторая половина как раз на C/C++), и в питоновском коде программы от силы 10% времени проводят. И тут хоть в 100 раз этот питон ускорить — ощутимого эффекта не будет. Зато продуктивность с этим самым питоном за счет высокоуровневых конструкций и быстрой обратной связи (не надо ждать компиляции, проще организовать автотесты) на порядок выше.


        1. khim
          28.01.2018 21:12

          Закон Амдала никто не отменял. У вас на лаптопе, может, «в питоновском коде программы от силы 10% времени проводят», а на сервере с 96 потоками (два сокета, 24 ядра на сокет, плюс гипертрединг)?

          Или думаете, что и через 10 лет все будут использовать двух-четырёхядерные системы?


          1. creker
            28.01.2018 23:31

            И при чем здесь это? Если питон используется как удобная обертка над высокопроизводительным кодом, то какая разница, сколько там питон выполняется? Хоть сколько там потоков, на них выполняется не питон совсем. Более того, у него скорее всего будет четко фиксированное процессорное время, т.к. он лишь прослойка, призванная раскидать задачи.


            1. khim
              29.01.2018 08:52

              Более того, у него скорее всего будет четко фиксированное процессорное время, т.к. он лишь прослойка, призванная раскидать задачи.
              Процессорное время — да. А вот просто время (по секундомеру) — нет. Если на одноядерной системе у вас эта прослойка занимает 10% времени, то на 96 ядреной она будет уже занимать 90% процентов времени.

              На практике этого я пока не наблюдал, но ситуации, когда в TensorFlow управляющая программа на питоне занимает половину wall-clock time — видел уже.


          1. Xop
            29.01.2018 01:26

            Ну я в курсе про закон Амдала, и на десктопе у меня реальных 20 ядер (спасибо списанным процам и алиэкспрессу), но только это немного мимо, как уже отписали выше. Если при написании программы/сервиса/whatever была заложена масштабируемость, то при росте числа ядер/узлов доля времени "медленного" кода как минимум не будет расти, а как максимум будет падать. При этом время на разработку на плюсах будет в разы больше, чем на том же питоне, а человекочасы масштабировать гораздо сложнее. Более того, даже в чисто плюсовых проектах регулярный паттерн — два уровня кодовой базы, в нижнем все вылизывается под максимальную производительность (и там вдумчивая работа с памятью, минимизация syscalls и т.п.), а в верхнем пишется в стиле лишь бы побыстрее, и чтобы потом в этом легко разобраться было. При этом основное время выполняется как раз низкоуровневый код, а если это становится не так, то находится очердной "горячий" участок и выделяются очередные низкоуровневые примитивы. И вот этот высокоуровневый код на самом деле часто гораздо удобнее и проще было бы писать на чем-то более высокоуровневом, чем C++. Питон я только для примера привел, скорее как крайность.


            1. khim
              29.01.2018 09:00

              При этом основное время выполняется как раз низкоуровневый код, а если это становится не так, то находится очердной «горячий» участок и выделяются очередные низкоуровневые примитивы.
              А чем находится? perf'ом? Так он процессорное время показывает!

              Это всё не теоретические изыскания: как я писал выше — я уже наблюдал ситуацию с TensorFlow, когда по perf'у время уходило в основном на вычислительную часть, а вот если пересчитать его на количество используемых ядер (получив тем самым wallclock-time), то половина времени проводилась в управляющей программе на питоне, написанной в стиле «лишь бы побыстрее, и чтобы потом в этом легко разобраться было». И как раз исправление этой части давало гораздо больший выигрыш. А если бы её изначально написали не на питоне, а хотя бы на Go? Вопрос, конечно, риторический…

              Если при написании программы/сервиса/whatever была заложена масштабируемость, то при росте числа ядер/узлов доля времени «медленного» кода как минимум не будет расти, а как максимум будет падать.
              Совершенно неочевидно — с какого перепугу так будет. По нашим наблюдениям доля wall-clock time всяких pyhon'овских скриптов только растёт. Одна из причин перехода на Go в некоторых местах, кстати.


              1. mayorovp
                29.01.2018 09:36

                Ну разумеется горячие места всегда находятся в неоптимизированной части. После чего их оптимизируют (выделяют новые абстракции и переписывают их на Си или С++).


                1. Mendel
                  29.01.2018 10:35

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


                  1. khim
                    29.01.2018 13:15

                    В принципе в описанном тут кейсе можно и не переписывать на кресты а изменить логику так, чтобы никто не ждал высокоуровневую часть.
                    Примерно это в TensorFlow и сделали. Теперь на 96 потоках python занимает одно ядро на 50-60% времени, а остальное — занято «числодробилками». Что будет если ядер станет ещё в 5 раз больше?

                    В зависимости от конкретной проблемы можно или распаралелить высокоуровневый язык (тут пайтон)
                    Если бы всё было так просто. Python в принципе однопоточный — это в нём в таком количестве мест прописано.

                    Да, есть костыли, позволяющие как-то разбить задачу на несколько CPU, но после этого вы получаете программу, которая и сложная (так как мы используем нетривиальные костыли), и работает медленно (потому как python).

                    Может возникнуть вопрос о том насколько вообще возможно распаралелить высокоуровневую логику (насколько можно сделать ее работу с числодробилкой асинхронной), но в большинстве случаев это проблема алгоритма а не языка, и если оно не паралелится на пайтоне то и на другом языке не сильно распаралелится.
                    Да — но в питоне потребность в параллелизме возникает раньше (где-то лет на 10-15 раньше, если верить в закон Мура), а решается сложнее, чем в Go, Rust'е или том же C++.


                    1. Mendel
                      29.01.2018 13:27

                      Примерно это в TensorFlow и сделали. Теперь на 96 потоках python занимает одно ядро на 50-60% времени, а остальное — занято «числодробилками». Что будет если ядер станет ещё в 5 раз больше?

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

                      Да, есть костыли, позволяющие как-то разбить задачу на несколько CPU, но после этого вы получаете программу, которая и сложная (так как мы используем нетривиальные костыли), и работает медленно (потому как python).

                      Ну очень сильно зависит от задачи.
                      Довольно много задач можно паралелить так, что общение потоков будет низкое, а значит можно обойтись банальным pid = os.fork() что не особо то и усложнит задачу.


                1. Antervis
                  29.01.2018 11:14

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


      1. Siemargl
        28.01.2018 19:17

        На самом деле всего два порядка =)
        Но у него другие — более противные и важные недостатки для серьезных задач.

        Реальной замены С++ в достаточно большой области применения нет, и в ближайшем будущем и не ожидается.

        Статья хороша именно анализом мест просадок — это лишние системные вызовы аллокаций.


        1. softaria
          28.01.2018 20:56

          Реальной замены С++ в достаточно большой области применения нет, и в ближайшем будущем и не ожидается.


          Rust? Go?


          1. interprise
            28.01.2018 21:38
            +2

            го нет, раст да. Если говорить про замену именно плюсам


            1. creker
              28.01.2018 23:24

              Это смотря где замена. Go не хватит разве что в высокопроизводительном клиентском коде вроде всяких браузерных движков и игр. Тут Rust отличная замена. На бэкэндах серверов, где high-load и куча потоков, Go вполне себе замена и плюсам. Собственно, для этого и создавался.


              1. Bonart
                29.01.2018 01:11
                +1

                На бекендах серверов го сразу кончается там, где начинается что-нибудь не IO-bound.
                Сборщик мусора у го отнюдь не быстрый, регулярки тоже, для сложной логики сам язык подходит примерно никак.


                1. creker
                  29.01.2018 11:16

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


                  1. Bonart
                    30.01.2018 01:59

                    А что у него не так со сборщиком?

                    Почему сразу не так? Сборщик мусора у го оптимизирован под минимизацию времени одиночной паузы и сознательно лишен гибкости в плане настроек.
                    Как результат и по скорости аллокаций, и по скорости очистки, и по потреблению ресурсов (и практически по всему остальному, кроме максимальной длительности одиночной паузы) сборщик мусора го уступает и яве, и дотнету.
                    На хабре уже была довольно подробная статья на эту тему:
                    https://habrahabr.ru/company/mailru/blog/318504/


      1. arcman
        28.01.2018 21:22

        А если Cython использовать?
        Удобство/скорость разработки питона и производительность компилируемых языков в одном флаконе.


        1. Xop
          29.01.2018 01:34

          Или Numba, если нужны суровые числодробилки и не пугает необходимость ставить LLVM не ниже четвертой версии


        1. Mendel
          29.01.2018 13:44

          А какой у него (Cython) оверхед по скорости в сравнении с крестами? Понятно что от задачи зависит, но хоть порядок. А то гуглится только в сравнении с нативным пайтоном.
          Сейчас как раз принял решение в одном проекте менять стек языков. Один черт все переписывать. Кресты хороши в числодробилках, но слишком дороги в мозгодробилках.
          Для 70% процессорного времени у меня и так используются библиотеки которые есть на большинстве языков, так что нативный си подключить не сложно. Если Cython будет медленнее раза в два-три, то бутерброд из пайтона, Cython и нативного си будет суммарно уступать варианту полностью на крестах на 10-20%, что более чем допустимо учитывая что кодовая база будет достаточно монолитная, с пайтона на другие популярные языки (пхп, javascript, java) значительно проще чем с крестов.
          А вот если там будет десятичный порядок или больше, то общая скорость упадет раза в два, и это уже ощутимо…



      1. 0xd34df00d
        29.01.2018 21:56

        Замедление в управляющем коде я ещё могу пережить (даже с Амдалами и прочими хорошими фамилиями, на моих серверах и рабочих машинах обычно от 40 до 60 ядер, так что знаю не понаслышке).

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

        Типизация и компиляция, эх.


    1. Corosan Автор
      28.01.2018 19:46

      Я тоже люблю и использую Python. Но в этой статье хотел ограничиться сравнением именно C и C++. Потому что переход в проекте на Python — это явный шаг, у которого должны быть веские причины, который должен быть осмыслен и обсуждён с коллегами.

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


      1. sumanai
        28.01.2018 22:32

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

        Это относится к переходу на любой язык с любого языка.


  1. iyemelyanov
    28.01.2018 17:55

    Спасибо за хорошую статью! Интересно какой результат покажет clang.


    1. Corosan Автор
      28.01.2018 19:54

      См. Update 1 в конце статьи.


  1. UncleAndy
    28.01.2018 18:03

    (0.722 seconds)
    (7.65 seconds)

    Не совсем понял. Разница в 10 раз? У вас первая версия была, вроде на десяток процентов медленнее, но в какой-то момент пошла вот такая разница.

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


    1. khim
      28.01.2018 18:16
      -1

      Великолепные вопросы! Прочитайте статью ещё раз, убедитесь что «чего у вас нет» — это ваше воображение… после чего и первый вопрос тоже отпадёт…


      1. UncleAndy
        28.01.2018 18:38

        Да, извиняюсь — по второму вопросу. Не заметил что при сравнении используется разный размер блока. Поэтому и данные на выходе разные. Но я не понял смысл такого сравнения. Почему для версии на Си не использовали тот-же размер блока при сравнении?


        1. Mendel
          28.01.2018 19:06
          +3

          Использовали одинаковые размеры блоков для разных версий.
          Но результаты тестирования алгоритма архивации были слишком… архивированны :)
          Следите за руками:
          Сначала приведено сравнение Си и крестов на большом блоке.
          Потом приведено сравнение Си и крестов на маленьком блоке.
          Потом была проведена оптимизация крестового кода, и приведено…
          Нет, не то что интуитивно ожидаемо для наглядности (результаты Си и новой версии крестов для большого блока, потом результаты Си и крестов для маленького) а только то, что изменилось, а именно два результата новой версии крестов — для большого блока и для маленького блока.
          С дальнейшей оптимизацией тоже самое.
          Так что для того чтобы сравнить результаты после оптимизации нужно взять результаты после оптимизации и сравнить их с результатами Си из первых тестов.

          ПС: мне было лень листать назад и я просто читал общий вывод (быстрее на хх%, медленнее на уу%).


          1. UncleAndy
            28.01.2018 19:11

            Нда… Понятно. Как-то это неитуитивно, вот и запутался. Спасибо что разъяснили!


            1. Corosan Автор
              28.01.2018 19:49

              Да, объяснение господина Mendel повеселило. Но, на самом деле, мой косяк. Нужно было скопировать тот самый неизменяющийся C результат для наглядности.


  1. maxood
    28.01.2018 19:51

    Ну да, Huffman tree, обычно, реализуется на очередях с приоритетом, но где ж std::priority_queue в коде на С++? Где std::vector для бинарного кодирования?


    1. Corosan Автор
      28.01.2018 19:53

      priority_queue реализовано самостоятельно. И операции с битовыми массивами — тоже. Чтобы иметь возможность сравнивать как можно более близкие имплементации из C и C++. Более того, про vector выше уже выразили несколько резковатое мнение. С которым я, в прочем, согласен на 100%.


      1. maxood
        28.01.2018 20:41
        +1

        Вы реализовали priority_queue самостоятельно, бинарный вектор — не то. Может отказаться совсем от STL? Почему б не реализовать собственный unique_ptr — это совсем не сложно? И в чем тогда будет заключаться сравнение C++/C?


      1. roman_kashitsyn
        28.01.2018 21:08

        При анализе инструментарием «callgrind» видно, что много инструкций тратится на работу с кучей — malloc и free.

        priority_queue реализовано самостоятельно

        Ваша реализация priority_queue — это прямо-таки бенчмарк для аллокатора. По значению надо всё хранить. std::priority_queue должен быть ощутимо шустрее.


      1. a1ien_n3t
        28.01.2018 22:22

        Реквестирую проверку кода с std::priority_queue. std::vector<bool> это да несовсем то, что надор.
        Но вот очеред с приоритетами и stl должна хорошо себя показать.


    1. mikhaelkh
      28.01.2018 22:42

      Какие очереди с приоритетом? Для дерева Хаффмана достаточно сортировки и обычной очереди.


  1. Psychosynthesis
    28.01.2018 20:38

    Хорошая, годная статья.

    Чтобы стала ещё лучше, советую сравнение результатов оформить в виде таблиц, либо картинок. Спасибо!


    1. Corosan Автор
      29.01.2018 12:02

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


  1. aamonster
    28.01.2018 23:58

    А "скорость" компиляции C++ — часом не от того, что просто подключается больше хедеров?


    1. technic93
      29.01.2018 01:40

      Не просто хедеров а хедеров с шаблонами и прочей “черной магией“ плюсов.


    1. Siemargl
      29.01.2018 10:16

      В основном потому, что компиляторы С++ делают >=7-проходов по исходному тексту. Это У.Брайт писал.

      Для сравнения — Паскаль — 1-проходный компилятор.


      1. khim
        29.01.2018 13:18

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

        Понять, что дело вовсе не в этих 7 проходах достаточно просто: переименуйте .c файл в .cc файл, исправьте несколько мест, которые перестанут компилироваться, и замерьте время компиляции.

        Тормозят в C++ шаблоны и связанная с ними «магия», а не 7 проходов…


        1. VlK
          01.02.2018 13:03
          -2

          Оптимизирующие проходы по уже готовому абстрактному дереву или внутреннему представлению — одно; хождение многократное коду для построения этого дерева — совершенно другое. Сейчас плюсы имеют такие жуткие проблемы с заголовками не из-за проходов оптимизатора, отнюдь.

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


          1. khim
            01.02.2018 13:39
            -1

            Сейчас плюсы имеют такие жуткие проблемы с заголовками не из-за проходов оптимизатора, отнюдь.
            Советую на досуге собрать-таки какой-нибудь проект с -O0. Вы будете приятно (или неприятно, я не знаю) удивлены.


            1. VlK
              01.02.2018 14:09

              Эм.

              А вы будете удивлены, когда узнаете, насколько — относительно больших проектов на плюсах — быстро собирается ядро линукса.

              Вроде и компилятор тот же, только фронтэнд другой…


  1. Miron11
    29.01.2018 08:27
    -1

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

    По идее шаблоны, или как их называют «generics», по — Русски наверное было бы точнее сказать «переменные типа данных» в с# формально отличаются от С++ тем, что в с# их развертка происходит во время выполнения, а в С++ во время сверстки ( компиляции ) исходных текстов.

    Но если отвлечься от формального описания, и попытаться уловить, в чем собственно суть дела, то в С++ оповещение о переменной «тип данных» происходит за счет, так называемых, «include», который передается издательством. В с# «include» распространяется вместе с сверстанным ( откомпилированным ) блоком, в виде метаданных.

    В принципе, при всем желании С++ сильно отличаться от С ( и если его напичкать ещё большим количеством стандартного наполнения, то он станет работать ещё в разы медленнее и возможно станет ещё не — похожее ) здесь он копия С. Поскольку его сверстанный интерфейс ни коим образом не привязан к опубликованным в «include» вызовам интерфейса. И если в с# достаточно вставить электронную подпись, чтобы эту связь сделать принудительной, то в С++ это сделать нельзя.


    1. mayorovp
      29.01.2018 08:40
      +1

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


      1. mayorovp
        29.01.2018 09:39

        Поправка: generic не поддерживает специализацию в принципе, не только частичную. И еще generic не поддерживает обращение к статическим элементам параметра-типа.


        1. Miron11
          29.01.2018 12:39

          Вообще — то я воспользовался с#, чтобы оттенить разницу ( её отсутствие ) между С и С++.

          «при всем желании С++ сильно отличаться от С»

          То, что Вы описываете, это детали синтаксиса языка. Их всегда можно уточнить в Википедии. За что сердечно признателен :)


          1. mayorovp
            29.01.2018 12:46

            А вы знаете что существует язык где одновременно присутствуют обобщенные классы (generics) и шаблоны (templates)?


          1. khim
            29.01.2018 13:20

            То, что Вы описываете, это детали синтаксиса языка.
            Превращение чего-то, выполняющегося за ограниченное время в что-то, полное по Тьюрингу — это, я извиняюсь, далеко не «детали».


            1. Miron11
              29.01.2018 13:54

              Статья об отличие С и С++.
              Тюринг о разнице между разумом и машиной.
              Мне кажется что Ваши комментарии Тюрингу не соответствуют :)


              1. mayorovp
                29.01.2018 16:23
                +1

                Нет, полнота по Тьюрингу не имеет никакого отношения к тесту Тьюринга.


                1. Miron11
                  30.01.2018 07:40
                  -2

                  Мерилом полноты, на самом деле, является тест. Это паттерна Вашего мышления. Уйти от темы ( теста ) и заменить её некоей «полнотой», которой на самом деле нет.


                  1. mayorovp
                    30.01.2018 07:51
                    +1

                    Как я мог забыть, ведь каждый ученый имеет право только на одно открытие в жизни!


            1. 0xd34df00d
              29.01.2018 22:02

              А ещё от этого ко/контравариантность ломается :(


  1. RomanArzumanyan
    29.01.2018 08:50
    -1

    Так как язык C является частью языка C++

    Отличное начало, так держать. Cи не является подмножеством С++.


    1. Corosan Автор
      29.01.2018 09:17
      -1

      Подскажите, пожалуйста, какие действия можно выразить в программе на языке C, которых нельзя было бы выразить тем же способом на языке C++? (мелкие синтаксические различия типа переиспользования ключевого слова auto не в счёт).


      1. splav_asv
        29.01.2018 09:29
        +1

        a = { .c = 30, .a = 10 }; для структур, VLA из C99, которые правда стали не обязательны в C11.


      1. RomanArzumanyan
        29.01.2018 09:42

        Например, массив нулевого размера:

        typedef struct array{
            size_t size;
            int data[0];
        } array;


        1. splav_asv
          29.01.2018 09:51

          Причём это скорее следствие поддержки Flexible array members. Здесь data имеет неопределённую длину, но находится внутри структуры. Естественно такое поле может быть только одно и быть только последним.

          typedef struct array{
              size_t size;
              int data[];
          } array;


    1. splav_asv
      29.01.2018 09:24

      Зря минусуют то. Строго говоря C++ не является надмножеством C. Особенно с учётом последних версий C.


  1. beduin01
    29.01.2018 10:31

    Для тех кому нужен чистый Си с нулевым рантаймом, поддержкой юникода, юнит-тестами и прочими фишками есть dlang.org/blog/2017/08/23/d-as-a-better-c


  1. WinPooh73
    29.01.2018 10:54
    -1

    Абстрактный сферический конь в вакууме быстрее абстрактного додэкаэдрического на 10-15%.


  1. slsla
    29.01.2018 11:07

    после фразы
    [q]Освоение языка C требует на порядок меньших усилий, значит, больше людей могут поучаствовать в разработке этого ПО.[/q]

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

    А у вас наоборот. Может и ассемблер «требует на порядок меньше усилий в освоении»?


    1. Gryphon88
      29.01.2018 12:14

      Плюсы с каждым стандартом — это немного свой язык со своими идиомами и deprecated, поэтому лично мне в плюсах сложно.


      1. slsla
        29.01.2018 12:40

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


        1. khim
          29.01.2018 13:24

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

          Никогда и никак не меняются языки, которыми пользуются 3 с половиной разработчика и которых «и так всё устраивает».


          1. slsla
            29.01.2018 13:50

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


            1. Gryphon88
              29.01.2018 14:58

              А что не так с С? В С99 много хороших обновлений, в С1Х мне generic macro очень понравился, с ним реально проще, а вот зачем threads нужны при наличии posix threads мне неясно


              1. slsla
                29.01.2018 15:04

                ну вот про такие мелочи я и говорю
                тут рядом статья «Почему опытные разработчики пишут тупой код и как распознать новичка за километр» примерно об этом и говорит

                писать с использованием новомодных оборотов — не есть бест-практис


                1. Gryphon88
                  29.01.2018 15:20

                  только если ты сам или сообщество в целом не реализовывали эти новомодные обороты самостоятельно и с накладными расходами до появления их в языке.


                  1. slsla
                    29.01.2018 15:44
                    -1

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


                1. VlK
                  01.02.2018 13:07

                  Новомодные? :-) Вы про восемнадцатилетний C99? :-) Или, быть может, про семилетний C11?

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


              1. creker
                29.01.2018 22:34

                Наверное затем, что posix threads к С отношения не имеют. Языку давно нужны были подобные абстракции, которые и так все пишут каждый раз заново, то и дело делая ошибки, плодя уязвимости. Еще бы туда IO кроссплатформенный и много боли на ровном месте можно было бы избежать. Язык то приятный, если бы не все эти тонны велосипедов, которые надо каждый раз самому писать, потому что готовое в большинстве случаев либо вообще отсутствует, либо в составе громадной библиотеки, которую лишний раз тащить не хочется.


                1. khim
                  29.01.2018 23:52

                  На самом деле проблема в Windows. posix_threads там нету, а C — есть. Потому пришлось сделать вот такой костыль.

                  Но на самом деле проще считать его «бесплатным» дополнением к атомикам и модели памяти, позволяющей, в рамках стандарта, писать программы совместымые к posix_treads/windows thread.

                  Ну просто странно выглядел бы стандарт, где атомики и мьютексы были бы, а никакого способа создать поток — не было бы в принципе…


                  1. creker
                    30.01.2018 00:53

                    Есть у меня подозрения, что далеко не windows сподвиг их это сделать, будучи довольно непопулярной платформой для C. Visual Studio не планирует даже C99 поддерживать полностью, чего о C11 говорить.

                    Да и просто посудить, всегда приятнее иметь что-то в стандартной библиотеке, а не какой никакой, но внешней зависимости. Куда больше гарантий это дает.


    1. khim
      29.01.2018 13:23
      +1

      Ассемблер, несомненно требует меньше усилий на освоение и в приличных курсах изучается до C и/или C++.

      Другое дело, что большие программы на нём писать сложно.


      1. slsla
        29.01.2018 13:48

        … и маленькую программу делающую хоть что-нибудь осмысленное на ассемблере написать намного cложнее.

        Вон автор обзора написал что программу на С писал три вечера, а на С++ за один вечер управился. Это говорит о том что на базовом уровне С++ намного проще
        Для чего собственно его и придумывали.


  1. NickViz
    29.01.2018 11:51

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


    1. Corosan Автор
      29.01.2018 11:58

      Ну, здесь цифры вообще субъективны вконец. То есть, я просто из головы постараюсь вспомнить, сколько вечеров факультативно я потратил на это. Скажем так, Написание на C заняло три вечера. И я сделал много ошибок с работой с указателями и неправильного преобразования типов, которые, естественно, компилятором не отслеживались. То есть, я сидел в gdb и пытался понять, а что это вообще тут за байты. Скажем, вечер.

      На C++ я переписал всё это за вечер, и программа сразу получилась корректной. Благодарить ли тут C++ или уже мою подкованность в только что реализованном алгоритме — не знаю. А вот на оптимизацию со всякими valgrind'ами ещё несколько вечеров ушло.

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


      1. Miron11
        29.01.2018 13:04

        Microsoft компилятор не пробовали? Как правило он генерирует код побыстрее.
        Ваш пример это замечательный benchmark для различных компиляторов.

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

        Может такой альтернативный подход ( если gcc его поддерживает ) к оптимизации поможет С++. У него, почти наверняка, размер выполнимого больше.


        1. khim
          29.01.2018 13:31

          Microsoft компилятор не пробовали? Как правило он генерирует код побыстрее.
          Эта… вы какие вещества-то потребляете? И почему не делитесь?

          Microsoft — это самый медленный из «живых» компиляторов. И всегда был самым медленным. Так-то, «на спор» можно программу под любую пару компиляторов написать так, чтобы показать, что компилятор A быстрее компилятора B, но на практике — я не видел кода, не заточенного специально под MSVC, который бы работал быстрее при компиляции этим недоразумением.

          P.S. Хотя, впрочем, стоит признать что они делают большие успехи. Последние версии уже обычно сравнимы по скорости с компиляторами и если бы не отдельные приступы сумасшествия (типа такого), то MSVC можно было бы уже реально использовать для написания быстрого кода…


          1. mayorovp
            29.01.2018 13:56

            (комментарий был удален)


        1. Miron11
          29.01.2018 13:36

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


  1. McAaron
    29.01.2018 13:44
    -1

    ак как язык C является частью языка C++

    Язык C никогда не являлся и не является, по крайней мере в реализациях стандартов C99 и С11, частью языка C++. Какие-то старые особенности присутствуют, но самых вкусных там нет. В частности, куча дыр в инициализации структур, массивов и прочих нужных штук. Со статической инициализацией в C++ как-то вообще не сложилось — полиморфизм мешает.


  1. Chelyuk
    29.01.2018 15:17

    Ну на сколько я понял суть статьи, в очередной раз вівели разницу между низкоуровневым и высокоуровневым языком. С — воплощает собой философию Unix/Linux, потому именно на нем много кода для них и написано. Одна задача — одна программа. С++ это уже все же "швейцарский нож". Все удобнее, все быстрее пишется, зачастую код еще оказывается более переносимый, есть готовые модули, но плата за это производительность. Да можно оптимизировать и добиться лучших результатов если поставтить цель, но в реальной жизни в 90% никто этого делать не будет. Мало кто выбирает язык программирования и пишет и использует его вразрез принятых в конкретном языке парадигм.
    Поэтому язык нужно выбирать как раз из этих критериев в первую очередь. Нужно максимальное быстродействие, не нужна переносимость, нет необходимость в чрезвычайно сложных структурах данных и объектах, размер проекта и сроки не ужаты сверх меры в угоду менеджерских целей — можно делать на С. Если же сроки сжаты, колличество меньше, чем хотелось бы и охватить нужно область знаний большую чем вы детально понимаете, а также вы не хотите выжать максимум на каждой операции и вообще вы полагаете что для данного проекта необходимо в несколько раз больше людей/команд — быстрее будет сделать на С++.


    1. LeonidY
      30.01.2018 00:36

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

      а также проблемы при сопровождении, если программа превышает некий предел размера (порядка 5000-10000 строк). Из-за неустойчивости кода — ты никогда не знаешь, действительно ли оператор присвоения это просто оператор присвоения. Ты также никогда не знаешь, какой метод будет реально вызван из этого места поскольку у тебя с десяток классов, наследующих один и тот же класс и имеющих одинаковое имя.


      1. Free_ze
        30.01.2018 12:33

        Вы про абстракцию (перегрузка операторов) и полиморфизм?


        1. sumanai
          30.01.2018 17:11

          Да, он везде про это жалуется, читаю этот пост уже во второй или третий раз.


          1. LeonidY
            30.01.2018 21:03

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


            1. sumanai
              30.01.2018 23:27

              А вас что, заставляют? Отказаться никак, семья в заложниках, сами прикованы к пулемёту серверной стойке?


              1. LeonidY
                31.01.2018 00:23

                Работа такая. Нужен результат, а он помимо моего, включает в частности и выполнение огромного и чужого кода, который считается написаным и работающим. Но он не работает… иногда, лезешь в него, а там… Становиться специалистом по этому коду и назубок знать, что там перезагружено и какие классы для чего — менять профиль, а рез-т все-таки нужен, и ясно, что где-то элементарная описка. Но где?


                1. Antervis
                  31.01.2018 09:56

                  а вы хотели править код не вникая?


                  1. firk
                    31.01.2018 13:37

                    Согласен с LeonidY. Чтобы понять нормальный код, надо только прочитать его и знать язык, на котором он написан. Чтобы сделать аналогичное в обсуждаемом случае — надо либо действовать наугад, либо на каждую строчку кода сверяться ещё с десятком файлов, раскиданных по разным местам. Это обстоятельство никак радовать не может. Причём тут два аспекта.
                    Первый — уже упомянутая перегрузка стандартных операций, когда, глядя на строчку, непонятно, настоящее ли там присваивание или вызов вороха вложенных функций. И это — особенность именно C++.
                    Второй — когда вызывается функция (и видно, что это функция), может потребоваться длительное время, чтобы найти её исходный код среди кучи мусора, которым наполнен код проекта. Это — не особенность C++, а особенность плохо спроектированого ООП-кода. То есть, можно написать программу на C++ и без этого дефекта (при этом пользуясь его преимуществами), но, к сожалению, массовым кодерам проще писать с ним. Ну и встречается эта проблема не только в C++, но и, например, в Java и даже в PHP (последние модные тенденции которого представляют из себя бездумную кальку с Java), где оно накладывается на общую дефективность языка.


                    1. mayorovp
                      31.01.2018 15:17

                      И то, и другое — последствия плохого проектирования.

                      Не важно какой там ворох функций вызывается из оператора присваивания если результатом является присваивание.


                      1. firk
                        31.01.2018 15:55

                        Не важно, до тех пор пока речь не идёт об отладке. А ошибки могут быть в том числе и внутри этого псевдо-присваивания, как явные (которые сразу делают исключение или краш), так и неявные, которые просто записывают куда-то неверные данные, проявляющиеся неожиданно позднее.
                        Не говоря уже о том, что если без этого всего понятно, что присваивание — это MOV или что-то похожее в ассемблерном листинге, а с этим там может быть что угодно, никак с первого взгляда на присваивание не похожее.


                    1. Antervis
                      01.02.2018 07:22

                      Чтобы понять нормальный код, надо только прочитать его и знать язык, на котором он написан

                      … и библиотеки, которые используются в коде. В случае c++ — это зачастую stl/boost/qt
                      Первый — уже упомянутая перегрузка стандартных операций, когда, глядя на строчку, непонятно, настоящее ли там присваивание или вызов вороха вложенных функций

                      сами по себе операторы безвредны. Взять тот же std::complex — ну кому вместо c = -a*b+d; захочется писать вызов нескольких функций? Проблемы начинаются только если реализация оператора противоречит его назначению. Скажем, если operator == меняет наблюдаемое состояние объекта, это явно отвратительный код.


                1. sumanai
                  31.01.2018 18:05

                  Работа такая.

                  Если она вам так не нравится, может, смените? Освободите место для тех, кто поймёт.


        1. LeonidY
          30.01.2018 20:58

          Да. Бесконтрольное использование этого ведет к проблемам, и я не слышал о серьезных попытках их ограничить или облегчить анализ-отладку. Я работал только с одним случаем, когда это все сильно помогло и делало программу достаточно компактной — C++ парсер для ASN1 сообщения, который (парсер) генерировался. Правда код совершенно нечитаемый получался, и ходят слухи, что один из авторов жаловался, что компиляторы с Algol68 не очень распространены, код для Algol68 сгенерить можно было бы еще проще.


          1. Free_ze
            31.01.2018 08:29

            Ну что значит «бесконтрольное»? Программист знает, что делает, ему кажется важным в этом месте выразить мысль через перегрузку. Если он делает что-то плохое, неочевидное в этом месте, то это его проблема, а не инструмента.
            Второй момент состоит в том, что вы не были готовы к особенностям языка (возможность, а порой и необходимость перегрузки операторов). Опять же, почему в этом виноват язык?

            ЗЫ Кмк, тут можно злиться на то, что в C++ малоинформативные ошибки, если не выбрасывается исключение, то без должного логгирования понять где возникла беда очень сложно.


  1. lamerok
    31.01.2018 08:43

    Хорошо было графики привести с результатами тестирования, а то текст плохо смотрится.