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

в 19:12, , рубрики: 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=%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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js