Может ли C# догнать C?

от автора

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

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

Детали

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

С = gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0

C# = C# 12, NET 8.0

Для сравнения будет использоваться машина с операционной системой Linux

Operating System: Ubuntu 24.04.1 LTS

Kernel: Linux 6.8.0-48-generic

Architecture: x86-64

CPU

*-cpu
description: CPU
product: AMD Ryzen 7 3800X 8-Core Processor
vendor: Advanced Micro Devices [AMD]
physical id: 15
bus info: cpu@0
version: 23.113.0
serial: Unknown
slot: AM4
size: 2200MHz
capacity: 4558MHz
width: 64 bits
clock: 100MHz
capabilities: lm fpu fpu_exception wp vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp x86-64 constant_tsc rep_good nopl nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 cqm rdt_a rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local clzero irperf xsaveerptr rdpru wbnoinvd arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif v_spec_ctrl umip rdpid overflow_recov succor smca sev sev_es cpufreq
configuration: cores=8 enabledcores=8 microcode=141561889 threads=16

Memory

Getting SMBIOS data from sysfs.
SMBIOS 3.3.0 present.

Handle 0x000F, DMI type 16, 23 bytes
Physical Memory Array
Location: System Board Or Motherboard
Use: System Memory
Error Correction Type: None
Maximum Capacity: 128 GB
Error Information Handle: 0x000E
Number Of Devices: 4

Handle 0x0017, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x000F
Error Information Handle: 0x0016
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM 0
Bank Locator: P0 CHANNEL A
Type: Unknown
Type Detail: Unknown

Handle 0x0019, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x000F
Error Information Handle: 0x0018
Total Width: 64 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM 1
Bank Locator: P0 CHANNEL A
Type: DDR4
Type Detail: Synchronous Unbuffered (Unregistered)
Speed: 3200 MT/s
Manufacturer: Unknown
Serial Number: 12030387
Asset Tag: Not Specified
Part Number: PSD416G320081
Rank: 1
Configured Memory Speed: 3200 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 6, Hex 0x02
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None

Handle 0x001C, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x000F
Error Information Handle: 0x001B
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM 0
Bank Locator: P0 CHANNEL B
Type: Unknown
Type Detail: Unknown

Handle 0x001E, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x000F
Error Information Handle: 0x001D
Total Width: 64 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM 1
Bank Locator: P0 CHANNEL B
Type: DDR4
Type Detail: Synchronous Unbuffered (Unregistered)
Speed: 3200 MT/s
Manufacturer: Unknown
Serial Number: 120304DD
Asset Tag: Not Specified
Part Number: PSD416G320081
Rank: 1
Configured Memory Speed: 3200 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 6, Hex 0x02
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None

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

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

В случае C это будет последовательный блок неуправляяемой помяти, полученный с помощью malloc, а в случае C# мы рассмотрим как блок памяти находящийся в управляемой куче, так и блок неуправляемой памяти в адресном пространстве процесса.

C# позволяет нам работать с неуправляемой памятью.

За счет чего может появиться разница во времени исполнения этой операции?

Код, который мы будем сравнивать, в конечном итоге превратится в инструкции для процессора, которые этот процессор будет выполнять. Однако, когда мы говорим о С, мы понимаем, что компилятор может оптимизировать написанный нами код. В случае же C# ситуация еще сложнее. В обычных условиях код будет скомпилирован в промежуточный язык CIL, который затем будет с помощью компиляции реального времени (JIT) скомпилирован в набор инструкций, которые будут исполняться. Код может быть оптимизирован на обоих этапах.
Именно сравнение этих оптимизаций двух языков программирования нам и интересно.

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

Тест №1
Для начала посмотрим на ситуацию без оптимизаций

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

Первым выполним код на C

Просто скомпилируем его, не указывая компилятору, что нужно применить оптимизации

