.NET Managed + C unmanaged: какова цена?

от автора

Программируя на C#, я часто выносил ресурсоемкие задачи в неуправляемый Си код, потому что производительность .NET вызывала вопросы. И вот за чашечкой чая мне в голову начали лезть вопросы: А какой на самом деле выигрыш от такого разделения кода? Действительно ли можно что-то выиграть, а если можно, то сколько? Как лучше строить API при таком подходе?

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

Зачем?

Этот вопрос задаст любой уважающий себя программист. Вести проект на двух различных языках – дело очень сомнительное, тем более неуправляемый код действительно сложен в реализации, отладке и поддержке. Но лишь сама возможность реализовать функционал, который будет работать быстрее, уже достойна рассмотрения, особенно в критически важных участках или в высоко нагруженных приложениях.

Другой возможный ответ: функционал уже реализован в unamaged виде! Зачем переписывать решение целиком, если можно не очень большими затратами обернуть все в .NET и использовать оттуда?

В двух словах – такое действительно встречается. А мы лишь посмотрим, что да как.

Лирика

Весь код писался в Visual Studio Community 2015. Для оценки я использовал свой рабоче-игровой компьютер, с i5-3470 на борту, 12-ю гигабайтами 1333MHz двухканальной оперативной памяти, а также жестким диском на 7200 rpm. Замеры производились с помощью System.Diagnostics.Stopwatch, который точнее DateTime, потому что реализован поверх PerformanceCounter. Тесты запускались на Release вариантах сборок, чтобы исключить возможность того, что в действительности все будет немного иначе. Версия фрэймворка .NET 4.5.2, а C++ проект компилировался с флагом /TC (Compile as C).

Заранее прошу прощения за обилие кода, но без него будет сложно понять, чего именно я добивался. Большую часть слишком нудного или незначительного кода я вынес в спойлеры, а другую и вовсе вырезал из статьи (изначально она была еще длиннее).

Вызов функций

Свое исследование я решил начать именно с замера скорости вызова функций. Причин на это было несколько. Во-первых, функции все равно придется вызывать, а вызываются функции из загружаемых dll не очень быстро, в сравнении с кодом в том же модуле. Во-вторых, примерно таким образом реализовано большинство существующих C#-оберток поверх любого неуправляемого кода (напр. sharpgl, openal-cs, а sharpdx ушел куда-то не в ту сторону). Собственно, это самый очевидный способ встраивания неуправляемого кода, и самый простой.

Перед тем как начать непосредственно измерения, нужно подумать, как мы будем хранить и оценивать результаты наших измерений. «CSV!», подумал я, и написал простенький класс для хранения данных в этом формате:

Простенькая реализация CSV

    public class CSVReport : IDisposable     {         int columnsCount;         StreamWriter writer;         public CSVReport(string path, params string[] header)         {             columnsCount = header.Length;             writer = new StreamWriter(path);             writer.Write(header[0]);             for (int i = 1; i < header.Length; i++)                 writer.Write("," + header[i]);             writer.Write("\r\n");         }          public void Write(params object[] values)         {             if (values.Length != columnsCount)                 throw new ArgumentException("Columns count for row didn't match table columns count");             writer.Write(values[0].ToString());             for (int i = 1; i < values.Length; i++)                 writer.Write("," + values[i].ToString());             writer.Write("\r\n");         }          public void Dispose()         {             writer.Close();         }     }

Не самый функциональный вариант, но для моей цели пойдет с лихвой. А для тестирования было принято решение написать простой класс, который умеет лишь суммировать числа и хранить результат. Вот так он выглядит:

    class Summer     {         public int Sum         {             get; private set;         }          public Summer()         {             Sum = 0;         }          public void Add(int a)         {             Sum += a;         }          public void Reset()         {             Sum = 0;         }     }

Но это управляемый вариант. Нам же нужен еще неуправляемый. Поэтому создаем шаблонный dll проект и сразу же добавляем туда файл, например, api.h, в который запихиваем определения экспорта:

#ifndef _API_H_ #define _API_H_   #define EXPORT  __declspec(dllexport) #define STD_API __stdcall   #endif

