Может ли 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 3800×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 0×000F, 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: 0×000E
Number Of Devices: 4Handle 0×0017, DMI type 17, 84 bytes
Memory Device
Array Handle: 0×000F
Error Information Handle: 0×0016
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: UnknownHandle 0×0019, DMI type 17, 84 bytes
Memory Device
Array Handle: 0×000F
Error Information Handle: 0×0018
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 0×02
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: NoneHandle 0×001C, DMI type 17, 84 bytes
Memory Device
Array Handle: 0×000F
Error Information Handle: 0×001B
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: UnknownHandle 0×001E, DMI type 17, 84 bytes
Memory Device
Array Handle: 0×000F
Error Information Handle: 0×001D
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 0×02
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
#include
#include
#include
#include
#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 блок указывающий, что сборка будет работать с таким кодом.
Exe
net8.0
enable
enable
true
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 файл секцию, включающую оптимизации выполняемые компилятором
Exe
net8.0
enable
enable
true
true
Результаты:
Среднее время: 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 должен содержать секцию добавляющую нативную публикацию
Exe
net8.0
enable
enable
true
true
true
Speed
Результаты для массивов в куче:
Среднее время: 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
#include
#include
#include
#include
#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.: Это мой первый опыт написания подобных статей, не судите строго за шероховатости.