#include <stdlib.h> #include <stdio.h> #include <time.h> #include <unistd.h> #include <stddef.h>  #define MEMSIZE (1l << 30) #define CLOCK_IN_MS (CLOCKS_PER_SEC / 1000) #define ITERATIONS 10    int main(int argc, char **argv) {     const size_t mem_size = MEMSIZE;     const size_t cache_line_size = sysconf (_SC_LEVEL1_DCACHE_LINESIZE);     clock_t start_clock;     long diff_ms = 0;     char *mem, *arr, *stop_addr, *ix_line;     ptrdiff_t ix_char = 0;     const char c = 1;     int iter = 0;     const int iter_count = ITERATIONS;          printf("memsize=%zxh sizeof(size_t)=%zx cache_line=%lu\n",              mem_size, sizeof(mem_size), cache_line_size     );      if (!(mem = malloc(mem_size + cache_line_size))){         fprintf(stderr, "unable to allocate memory\n");         return -1;     }      arr = mem + cache_line_size - (long)mem % cache_line_size;      stop_addr = arr + mem_size;      for (iter = 0 ; iter < iter_count; ++iter) {         start_clock = clock();         for ( ix_line = arr; ix_line < stop_addr ; ix_line += cache_line_size) {             for (ix_char = 0 ; ix_char < cache_line_size ; ++ix_char) {                 *(ix_line + ix_char) = c;             }         }         diff_ms = (clock() - start_clock) / CLOCK_IN_MS;         printf("iter=%d seq time=%lu\n", iter, diff_ms);     }      free(mem);      return 0; }

Результаты:

Среднее время: 2700 ms

iter=0 seq time=2177
iter=1 seq time=2765
iter=2 seq time=2765
iter=3 seq time=2797
iter=4 seq time=2781
iter=5 seq time=2743
iter=6 seq time=2791
iter=7 seq time=2743
iter=8 seq time=2695
iter=9 seq time=2739

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

Теперь посмотрим на C# и массив в куче

using System.Diagnostics;  const int typicalItarationsCount = 10; const int arraySize = 1073741824; const int lineLength = 64; const int linesCount = arraySize / lineLength;  var tmpArray = new bool[arraySize]; for(var iteration = 0; iteration < typicalItarationsCount; ++iteration) {     var watch = new Stopwatch();     watch.Start();     for(long i = 0; i < linesCount; ++i)     {         for(long j = 0; j < lineLength; ++j)         {             tmpArray[i * lineLength + j] = true;         }     }     watch.Stop();     tmpArray = new bool[arraySize];     Console.WriteLine($"iter={iteration} seq time={watch.ElapsedMilliseconds}"); }

Результаты:

Среднее время: 446 ms

iter=0 seq time=764
iter=1 seq time=766
iter=2 seq time=362
iter=3 seq time=362
iter=4 seq time=369
iter=5 seq time=362
iter=6 seq time=364
iter=7 seq time=372
iter=8 seq time=368
iter=9 seq time=370

На самом деле среднее время меньше, так как большой вклад дают первые две итерации. Если выполнить большее число итераций, среднее время уменьшится.

А теперь посмотрим на неуправляемую память в C#

Для работы с указателями в C# необходимо пометить блок кода ключевым словом «unsafe», а так же добавить в файл .csproj блок указывающий, что сборка будет работать с таким кодом.

<Project Sdk="Microsoft.NET.Sdk">    <PropertyGroup>     <OutputType>Exe</OutputType>     <TargetFramework>net8.0</TargetFramework>     <ImplicitUsings>enable</ImplicitUsings>     <Nullable>enable</Nullable>   </PropertyGroup>   <PropertyGroup>     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>   </PropertyGroup> </Project> 
using System.Diagnostics; using System.Runtime.InteropServices;  unsafe  {     const int typicalItarationsCount = 10;     const int arraySize = 1073741824;     const int lineLength = 64;     const int linesCount = arraySize / lineLength;      for(var iteration = 0; iteration < typicalItarationsCount; ++iteration)     {         bool* buffer = (bool*)NativeMemory.Alloc((nuint) arraySize, sizeof(bool));         var readPtr = buffer;         var endPtr = buffer + arraySize;         var watch = new Stopwatch();         watch.Start();         for(long i = 0; i < linesCount; ++i)         {             for(long j = 0; j < lineLength; ++j)             {                 *readPtr = true;                 ++readPtr;             }         }         watch.Stop();         NativeMemory.Free(buffer);         Console.WriteLine($"iter={iteration} seq time={watch.ElapsedMilliseconds}");     } } 

Результаты:

Среднее время: 691 ms

iter=0 seq time=696
iter=1 seq time=704
iter=2 seq time=694
iter=3 seq time=689
iter=4 seq time=686
iter=5 seq time=696
iter=6 seq time=684
iter=7 seq time=692
iter=8 seq time=685
iter=9 seq time=688

Без применения специальных оптимизаций, C проиграл соревнование по скорости в 7 раз по сравнению с массивами в куче C#, и в 4 раза по сравнению с использованием неуправляемой помяти в C#. Результаты уже интересны.