Рядышком положим summer.c, и реализуем весь необходимый нам функционал:

#include "api.h"  int sum;  EXPORT void STD_API summer_init( void ) { 	sum = 0; }  EXPORT void STD_API summer_add( int value ) { 	sum += value; }  EXPORT int STD_API summer_sum( void ) { 	return sum; }

Теперь нам нужен класс-обертка над этим безобразием:

    class SummerUnmanaged     {         const string dllName = @"unmanaged_test.dll";         [DllImport(dllName)]         private static extern void summer_init();          [DllImport(dllName)]         private static extern void summer_add(int v);          [DllImport(dllName)]         private static extern int summer_sum();          public int Sum         {             get             {                 return summer_sum();             }         }          public SummerUnmanaged()         {             summer_init();         }          public void Add(int a)         {             summer_add(a);         }          public void Reset()         {             summer_init();         }     }

В итоге получилось именно то, чего я и хотел. Есть две совершенно одинаковые для использования реализации: одна на C#, вторая на Си. Теперь можно и посмотреть, что из этого выйдет! Напишем код, который замерит время выполнения n вызовов одного и другого класса:

        static void TestCall()         {             Console.WriteLine("Function calls...");             Stopwatch sw = new Stopwatch();             Summer s_managed = new Summer();             SummerUnmanaged s_unmanaged = new SummerUnmanaged();             Random r = new Random();             int[] data;             CSVReport report = new CSVReport("fun_call.csv", "elements", "C# managed", "C unmanaged");             data = new int[1000000];             for (int j = 0; j < 1000000; j++)                 data[j] = r.Next(-1, 2); // Генерируем мусор              for (int i=0; i<100; i++)             {                 // Чтобы не ждать у консоли погоды                 Console.Write("\r{0}/100", i+1);                 int length = 10000*i;                 long managedTime = 0, unmanagedTime = 0;                 Thread.Sleep(10);                 s_managed.Reset();                 sw.Start();                 for (int j = 0; j < length; j++)                 {                     s_managed.Add(data[j]);                 }                 sw.Stop();                 managedTime = sw.ElapsedTicks;                 sw.Reset();                 sw.Start();                 for(int j=0; j<length; j++)                 {                     s_unmanaged.Add(data[j]);                 }                 sw.Stop();                 unmanagedTime = sw.ElapsedTicks;                 report.Write(length, managedTime, unmanagedTime);             }             report.Dispose();             Console.WriteLine();         }

Осталось только вызвать эту функцию где-нибудь в мэйне, да поглядеть отчет в fun_call.csv. Для наглядности я не буду приводить скучные и сухие цифры, а выведу лишь график. По вертикали – время в тиках, по горизонтали – количество вызовов функции.

Результат меня немного удивил. C# явно был фаворитом в данном тесте. Все-таки, тот же модуль, да и возможность инлайнить… но в результате оба варианта оказались приблизительно одинаковыми. Собственно, в данном случае такое разделение кода оказалось бессмысленным – ничего не выиграли, а проект усложнили.

Массивы

Размышления о результатах были не долгими, и я тут же понял – нужно отсылать данные не по одному элементу, а массивами. Настало время модернизировать код. Допишем функционал:

        public void AddMany(int[] data)         {             int length = data.Length;             for (int i = 0; i < length; i++)                 Sum += i;         }

И, собственно, Си часть:

EXPORT int STD_API summer_add_many( int* data, int length ) {         for ( int i = 0; i < length; i++ )                 sum += data[ i ]; }

        [DllImport(dllName)]         private static extern void summer_add_many(int[] data, int length);           public void AddMany(int[] data)         {             summer_add_many(data, data.Length);         }

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

Новая функция измерения производительности

        static void TestArrays()         {             Console.WriteLine("Arrays...");             Stopwatch sw = new Stopwatch();             Summer s_managed = new Summer();             SummerUnmanaged s_unmanaged = new SummerUnmanaged();             Random r = new Random();             int[] data;             CSVReport report = new CSVReport("arrays.csv", "elements", "C# managed", "C unmanaged");              for (int i = 0; i < 100; i++)             {                 Console.Write("\r{0}/100", i+1);                 int length = 10000 * i;                 long managedTime = 0, unmanagedTime = 0;                 data = new int[length];                 for (int j = 0; j < length; j++) // Генерируем мусор                     data[j] = r.Next(-1, 2);                 s_managed.Reset();                 sw.Start();                 s_managed.AddMany(data);                 sw.Stop();                 managedTime = sw.ElapsedTicks;                 sw.Reset();                 sw.Start();                 s_unmanaged.AddMany(data);                 sw.Stop();                 unmanagedTime = sw.ElapsedTicks;                 report.Write(length, managedTime, unmanagedTime);             }             report.Dispose();             Console.WriteLine();         }

Запускаем, проверяем отчет. По вертикали все еще время в тиках, по горизонтали – количество элементов массива.

Видно невооруженным глазом — Си значительно лучше справляется с банальной обработкой массивов. Но это и есть цена за «управляемость» – в то время, как управляемый код в случае переполнения, выхода за границы массива, несовпадения фазы Луны с фазой Марса мягко кинет эксепшн, то си-код может запросто перезаписать не свою память и делать вид, что так и надо.

Лирика

Кстати, я так и не смог придти к разумному выводу о том, почему графики получаются такими неровными. Наверное, фазы GC или другие процессы отъедают процессорное время, но это не точно. Тем не менее, даже простое усреднение показывает, что Си в разы быстрее в конкретном случае.

Чтение файла

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

Для этих целей я сгенерировал стопку файлов (конечно же, линейно возрастающих в своем размере)

Генерируем файлы

        static void Generate()         {             Random r = new Random();             for(int i=0; i<100; i++)             {                 BinaryWriter writer = new BinaryWriter(File.OpenWrite("file" + i.ToString()));                 for(int j=0; j<200000*i; j++)                 {                     writer.Write(r.Next(-1, 2));                 }                 writer.Close();                 Console.WriteLine("Generating {0}", i);             }         }

В итоге самый большой файл получился 75 мегабайт, чего было вполне себе достаточно. Для теста я не стал выделять отдельный класс, а набыдлокодил прямо в класс мэина. Почему бы и нет, собственно.

        static int FileSum(string path)         {             BinaryReader br = new BinaryReader(File.OpenRead(path));             int sum = 0;             long length = br.BaseStream.Length;             while(br.BaseStream.Position != length)             {                 sum += br.ReadInt32();             }             br.Close();              return sum;         }

Как видно из кода, задачу я поставил следующую: просуммировать все целые числа из файла. Соответствующая реализация на Си:

EXPORT int STD_API file_sum( const char* path ) { 	FILE *f = fopen( path, "rb" ); 	if ( !f ) 		return 0; 	int sum = 0; 	while ( !feof( f ) ) 	{ 		int add; 		fread( &add, sizeof( int ), 1, f ); 		sum += add; 	} 	fclose( f ); 	return sum; }

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

Как видно из данного графика, Си оказался немного быстрее (примерно в полтора раза). Но выигрыш есть выигрыш.

Где-то на этом моменте меня понесло немного в степь (или куда-то еще), но не поделиться этими размышлениями я не могу. Любопытных прошу в спойлер, а всех остальных прошу перейти к следующей части моих исследований.

Степь

Время чтения файлов меня ужасало. Опытный программист сразу скажет, что не так с моим кодом – он слишком часто обращается к системе за данными из файла. Это медленно, очень медленно. В итоге я слегка модернизировал алгоритм чтения, добавив принудительную буферизацию до 400Кб данных из файла, что заметно все ускорило. Соответствующие изменения в Си и С# коде:

            long length = br.BaseStream.Length;             byte[] buffer = new byte[100000*4];             while(br.BaseStream.Position != length)             {                 int read = br.Read(buffer, 0, 100000*4);                 for(int i=0; i<read; i+=4)                 {                     sum += BitConverter.ToInt32(buffer, i);                 }             }

	int sum = 0; 	int *buffer = malloc( 100000 * sizeof( int ) ); 	while ( !feof( f ) ) 	{ 		int read = fread( buffer, sizeof( int ), 100000, f ); 		for ( int i = 0; i < read; i++ ) 		{ 			sum += buffer[ i ]; 		} 	}

Этот тест я не захотел включать в «основу» статьи, и на то была одна причина: тест не совсем справедливый. Си явно лучше подходил для такой задачи, потому что может писать что угодно и куда угодно, а вот в C# пришлось дополнительно конвертировать все побайтово, из-за чего я получил то, что получил:

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

Возвращение массивов

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

На чистом C# такая задача реализуется весьма просто, но чтобы связать Си код с C# кодом в данном случае придется сделать кое-что еще.

Для начала, решение на C#

        static byte[] FileRead(string path)         {             BinaryReader br = new BinaryReader(File.OpenRead(path));             byte[] ret = br.ReadBytes((int)br.BaseStream.Length);             br.Close();             return ret;         }

И соответствующее решение на Си:

EXPORT char* STD_API file_read( const char* path, int* read ) { 	FILE *f = fopen( path, "rb" ); 	if ( !f ) 		return 0;  	fseek( f, 0, SEEK_END ); 	long length = ftell( f ); 	fseek( f, 0, SEEK_SET ); 	read = length;  	int sum = 0; 	uint8_t *buffer = malloc( length ); 	int read_f = fread( buffer, 1, length, f ); 	fclose( f );  	return buffer; }

Для успешного вызова такой функции из C# нам придется написать обертку, которая вызовет эту функцию, скопирует данные из неуправляемой памяти в управляемую, да освободит неуправляемый участок:

        static byte[] FileReadUnmanaged(string path)         {             int length = 0;             IntPtr unmanaged = file_read(path, ref length);             byte[] managed = new byte[length];             Marshal.Copy(unmanaged, managed, 0, length);             Marshal.FreeHGlobal(unmanaged); // Мы же не желаем протекать?             return managed;         }

В функции замера изменились только соответствующие вызовы измеряемых функций. А результат выглядит вот так:

Даже с потерями времени на копирование памяти Си снова оказался в лидерах, выполняя поставленную задачу примерно в 2 раза быстрее. Честно говоря, я ожидал немного других результатов (учитывая данные второго теста). Скорее всего из-за того, что чтение данных даже крупной пачкой в C# происходит достаточно медленно. В Си же потрея времени идет при копировании неуправляемой памяти в управляемую.

Лирика

Чуть позже я заметил, что Marshal.Free… падает в Debug сборке. Чем это вызвано я не понял, но в Release сборке все работало как надо и не текло. Впрочем, самый первый тест намекает на то, что вызов free из Си библиотеки мало на что повлияет.

Реальная задача

Логическим заключением всех проведенных мною тестов было таким: реализовать какой-нибудь полноценный алгоритм на C# и на Си. Быстродействие оценить.

За алгоритм я принял чтение несжатого TGA файла с 32 битами на пиксель, да приведение его в нормальное RGBA представление (TGA формат подразумевает хранение цвета как BGRA). Чтобы жизнь не казалась маслом, возвращать будем не байты, а структуры Color:

        struct Color         {             public byte r, g, b, a;         }

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

Простенькая реализация чтения TGA

        static Color[] TGARead(string path)         {             byte[] header;             BinaryReader br = new BinaryReader(File.OpenRead(path));             header = br.ReadBytes(18);             int width = (header[13] << 8) + header[12];  // Небольшая магия, чтобы получить short             int height = (header[15] << 8) + header[14]; // Little-Endian, сначала младшие разряды             byte[] data;             data = br.ReadBytes(width * height * 4);             Color[] colors = new Color[width * height];             for(int i=0; i<width*height*4; i+=4)             {                 int index = i / 4;                 colors[index].b = data[i];                 colors[index].g = data[i + 1];                 colors[index].r = data[i + 2];                 colors[index].a = data[i + 3];             }             br.Close();             return colors;         }         static Color[] TGAReadUnmanaged(string path)         {             int width = 0, height = 0;             IntPtr colors = tga_read(path, ref width, ref height);             IntPtr save = colors;             Color[] ret = new Color[width * height];             for(int i=0; i<width*height; i++)             {                 ret[i] = Marshal.PtrToStructure<Color>(colors);                 colors += 4;             }             Marshal.FreeHGlobal(save);             return ret;         } 

И Сишный вариант:

#include "api.h"  #include <stdlib.h> #include <stdio.h>  // Ведет к беде, если структура выровняется // не по 4 байтам typedef struct { 	char r, g, b, a; } COLOR;   // Костыль, чтобы читать структуру целиком #pragma pack(push) #pragma pack(1) typedef struct { 	char idlength; 	char colourmaptype; 	char datatypecode; 	short colourmaporigin; 	short colourmaplength; 	char colourmapdepth; 	short x_origin; 	short y_origin; 	short width; 	short height; 	char bitsperpixel; 	char imagedescriptor; } TGAHeader; #pragma pack(pop)  EXPORT COLOR* tga_read( const char* path, int* width, int* height ) { 	TGAHeader header; 	FILE *f = fopen( path, "rb" ); 	fread( &header, sizeof( TGAHeader ), 1, f ); 	COLOR *colors = malloc( sizeof( COLOR ) * header.height * header.width ); 	fread( colors, sizeof( COLOR ), header.height * header.width, f ); 	for ( int i = 0; i < header.width * header.height; i++ ) 	{ 		char t = colors[ i ].r; 		colors[ i ].r = colors[ i ].b; 		colors[ i ].b = t; 	} 	fclose( f );  	return colors; }

Теперь дело осталось за малым. Нарисовать простое TGA изображение и загружать его n раз. Результат получился таким (по вертикали как обычно, по горизонтали – количество чтений файла).

Тут нужно отметить, что я нагло использовал возможности Си в его пользу. Читать из файла прямо в структуры существенно облегчило жизнь (а в случае, когда структуры выровняются не по 4 байтам, будет веселая отладка). Однако результатом я доволен. Такой вот незамысловатый алгоритм получилось эффективно реализовать на Си, и эффективно его же использовать в C#. Соответственно, я получил ответ на изначальный вопрос: выиграть действительно можно, но не всегда. Иногда можно выиграть незначительно, иногда не выиграть вовсе, а иногда выиграть в несколько и больше раз.

Сухой остаток

В целом, сама идея выносить реализацию чего-то на другой язык сомнительна, как я и написал в самом начале. В конце концов, применения такому способу ускорения кода можно найти достаточно мало. Если открытие файла начинает вешать UI – можно вынести загрузку в отдельный фоновый поток, и тогда загрузка даже в секунду не вызовет ни у кого серьезных затруднений.

Соответственно, так извращаться стоит только тогда, когда производительность действительно необходима, и добиться ее другими путями уже не получается (а в таких случаях обычно пишут сразу на Си или С++). Или если уже есть готовый алгоритм, который можно использовать, а не изобретать велосипед.

Хочется отметить, что простая обертка над неуправляемой dll особого выигрыша в производительности не даст, и вся «шустрость» неуправляемых языков начинает раскрываться только при обработке достаточно больших объемов данных, так что на это тоже стоит обратить внимание. Однако и от использования такой обертки хуже не станет.

C# очень хорошо справляется с передачей управляемых ресурсов неуправляемому коду, но обратное превращение происходит не так быстро, как хотелось бы. Поэтому частого преобразования данных желательно избегать и держать неуправляемые ресурсы в неуправляемом коде. Если нет необходимости эти данные править / читать в управляемом коде, то можно использовать IntPtr для хранения указателей, а оставшуюся работу вынести целиком в неуправляемый код.

Конечно, можно (и даже нужно) провести дополнительные исследования перед принятием окончательного решения о переносе кода в неуправляемые сборки. Но и с текущей информацией можно принять решение о целесообразности таких действий.

А на этом у меня все. Спасибо, если дочитали до конца!
ссылка на оригинал статьи https://habrahabr.ru/post/325954/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *