Программируя на 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!», подумал я, и написал простенький класс для хранения данных в этом формате:
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(); }
Запускаем, проверяем отчет. По вертикали все еще время в тиках, по горизонтали – количество элементов массива.
Видно невооруженным глазом — Си значительно лучше справляется с банальной обработкой массивов. Но это и есть цена за «управляемость» – в то время, как управляемый код в случае переполнения, выхода за границы массива, несовпадения фазы Луны с фазой Марса мягко кинет эксепшн, то си-код может запросто перезаписать не свою память и делать вид, что так и надо.
Чтение файла
Убедившись, что обрабатывать крупные массивы данных в Си быстрее, я решил — надо читать файлы. Это решение было вызвано желанием проверить, насколько шустро код сможет общаться с системой.
Для этих целей я сгенерировал стопку файлов (конечно же, линейно возрастающих в своем размере)
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; }
Теперь осталось циклически прочитать все файлы, да измерить скорость работы каждой реализации. Код функции замера приводить не буду (его можно составить и самому) и перейду сразу к наглядным демонстрациям.
Как видно из данного графика, Си оказался немного быстрее (примерно в полтора раза). Но выигрыш есть выигрыш.
Где-то на этом моменте меня понесло немного в степь (или куда-то еще), но не поделиться этими размышлениями я не могу. Любопытных прошу в спойлер, а всех остальных прошу перейти к следующей части моих исследований.
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# происходит достаточно медленно. В Си же потрея времени идет при копировании неуправляемой памяти в управляемую.
Реальная задача
Логическим заключением всех проведенных мною тестов было таким: реализовать какой-нибудь полноценный алгоритм на C# и на Си. Быстродействие оценить.
За алгоритм я принял чтение несжатого TGA файла с 32 битами на пиксель, да приведение его в нормальное RGBA представление (TGA формат подразумевает хранение цвета как BGRA). Чтобы жизнь не казалась маслом, возвращать будем не байты, а структуры Color:
struct Color { public byte r, g, b, a; }
Реализация алгоритма довольно емкая, да и вряд ли интересна. Поэтому она вынесена в спойлер, чтобы не мозолить глаза тем, кому не интересно.
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/
Добавить комментарий