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

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

Детали

Оба языка будут участвовать в последних своих 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.: Это мой первый опыт написания подобных статей, не судите строго за шероховатости.

Комментарии (46)


  1. viordash
    04.11.2024 19:43

    для чистоты эксперимента в первом тесте, я бы посоветовал вам в коде для "c" переместить выделение\освобождение памяти в цикл for (iter = 0 ; iter < iter_count; ++iter), наподобии кода в c#.


    1. viordash
      04.11.2024 19:43

      и чтобы для си компилятор не выкинул заполнение памяти, добавьте volatile

      volatile const char c = 1;


      1. aamonster
        04.11.2024 19:43

        Может, сразу sleep(1) в цикл добавить для надёжности? :-)

        Лучше уж код на C# нормально написать, не создавая 100500 массивов в цикле.


        1. viordash
          04.11.2024 19:43

          ну только если вам это нужно для теста


  1. Vad344
    04.11.2024 19:43

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

    Т.е., фактически, сравниваете скорость выделения памяти операнционной системы со скоростью встроенного диспетчера памяти среды выполнения C#? :)


    1. Smerig
      04.11.2024 19:43

      а если учесть, что диспетчер тоже обращается к ОС, чтоб та выделила память, то еще интереснее получается: просто смотрим на скорость быстродействия диспетчера в вакууме.


  1. Kotofay
    04.11.2024 19:43

    Отличный тест, "точный, как швейцарские часы":

    for(var iteration = 0; iteration < typicalItarationsCount; ++iteration)
    {
        .
        .
        .
        tmpArray = new bool[arraySize];
        .
    }


    1. pr_ophet Автор
      04.11.2024 19:43

      В замерах выделение памяти не участвует, gc на время выполнения не повлияет, так как он соберет большой объект только при выделении памяти, когда память выделенная для кучи закончится. Соберет он его вместе с поколением 2. Именно для того, чтобы сборка мусора не произошла в неудобный момент, выделений памяти вынесено за пределы измеряемого блока кода. Пробовал с ручной сборкой LOH, результаты не отличались. Оставил так для простоты кода.


  1. Kotofay
    04.11.2024 19:43

    Два простейших кода на Java и C показывают, что не всё так однозначно:

    MSVC, -Ox
    MSVC, -Ox
    Java, no options
    Java, no options


    1. Fancryer
      04.11.2024 19:43

      А почему сверху C++, а не C?


      1. Kotofay
        04.11.2024 19:43

        Вы думаете что то изменится? Увы, нет:


        1. AgentFire
          04.11.2024 19:43

          Лайк за шрифт консоли


    1. Kelbon
      04.11.2024 19:43

      конечно заполнение массива интересное, сверху (int)ptr, что вообще непонятно какое число даёт (unspecified + overflow)
      внизу в джаве тоже с оверфлоу заполнение

      Если пойти посмотреть на генерируемый код, то (ожидаемо) подобные конструкции полностью исчезают, т.к. ничего не делают
      https://godbolt.org/z/M6W43GG4E


      1. Kotofay
        04.11.2024 19:43

        Чтобы умный gcc не выбрасывал код достаточно сделать указатель статическим и присваивать не 1 а что то изменяющееся, например "i & 0xFF"


        1. Kelbon
          04.11.2024 19:43

          это называется shadowing. Ошибки нет


          https://godbolt.org/z/jPf33Wz3r

          всё по вашим советам, ничего не изменилось


          1. Kotofay
            04.11.2024 19:43

            изменилось
            изменилось

            И можно посмотреть. Как видим, в строке 9 происходит усечение значения из 64 в 8 бит без каких либо проблем, точно так же в моём коде происходит усечение значения адреса.

            Код, в данном случае, не выбрасывается оптимизатором потому что указатель m передаётся в ф-ю free, и может быть использован в другой единице компиляции. По крайней мере он должен так делать. Если не делает, то оптимизатор ещё надо дорабатывать.


            1. Kelbon
              04.11.2024 19:43

              так вы сделали нечто очень странное, написали функцию, которую вызывать дважды == UB.
              У вас просто очень странный код(которого не будет в реальности никогда), поэтому компилятор не оптимизировал

              Компилятор знает что делает функция free, это по стандарту определено, поэтому компилятор может оптимизировать


              1. Kotofay
                04.11.2024 19:43

                • У вас просто очень странный код(которого не будет в реальности никогда), поэтому компилятор не оптимизировал

                Это было сделано только для целей отключения оптимизации затенения(shadowing).

                Таких нереентрантных ф-й в стандартной библиотеке в достатке, к примеру: char *strtok(char *string, const char *delim);

                Первый вызов - первый аргумент строка, второй вызов -- первый аргумент может быть NULL. И где по вашему сохраняется указатель на строку? Это первое.

                Второе -- MSVC при таком коде выдаёт ошибку компиляции, и это правильно, т.к. статическая переменная внутри ф-ии инициализируется один раз. Очень странно, что godbolt это компилирует без ошибок.

                N.B. Для тех кто не писал на ассемблере, весь С "== UB".


  1. alexander_kuznetsov
    04.11.2024 19:43

    На будущее, однозначно рекомендую использовать вместо среднего медиану. Она как раз не чувствительна к выбросам, по типу тех, что бывали у вас в начале.


  1. viruseg
    04.11.2024 19:43

    Компилятор C активно использует SIMD инструкции. А в вашем коде c# таких инструкций нет. Перепишите код с использованием Vector128/Vector256 и повторите тест. А то как-то нечестно получилось.

    unsafe
    {
        const int typicalItarationsCount = 10;
        const int arraySize = 1073741824;
        var lineLength = sizeof(Vector256<byte>);
        var linesCount = arraySize / lineLength;
        
        var tmpArray = new byte[arraySize];
        for (var iteration = 0; iteration < typicalItarationsCount; ++iteration)
        {
            var watch = new Stopwatch();
            watch.Start();
            
            fixed (byte* tmpArrayPtr = tmpArray)
                for (long i = 0; i < linesCount; ++i)
                {
                    var vector = Vector256.Create((byte) 1);
                    vector.Store(tmpArrayPtr + i * lineLength);
                }
    
            watch.Stop();
            tmpArray = new byte[arraySize];
            Console.WriteLine($"iter={iteration} seq time={watch.ElapsedMilliseconds}");
        }
    }

    Примерно так это выглядит на моём пк. Цифры не идеальны т.к. в фоне работает много процессов. Я не ставил целью сделать идеальный тест. Лишь хотел показать тенденцию.

    Мой код с Vector256
    Мой код с Vector256
    Код из статьи
    Код из статьи

    Если сравнивать C и c#, то только так. У меня нет настроенного рабочего окружения под C чтобы проверить разницу с шарпом. Оставляю это для вас.


    1. VBDUnit
      04.11.2024 19:43

      В новых .NET кстати появились AVX512. Но они, к сожалению, доступны далеко не на всех современных железках, и не всегда работают хорошо.

        unsafe
        {
            const int typicalItarationsCount = 10;
            const int arraySize = 1073741824;
            var linesCount = arraySize / sizeof(Vector512<byte>);
      
            if (arraySize % sizeof(Vector512<byte>) != 0)
                Console.WriteLine("Хвостик не обработаем :|");
      
      
            var tmpArrayPtr = Marshal.AllocHGlobal(arraySize);
            try
            {
                for (var iteration = 0; iteration < typicalItarationsCount; ++iteration)
                {
                    var watch = new Stopwatch();
                    watch.Start();
      
                    var vector = Vector512.Create((byte)1);
      
                    Vector512<byte>* ptr = (Vector512<byte>*)tmpArrayPtr;
                    Vector512<byte>* end = ptr + linesCount;
                    Vector512<byte>* end4 = ptr + linesCount / 4 * 4;
      
                    while (ptr < end4)
                    {
                        *ptr++ = vector;
                        *ptr++ = vector;
                        *ptr++ = vector;
                        *ptr++ = vector;
                    }
                  
                    while (ptr < end)
                        *ptr++ = vector;
      
                    watch.Stop();
      
                    Console.WriteLine($"iter={iteration} seq time={watch.ElapsedMilliseconds}");
                }
            }
            finally
            {
                Marshal.FreeHGlobal(tmpArrayPtr);
            }
        }
      iter=0 seq time=119
      iter=1 seq time=40
      iter=2 seq time=41
      iter=3 seq time=41
      iter=4 seq time=40
      iter=5 seq time=41
      iter=6 seq time=41
      iter=7 seq time=41
      iter=8 seq time=40
      iter=9 seq time=40

      Имхо — при подобных подходах C, C++ и C# должны уже показывать +‑ одинаковые результаты. Просто потому что это подразумевает отказ почти ото всех абстракций, которые дают языки, кроме каких‑то базовых. Дальше уже только ассемблер. Который в разном виде можно так или иначе запустить во всех языках.


      1. viruseg
        04.11.2024 19:43

        Не стал упоминать Vector512 иначе бы пришлось этот маленький пример превратить в простыню из:

        if (Vector512.IsHardwareAccelerated) {}
        else if (Vector256.IsHardwareAccelerated) {}
        else if (Vector128.IsHardwareAccelerated) {}
        else if (Vector64.IsHardwareAccelerated) {}
        else {}

        Но это всё не имеет значения в контексте этого теста. Да и моё железо не поддерживает Vector512.


    1. pr_ophet Автор
      04.11.2024 19:43

      Вроде бы, нет тут SIMD

      Код асемблера:

      	.file	"test.c"
      	.text
      	.section	.rodata.str1.8,"aMS",@progbits,1
      	.align 8
      .LC0:
      	.string	"memsize=%zxh sizeof(size_t)=%zx cache_line=%lu\n"
      	.section	.rodata.str1.1,"aMS",@progbits,1
      .LC1:
      	.string	"unable to allocate memory\n"
      .LC2:
      	.string	"iter=%d seq time=%lu\n"
      	.section	.text.startup,"ax",@progbits
      	.p2align 4
      	.globl	main
      	.type	main, @function
      main:
      .LFB51:
      	.cfi_startproc
      	endbr64
      	pushq	%r15
      	.cfi_def_cfa_offset 16
      	.cfi_offset 15, -16
      	movl	$190, %edi
      	pushq	%r14
      	.cfi_def_cfa_offset 24
      	.cfi_offset 14, -24
      	pushq	%r13
      	.cfi_def_cfa_offset 32
      	.cfi_offset 13, -32
      	pushq	%r12
      	.cfi_def_cfa_offset 40
      	.cfi_offset 12, -40
      	pushq	%rbp
      	.cfi_def_cfa_offset 48
      	.cfi_offset 6, -48
      	pushq	%rbx
      	.cfi_def_cfa_offset 56
      	.cfi_offset 3, -56
      	subq	$24, %rsp
      	.cfi_def_cfa_offset 80
      	call	sysconf@PLT
      	movl	$8, %ecx
      	movl	$1073741824, %edx
      	leaq	.LC0(%rip), %rsi
      	movq	%rax, %r8
      	movq	%rax, %rbx
      	movl	$2, %edi
      	xorl	%eax, %eax
      	call	__printf_chk@PLT
      	leaq	1073741824(%rbx), %rdi
      	call	malloc@PLT
      	movq	%rax, 8(%rsp)
      	testq	%rax, %rax
      	je	.L14
      	xorl	%edx, %edx
      	movq	%rbx, %r14
      	xorl	%r12d, %r12d
      	movabsq	$2361183241434822607, %r15
      	divq	%rbx
      	movq	8(%rsp), %rax
      	subq	%rdx, %r14
      	addq	%rax, %r14
      	leaq	1073741824(%r14), %rbp
      	.p2align 4,,10
      	.p2align 3
      .L6:
      	call	clock@PLT
      	movq	%rax, %r13
      	testq	%rbx, %rbx
      	je	.L4
      	movq	%r14, %rcx
      	.p2align 4,,10
      	.p2align 3
      .L5:
      	movq	%rcx, %rdi
      	movq	%rbx, %rdx
      	movl	$1, %esi
      	call	memset@PLT
      	movq	%rax, %rcx
      	addq	%rbx, %rcx
      	cmpq	%rbp, %rcx
      	jb	.L5
      .L4:
      	call	clock@PLT
      	movl	$2, %edi
      	subq	%r13, %rax
      	movq	%rax, %rsi
      	imulq	%r15
      	xorl	%eax, %eax
      	sarq	$63, %rsi
      	sarq	$7, %rdx
      	subq	%rsi, %rdx
      	leaq	.LC2(%rip), %rsi
      	movq	%rdx, %rcx
      	movl	%r12d, %edx
      	addl	$1, %r12d
      	call	__printf_chk@PLT
      	cmpl	$10, %r12d
      	jne	.L6
      	movq	8(%rsp), %rdi
      	call	free@PLT
      	xorl	%eax, %eax
      .L1:
      	addq	$24, %rsp
      	.cfi_remember_state
      	.cfi_def_cfa_offset 56
      	popq	%rbx
      	.cfi_def_cfa_offset 48
      	popq	%rbp
      	.cfi_def_cfa_offset 40
      	popq	%r12
      	.cfi_def_cfa_offset 32
      	popq	%r13
      	.cfi_def_cfa_offset 24
      	popq	%r14
      	.cfi_def_cfa_offset 16
      	popq	%r15
      	.cfi_def_cfa_offset 8
      	ret
      .L14:
      	.cfi_restore_state
      	movq	stderr(%rip), %rcx
      	movl	$26, %edx
      	movl	$1, %esi
      	leaq	.LC1(%rip), %rdi
      	call	fwrite@PLT
      	orl	$-1, %eax
      	jmp	.L1
      	.cfi_endproc
      .LFE51:
      	.size	main, .-main
      	.ident	"GCC: (Ubuntu 13.2.0-23ubuntu4) 13.2.0"
      	.section	.note.GNU-stack,"",@progbits
      	.section	.note.gnu.property,"a"
      	.align 8
      	.long	1f - 0f
      	.long	4f - 1f
      	.long	5
      0:
      	.string	"GNU"
      1:
      	.align 8
      	.long	0xc0000002
      	.long	3f - 2f
      2:
      	.long	0x3
      3:
      	.align 8
      4:
      


      Благодарю за комментарий. Ваш код проверил, цифры те же +- что и у обычного массива. Обязательно разберусь, можно ли с помощью SIMD ускориться.


      1. viruseg
        04.11.2024 19:43

        Отметил на скрине avx инструкции.

        Можно здесь посмотреть. https://sharplab.io/

        Ваш код проверил, цифры те же +- что и у обычного массива.

        Насчёт этого не понял. Этот код с Vector256 ну просто не может быть по скорости таким же как как ваш из статьи.

        upd: не заметил, что вы написали в комментарии скомпилированный код C, а не c#. На C я не работал. Поэтому подумал, что там есть SIMD инструкции после компиляции и не проверил свою гипотезу.


        1. Cheater
          04.11.2024 19:43

          .file "test.c" - речь шла про сишный ассемблер.

          Ответ для C - думаю потому что компилятор решил сгенерировать вызов memset и все SIMD оптимизации идут уже в нём.


          1. viruseg
            04.11.2024 19:43

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

            Без SIMD такой буст как в тестах в статье просто невозможен.


      1. LittleAlien
        04.11.2024 19:43

        Gcc всё сводит к вызову memset (call memset@PLT), видимо у неё внутри SIMD есть.
        Можно Clang смотреть, там SIMD в явном виде (строки 72-74): https://gcc.godbolt.org/z/PPPz6xv6s


  1. Kelbon
    04.11.2024 19:43

    т.е. тестировался вывод в консоль (prinf внутри цикла "бенчмарка") и в статье про заполнение памяти не упомянуты memset и memcpy, а всё происходило внутри функции main, которая для гцц особая и оптимизируется иначе.

    Что ж, эталонный бенчмарк


    1. NikkiG
      04.11.2024 19:43

      Вы код читали? Вывод и заполнение памяти вне измеряемого цикла же.


      1. Kelbon
        04.11.2024 19:43

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


        1. NikkiG
          04.11.2024 19:43

          Вы многого хотите от компилятора, чтоб он оценивал используемость памяти в куче)

          Это уже автоматическое управление памятью будет)


          1. Kelbon
            04.11.2024 19:43

            Вы переоцениваете сложность задачи

            https://godbolt.org/z/Pad8Mc3ca

            Как видно, никаких манипуляций с памятью, в том числе её выделения, нет


            1. NikkiG
              04.11.2024 19:43

              И что, он у вас это заоптимизировал, хотите сказать?


              1. Kelbon
                04.11.2024 19:43

                нуу, да


                1. NikkiG
                  04.11.2024 19:43

                  Странно конечно

                  А если я потом по этому адресу напрямую что то достану?


                  1. Kelbon
                    04.11.2024 19:43

                    потом это когда, после free?


                    1. pr_ophet Автор
                      04.11.2024 19:43

                      В другом потоке, например


                      1. Kelbon
                        04.11.2024 19:43

                        напишите пожалуйста код, иначе непонятно откуда вы где то возьмёте эту же память в другом потоке


        1. pr_ophet Автор
          04.11.2024 19:43

          Речь идет о C, а не о C++. Проверьте вышеприведенный код с этим языком


          1. Kelbon
            04.11.2024 19:43

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

            https://godbolt.org/z/G69hdzEdc


            1. pr_ophet Автор
              04.11.2024 19:43

              Справедливости ради, компилятор указан в начале статьи


  1. JordanCpp
    04.11.2024 19:43

    Вы тестируете оптимизатор С? Да он довольно хорош.


  1. JordanCpp
    04.11.2024 19:43

    Код на С, довольно вырвиглазный.


  1. Shersh
    04.11.2024 19:43

    Пожалуйста не используйте эти стопаотчи для тестирования производительности.

    Для бенчмарков в .net есть уже либо - BenchmarkDotnet ей и пользуйтесь


  1. rutsh
    04.11.2024 19:43

    Для "C" самым шустрым на данный момент является всё же не gcc-13, а clang-18.
    Конкретно в случае моей машины получаем "avg seq time=309.10" на gcc против "avg seq time=134.50" на clang в случае последовательной записи в память


  1. viruseg
    04.11.2024 19:43

    Вспомнил, что в c# есть Span<T>.Fill. Для C использовал компилятор clang-19.1.0 с аргументом "-O3". Результат, абсолютно одинаковая скорость. Так что выводы в статье абсолютно неверные, как и код для тестирования.