Почему C быстрее, чем C++?
Многие неправильно понимают сходство между C и C++. Хотя C++ начинался как супермножество современного C, оба языка ушли вперед. Точно так же, как мы не являемся просто супермножествами одного или обоих наших родителей.
Существуют определенные случаи, когда можно создать исходный файл, который может быть скомпилирован как C или C++ и, используя тот же компилятор с теми же флагами компиляции, создать тот же исполняемый объект с теми же инструкциями.
Эмпирически они имеют одинаковую производительность.
На протяжении длительного периода существования этих двух языков, C был самым быстрым языком, превосходящим ассемблер, для многих сценариев, потому что он был наиболее способен к кодированию 1:1 утверждение:инструкция.
a = 1; // привязывается к руке: `mov w20, 1` К сожалению, большинство современных архитектур являются очень сложными, с несколькими уровнями кэша, спекулятивным выполнением, предикторами и глубокой конвейеризацией.
Если вы используете современный компилятор на современной архитектуре - clang, gcc, msvc; x86_64, arm64, amd64, m1 - то компиляции C и C++ будут иметь огромное количество общих кодовых путей и возможностей.
Ранний C++ - то есть особенности, отличные от C, такие как методы классов и т.д. - часто страдал от накладок производительности во время выполнения.
// реализация на Cvoid execute(int (*op)(int, int), int l, int r, int* out){ если (op) *out = op(l, r);}// реализация на C++структура S{ virtual int operator()(int l, int r);};void execute(S* op, int l, int r, int& out){ if (op) out = op->operator(l, r);} В реализации на C, то, как мы используем ‘op’, может сделать вполне очевидным, что это будет иметь не больше накладных расходов, чем обычный вызов функции.
Реализация на C++ не так однозначна, и более старые компиляторы C++ действительно должны были бы делать перенаправление:
mov %eax, DWORD PTR [%ebp+16] // поиск в виртуальной таблице mov %edx, DWORD PTR [%ecx] mov DWORD PTR [%esp], %ecx mov DWORD PTR [%esp+8], %eax mov %eax, DWORD PTR [%ebp+12] mov DWORD PTR [%esp+4], %eax вызов [DWORD PTR [%edx]] mov %edx, DWORD PTR [%ebp+20] mov DWORD PTR [%edx], %eax более сложная иерархия классов может потенциально добавить больше этих накладных расходов.
Даже современный C++ с самым современным компилятором C++ все еще имеет эти дополнительные накладные расходы на вызов виртуальной функции [edit: without additional arguments and boilerplate, apples-to-oranges comparison is relevant for a few more paragraphs tho]:
// C je .LBB0_2 mov esi, dword ptr [esp + 28] sub esp, 8// vs C++ je .LBB1_2 mov esi, dword ptr [esp + 28] mov ecx, dword ptr [eax] суб-эсп, 4 Это не делает C быстрее, это просто показывает, что некоторые шаблоны и поведение C++ несут дополнительные накладные расходы, которые могут сделать их медленнее. Вы все еще можете использовать механизм C в программе на C++, если вам нужно убрать лишнюю инструкцию.
Один вызов функции не делает программу особенно хорошей для бенчмаркинга.
И наоборот, код на языке C часто должен содержать шаблонные повторения одной и той же функции для обработки различных аргументов.
#include bool compare_int(int a, int b) { return a < b; }bool compare_float(float a, float b) { return a < b; } Программисты на языке Си часто решают эту проблему путем написания макросов
#define DECL_BOOL_COMPARE(type) bool compare_#type(type a, type b) { return a < b; }DECL_BOOL_COMPARE(int);DECL_BOOL_COMPARE(float);//... Программисты C++ заменяют макрос на шаблон или используют современные возможности языка C generics.
Когда появился процессор Pentium, C++ страдал от этого. Шаблоны C++ выдавали строго типобезопасные варианты функций для каждого используемого типа.
Программисты на языке Си просто приводят к совместимому типу:
long i = 10;copmare_int((int)i, (int)i); Компиляторы C++ стали немного умнее и выдавали отдельные экземпляры одного и того же тела основной функции с простыми условиями для перехода от одного случая к другому.
Процессоры становились быстрее, параллельнее, а память - меньше. Условные ветвления стали врагом производительности.
Сегодня в любом случае, где это возможно, современный компилятор будет пытаться устранить дублирование генерируемых функций, а тонкости в шаблонах C++ часто могут быть решены с помощью условных операций, таких как CMOV.
В версиях C generics компилятор получает небольшую подсказку для кандидатов, в версиях C++ generics и templates компилятор, как правило, очень хорош в этом.
Однако в случае с C стоит отметить, что функции имеют разные имена… , поэтому эта раздутость передается вверх по течению, усложняя работу компилятора.
Другими словами, язык, который приводит к исполняемому файлу, содержащему
f(int): lea eax, [rdi-1+rdi*2] retg(int): lea eax, [rdi-1+rdi*2] ret может привести к созданию более медленной программы для многих современных контекстов за счет снижения когерентности кэша/точности предсказания разветвлений.
В конечном итоге, многие проекты на языке C заканчиваются чем-то вроде:
bool test(enum Type type, void* a, void* b){ переключатель (тип) { case TYPE_INT: return compare_int(*(int*)a, *(int*)b); case TYPE_FLOAT: return compare_float(*(float*)a, *(int*)b); ... Это не обязательно должно быть сделано именно так, просто часто так и происходит. Вот здесь-то и пригодится наследование и виртуальные функции C++, которые амортизируют эти перенаправления vtable, устраняя условное ответвление, описанное выше, и ставя C++ на одну ступень с кодом C, или, возможно, с небольшим преимуществом, в зависимости от сложности и так далее.
Поэтому, в конечном счете, ответ на вопрос “Что делает C быстрее” таков: код, который вы ему скармливаете.
Начиная с C++17 и C18, для любого кода на C или C++ вы можете написать код на другом языке (*caveat, почти, насколько я знаю), который в конечном итоге будет компилироваться в точно такие же машинные инструкции, если вы готовы пойти на абсолютно любые меры, независимо от сопровождаемости и читаемости, включая “significantly less readable than if I’d just written it in
Программисты C++ могут обратиться к большой библиотеке хорошо продуманных стандартных функций с хорошо известными характеристиками, которые попытаются обеспечить наилучшую среднюю производительность по всем архитектурам, но не гарантируют наилучшую производительность при любых обстоятельствах и для всех моделей использования.
Программистам на языке Си обычно приходится обращаться к внешним библиотекам или реализовывать их самостоятельно.
Настоящее различие между C и C++ - это вопрос фокуса. Программисты на Си считают, что при кодировании на Си нет никакой скрытой магии. Программисты на C++ возражают аргументом о том, как трудно бывает расшифровать, что происходит в макросе, а все, кто не программист на C++, укажут на предупреждение об инстанцировании шаблона в 700 строк….
Я’ обнаружил, что на практике проблемы производительности гораздо легче выявить и обсудить в C, потому что они редко скрываются в макросах. И наоборот, в C++ они обычно прячутся глубоко в кишках глубоко вложенного мета-программирования, которое требует от вас не только мысленного перевода C<->assembler, но и учета глубоких аспектов того, как компилируется язык (ADO, ODR, SFINAE) и очень глубокого стандартного языка.
Программисты на языке Си утверждают, что наличие 24 версий “compare_a_and_b” означает, что каждый случай явно описан, и когда выясняется, что 64-битная версия с двойным знаковым символом является той, которая’ медленная, вы можете сделать хирургическое исправление.
Решение этой проблемы в общей функции будет означать либо создание специализации, о которой вы впоследствии должны будете знать при настройке того, что вы считаете одной общей функцией, либо написание большего количества кода в одном пути кода, добавляя сложность, которую вы обычно не ожидали’.
Однако этот аргумент - лишь перспектива. При работе с большими кодовыми базами на языке Си я часто сталкивался с проблемами, когда существовал уникальный путь кода, который не поддерживался в актуальном состоянии сопутствующими функциями и в какой-то момент становился узким местом или причиной редких ошибок.
Подумайте об этом так:
Если функция `f` очень быстрая, и вы вызываете ее 1 000 000 000 раз в цикле, будет ли это быстро? Или быстрее будет сделать 1 000 000 вызовов функции `g`, которая вызывает `f` 990 раз?
Правильный ответ не интуитивен, потому что это простое изменение в шаблоне может радикально изменить предсказание ветвлений или кэша процессора, и результирующие промахи могут сделать второй вариант радикально медленнее, несмотря на то, что технически он выполняет меньше работы….
И поэтому C быстрее, когда вы пишете более быстрый код на нем, а C++ быстрее, когда вы пишете/используете более быстрый код на *нем*.
Comments