Современное сообщество программистов разбито на два лагеря - на тех, кто любит языки программирования с управляемой памятью, и тех кто их не любит. Два лагеря яро спорят друг с другом, ломая копья по поводу преимуществ в каком-то из аспектов программирования. Языки с неуправляемой памятью представляются как более быстрые, управляемые, контролируемые. А языки с управляемой памятью считаются более удобными в разроботке, в то время как их отставание по скорости выполнения и потребляемой памяти считается несущественным. В этой статье мы проверим, так ли это на самом деле. Со стороны олдскульных языков программирования выступит мастодонт мира разработки - С.
Сторону языков последних поколений будет представлять С#.
Статья носит ознакомительный характер и не претендует на комплексное сравнение. Полноценного тестирования проведено не будет, но будут приведены тесты, которые сможет повторить любой разработчик на своем компьютере.
Детали
Оба языка будут участвовать в последних своих 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: 4Handle 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: UnknownHandle 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: NoneHandle 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: UnknownHandle 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=%lun",
mem_size, sizeof(mem_size), cache_line_size
);
if (!(mem = malloc(mem_size + cache_line_size))){
fprintf(stderr, "unable to allocate memoryn");
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=%lun", 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=%lun",
mem_size, sizeof(mem_size), cache_line_size
);
if (!(mem = malloc(mem_size + cache_line_size))){
fprintf(stderr, "unable to allocate memoryn");
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=%lun", 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.: Это мой первый опыт написания подобных статей, не судите строго за шероховатости.
Автор: pr_ophet