Тест №2
Теперь скомпилируем C код с максимальными возможными оптимизациями
— используем аргумент командной строки для gcc «-Wall -O4»

Результаты:

Среднее время: 118 ms

iter=0 seq time=448
iter=1 seq time=81
iter=2 seq time=82
iter=3 seq time=83
iter=4 seq time=82
iter=5 seq time=82
iter=6 seq time=82
iter=7 seq time=81
iter=8 seq time=81
iter=9 seq time=82

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

Как и предполагалось, оптимизированный код на C показывает впечатляющие результаты
Но эти результаты впечатляют по сравнению с результатами неоптимизированного специально кода на C#.

Попробуем использовать оптимизации в C# при работе с массивом в куче

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

<Project Sdk="Microsoft.NET.Sdk">   <PropertyGroup>     <OutputType>Exe</OutputType>     <TargetFramework>net8.0</TargetFramework>     <ImplicitUsings>enable</ImplicitUsings>     <Nullable>enable</Nullable>   </PropertyGroup>   <PropertyGroup>     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>   </PropertyGroup>   <PropertyGroup>     <Optimize>true</Optimize>   </PropertyGroup> </Project>

Результаты:

Среднее время: 603 ms

iter=0 seq time=953
iter=1 seq time=948
iter=2 seq time=515
iter=3 seq time=522
iter=4 seq time=520
iter=5 seq time=517
iter=6 seq time=516
iter=7 seq time=520
iter=8 seq time=507
iter=9 seq time=510

Попробуем использовать оптимизации в C# при работе с неуправляемой помятью

Результаты:

Среднее время: 694 ms

iter=0 seq time=690
iter=1 seq time=687
iter=2 seq time=686
iter=3 seq time=694
iter=4 seq time=691
iter=5 seq time=702
iter=6 seq time=697
iter=7 seq time=704
iter=8 seq time=695
iter=9 seq time=695

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

Может быть дело в JIT-компиляции? Последяя версия C# позволяет использовать AOT-компиляцию.

Тест №3
Попробуем скомпилировать C# код нативно для нашего компьютера.

Для исполнения такого файла нам не нужен будет dotnet

Для этого .csproj должен содержать секцию добавляющую нативную публикацию

<Project Sdk="Microsoft.NET.Sdk">   <PropertyGroup>     <OutputType>Exe</OutputType>     <TargetFramework>net8.0</TargetFramework>     <ImplicitUsings>enable</ImplicitUsings>     <Nullable>enable</Nullable>   </PropertyGroup>   <PropertyGroup>     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>   </PropertyGroup>   <PropertyGroup>     <Optimize>true</Optimize>   </PropertyGroup>   <PropertyGroup>     <PublishAot>true</PublishAot>     <OptimizationPreference>Speed</OptimizationPreference>   </PropertyGroup> </Project> 

Результаты для массивов в куче:

Среднее время: 548 ms

iter=0 seq time=932
iter=1 seq time=905
iter=2 seq time=453
iter=3 seq time=450
iter=4 seq time=453
iter=5 seq time=464
iter=6 seq time=452
iter=7 seq time=459
iter=8 seq time=452
iter=9 seq time=456

Первые две итерации опять сильно влияют на результат.

Результаты для неуправляемой памяти:

Среднее время; 827 ms

iter=0 seq time=822
iter=1 seq time=822
iter=2 seq time=828
iter=3 seq time=829
iter=4 seq time=826
iter=5 seq time=828
iter=6 seq time=827
iter=7 seq time=829
iter=8 seq time=831
iter=9 seq time=826

Прироста производительности тоже не наблюдается

Вывод

C# проигрывает C при последовательной записи в оперативную память примерно в 8 раз. Это происходит из-за того, что оптимизации компилятора C превосходят оптимизации которые претерпевает C# код, превращаясь в машинные коды. Однако, эти оптимизации бесполезны при непоследовательной записи в память, что будет видно в следующем тесте. Сторонние факторы, такие как физическая реализация процессора, влияют на многие операции сильнее, чем разница в программах, написанных на этих языках

Немного теории

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

Поэтому проведем следующий тест.

Тест №4
Посмотрим на C код не последовательно пишущий в память

#include <stdlib.h> #include <stdio.h> #include <time.h> #include <unistd.h> #include <stddef.h>  #define MEMSIZE (1l << 30) #define CLOCK_IN_MS (CLOCKS_PER_SEC / 1000) #define ITERATIONS 10    int main(int argc, char **argv) {     const size_t mem_size = MEMSIZE;     const size_t cache_line_size = sysconf (_SC_LEVEL1_DCACHE_LINESIZE);     clock_t start_clock;     long diff_ms = 0;     char *mem, *arr, *stop_addr, *ix_line;     ptrdiff_t ix_char = 0;     const char c = 1;     int iter = 0;     const int iter_count = ITERATIONS;          printf("memsize=%zxh sizeof(size_t)=%zx cache_line=%lu\n",              mem_size, sizeof(mem_size), cache_line_size     );      if (!(mem = malloc(mem_size + cache_line_size))){         fprintf(stderr, "unable to allocate memory\n");         return -1;     }      arr = mem + cache_line_size - (long)mem % cache_line_size;      stop_addr = arr + mem_size;      for (iter = 0 ; iter < iter_count; ++iter) {         start_clock = clock();         for (ix_char = 0 ; ix_char < cache_line_size ; ++ix_char) {             for ( ix_line = arr; ix_line < stop_addr ; ix_line += cache_line_size) {                 *(ix_line + ix_char) = c;             }         }         diff_ms = (clock() - start_clock) / CLOCK_IN_MS;         printf("iter=%d unseq time=%lu\n", iter, diff_ms);     }      free(mem);      return 0; }
Среднее время: 5188 ms

iter=0 unseq time=5521
iter=1 unseq time=5122
iter=2 unseq time=5110
iter=3 unseq time=5160
iter=4 unseq time=5130
iter=5 unseq time=5124
iter=6 unseq time=5170
iter=7 unseq time=5181
iter=8 unseq time=5195
iter=9 unseq time=5163

Среднее время оптимизированной версии: 5735 ms

iter=0 unseq time=6067
iter=1 unseq time=5694
iter=2 unseq time=5704
iter=3 unseq time=5695
iter=4 unseq time=5692
iter=5 unseq time=5695
iter=6 unseq time=5707
iter=7 unseq time=5698
iter=8 unseq time=5704
iter=9 unseq time=5691

Непоследовательный доступ в C#. Массив в куче

using System.Diagnostics;  const int typicalItarationsCount = 10; const int arraySize = 1073741824; const int lineLength = 64; const int linesCount = arraySize / lineLength;  var tmpArray = new bool[arraySize]; for(var iteration = 0; iteration < typicalItarationsCount; ++iteration) {     var watch = new Stopwatch();     watch.Start();     for(long i = 0; i < lineLength; ++i)     {         var currentLineStart = 0;         for(long j = 0; j < linesCount; ++j)         {             tmpArray[currentLineStart + i] = true;             currentLineStart += lineLength;         }     }     watch.Stop();     Console.WriteLine($"iter={iteration} seq time={watch.ElapsedMilliseconds}"); }

Результаты:

Среднее время: 5647 ms

iter=0 seq time=5969
iter=1 seq time=5637
iter=2 seq time=5568
iter=3 seq time=5618
iter=4 seq time=5568
iter=5 seq time=5617
iter=6 seq time=5623
iter=7 seq time=5637
iter=8 seq time=5626
iter=9 seq time=5608

Непоследовательный доступ в C#. Неуправляемая память

using System.Diagnostics; using System.Runtime.InteropServices;  unsafe  {     const int typicalItarationsCount = 10;     const int arraySize = 1073741824;     const int lineLength = 64;     const int linesCount = arraySize / lineLength;      for(var iteration = 0; iteration < typicalItarationsCount; ++iteration)     {         bool* buffer = (bool*)NativeMemory.Alloc((nuint) arraySize, sizeof(bool));         var readPtr = buffer;         var endPtr = buffer + arraySize;         var watch = new Stopwatch();         watch.Start();         for(long i = 0; i < lineLength; ++i)         {             readPtr = buffer + i;             for(long j = 0; j < linesCount; ++j)             {                 *readPtr = true;                 readPtr += lineLength;             }         }         watch.Stop();         NativeMemory.Free(buffer);         Console.WriteLine($"iter={iteration} seq time={watch.ElapsedMilliseconds}");     } }

Результаты:

Среднее время: 6145 ms

iter=0 seq time=6166
iter=1 seq time=6160
iter=2 seq time=6142
iter=3 seq time=6135
iter=4 seq time=6152
iter=5 seq time=6130
iter=6 seq time=6120
iter=7 seq time=6160
iter=8 seq time=6138
iter=9 seq time=6142

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

P.S.: Это мой первый опыт написания подобных статей, не судите строго за шероховатости.


ссылка на оригинал статьи https://habr.com/ru/articles/856018/