Программирование сопроцессора на C#? Да!

    Наверное все знают о существовании сопроцессора FPU. Как писать код для него читаем дальше . FPU – floating point unit – часть центрального процессора, специально предназначенная для работы с типами данных, представляющими числа с плавающей точкой, или по-другому с типами float и double. Данный модуль в составе процессоров появился после появления на свет Intel 486DX (благодарю за поправку), да вот так давно. И с тех пор именно он выполняет работу по вычислениям различных математических выражений, а точнее их в виде кода на языке ассемблера. Другими словами, компилятор не весь код программы преобразует в стандартный набор инструкций типа mov, sub и прочие, но и еще в fld, fstp, fsub, fadd…, если речь идет о вычислениях с участием типов double. Как видите, инструкции для FPU имеют приставку “f”, по которым собственно можно сразу отличить код, предназначенный для него. Всю информацию по FPU вы можете найти на просторах инета, погуглив его по имени, также рекомендую сайт wasm.ru – раздел «Процессоры». Сопроцессор очень интересная штука и программирование его очень интересное занятие, я бы даже сказал захватывающее – не знаю, что почувствуете Вы, но я был в восторге, когда у меня получилось «заклинать» код, давая команды непосредственно процессору без посредников-компиляторов, CLR-среды и др. Почему «заклинать»? Об этом чуть позже.
    Термин «заклинать» я позаимствовал у автора замечательных статей на сайте. Это серия статей про «Заклинание кода», которые я Вам рекомендую почитать после прочтения моей статьи.
    Сейчас я покажу Вам как же написать простой пример заклинания кода применительно для FPU. Сразу должен предупредить, что хоть в конце и будет участвовать C#, для самого заклинания нужен С++.
    Допустим нам надо вычислить такое выражение: result = arg1 – arg2 + arg3.
    Есть несколько вариантов составления кода. Чтобы не усложнить понимание происходящего, я покажу сначала один, чуть позже покажу другой.
    Итак, первый вариант выглядит так:

    fld [arg1]
    fld [arg2]
    fsubp
    fld [arg3]
    faddp
    fstp [result]
    ret

    Теперь поясню. В квадратных скобках мы должны указывать адреса переменных arg1, arg2, arg3, result.
    Инструкция fld загружает в вершину стека (FPU работает со стеком, причем он имеет некоторые особенности) значение переменной double, адрес которой идет сразу после инструкции; fsubp – производит вычитание значения, лежащего на 1 позиции в стеке ниже, значение, лежащее на вершине стека и освобождает вершину стека, тем самым результат записывается на место значения, из которого вычитается, результат находится теперь на вершине стека; faddp – работает по аналогии с fsubp, только не вычитает, а складывает значения; fstp – выгружает из вершины стека значение double, выгружает в ячейку по адресу, указанному далее; ну и инструкция ret – интуитивно понятная – завершает выполнение функции и передает управление в функцию, вызвавшую ее. Чтобы было более понятно, покажу работу нашего кода в картинках:



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

    double ExecuteMagic(double arg1, double arg2, double arg3)
        {
          short* code;
          short* code_cursor;
          short* code_end;
          double* data;
          double* data_cursor;
          SYSTEM_INFO si;
          GetSystemInfo(&si);
          DWORD region_size = si.dwAllocationGranularity;

    code = (short*)VirtualAlloc(NULL, region_size * 2, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
          code_cursor = code;
          code_end = (short*)((char*)code + region_size);
          data = (double*)code_end;
          data_cursor = data;

          *data_cursor = arg1;
          *code_cursor++ = (short)0x05DDu; //fld
          *(int*)code_cursor = (int)(INT_PTR)(data_cursor); //1.0
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение
          data_cursor++;

          *data_cursor = arg2;
          *code_cursor++ = (short)0x05DDu; //fld
          *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //-2.0
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

          *code_cursor++ = (short)0xE9DEu; //fsubp

          *data_cursor = arg3;
          *code_cursor++ = (short)0x05DDu; //fld
          *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //2.0
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

          *code_cursor++ = (short)0xC1DEu; //faddp

          double *result = data_cursor;

          *code_cursor++ = (short)0x1DDDu; //fstp
          *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

          *code_cursor++ = (short)0x90C3u; //ret

          void (*function)() = (void (*)())code;

          //1-(-2)+2=5
          function();

          return *result;
        }

    * This source code was highlighted with Source Code Highlighter.


    Теперь давайте разберем все самое вкусное здесь. Итак, мы с помощью функции VirtualAlloc выделяем нашему коду некоторое количество памяти (а именно согласно значению структуры
    SYSTEM_INFO. dwAllocationGranularity, как бы системная величина разбиения памяти); обратите внимание какие аргументы принимает функция на вход, а именно на PAGE_EXECUTE_READWRITE – именно этот параметр позволяет обратиться к вновь созданному участку памяти не только для чтения/записи, но и для выполнения кода, т.е. мы можем передать в этот участок памяти управление и процессор будет считывать дальнейшие инструкции именно отсюда.
    Половина этого созданного массива выделяем для кода, вторую половину для данных – некое подобие сегмента кода и сегмента данных. Все что осталось – заполнить эти сегменты данных необходимыми значениями. Для заполнения массива кодом необходимо просто записать в этот массив опкоды (инструкции процессора) в шестнадцатеричном виде. Разберем все по порядку.
    Инструкция FLD имеет опкод DD/0. Да, кстати, сразу скажу, что значения опкодов и их мнемоническое написание вы можете посмотреть в документации по архитектуре процессоров. Продолжим, FSTP имеет тоже опкод DD, но уже с приставкой /3 – это расширение опкода – mod r/m байт. Вот таблица значений mod r/m байта [http://www.sandpile.org/ia32/opc_rm32.htm] (Пытливые умы при присутствии интереса смогут разобраться во всем этом, поверьте). Так как инструкция FLD и FSTP могут оперировать с операндами разного типа, т.е. ячейками, регистрами процессора, то для этого и существует расширение опкода. Нам для работы нужен вид операнда адрес числа double, поэтому в той таблице мы смотрим значение для [sdword]. Для FLD это значение равно 05h, для FSTP 1Dh. Прибавляем эти значения к опкодам и получаем: FLD = DD05h, FSTP = DD1Dh. Инструкция FSUBP имеет опкод DE/5, и мы опять должны обратиться к таблице расширения опкода и посмотреть значение расширения для XMM1 (это ссылка элемент стека FPU) и видим, что оно равно E9h, т.е. FSUBP = DEE9h. FADDP также как и FSUBP имеет опкод DE, но уже /0, что для XMM1 имеет значение C1h, т.е. FADDP = DEC1h. Инструкция RET имеет опкод C390h.
    Следует отметить, что инструкции процессор считывает с конца, поэтому их надо записывать наоборот, с учетом того, что они по 2 байта и парные, т.е. FLD = DD05h надо записывать не 50DDh, а 05DDh, это важно!
    Ну вот в принципе и все по опкодам. Код на языке С++ выше показывает как заполнять массив инструкциями. Сначала записываем инструкцию, затем, если это необходимо, адрес ячеек. Обратите внимание, что адрес имеет длину 4 байта (32 бита) для 32-битных систем, поэтому после записи адреса в массив кода, необходимо сместить указатель на 4 байта вперед, вместо 2 байт в случае инструкций.
    Кульминацией сего чуда является выполнение записанного в память кода. Как выполнить код из нашего массива? За помощью мы обращаемся к указателю на функцию, здесь язык С++ выручает. Создаем указатель на функцию типа void с параметрами void, далее присваиваем ему указатель на начало массива кода. Все! Запускаем наш указатель на функцию, получаем результат работы программы прям в памяти, процессор все сделал ровно так, как мы ему сказали в нашем массиве кода.
    Теперь я напомню, что это 1 способ передачи параметров и возвращения результата. Второй способ заключается в том, чтобы создать указатель на функцию типа double(void), т.е. чтобы нам не в память записывался результат и мы его сами вытаскивали, а чтобы нам уже результат возвратила наша функция, созданная динамически. Для этого просто изменяем код на такой:

    fld [arg1]
    fld [arg2]
    fsubp
    fld [arg3]
    faddp
    //fstp [result]
    ret

    Т.е. просто оставляем в вершине стека результат. И наш указатель на функцию вернет нам результат из вершины стека. Все просто.

    Читатель уже с середины статьи задается вопросом: «Причем тут C#??? Один С++ и Ассемблер, непонятные цифры…». Справедливо, но надо быть терпеливее :).

    Итак, мы с Вами знаем, что мы можем выполнять из С# функции, написанные на С++, Delphi и др.
    Реализовывать это можно с помощью ключевого слова extern и атрибута [DllImport(«*.dll»)].
    Также есть вариант и проще. Программисты платформы .NET смогли подружить управляемый код и неуправляемый. Таким образом просто создаем новый класс на С++ с использованием вышеупомянутой техники, реализующий кодогенерацию, заклинание кода. Далее просто подключаем эту библиотеку к проекту, использующему управляемый код C# и совершенно беспрепятственно пользуемся. Я так и сделал. Как же я был рад, когда результат не заставил себя ждать! :)

    Вот что я сделал:

    #include <windows.h>

    #pragma once

    using namespace System;

    namespace smallcodelib
    {
      public ref class CodeMagics
      {
      public:
        static double ExecuteMagic(double arg1, double arg2, double arg3)
        {
          short* code;
          short* code_cursor;
          short* code_end;
          double* data;
          double* data_cursor;
          SYSTEM_INFO si;
          GetSystemInfo(&si);
          DWORD region_size = si.dwAllocationGranularity;

          code = (short*)VirtualAlloc(NULL, region_size * 2, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
          code_cursor = code;
          code_end = (short*)((char*)code + region_size);
          data = (double*)code_end;
          data_cursor = data;

          *data_cursor = arg1;
          *code_cursor++ = (short)0x05DDu; //fld
          *(int*)code_cursor = (int)(INT_PTR)(data_cursor); //1.0
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение
          data_cursor++;

          *data_cursor = arg2;
          *code_cursor++ = (short)0x05DDu; //fld
          *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //-2.0
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

          *code_cursor++ = (short)0xE9DEu; //fsubp

          *data_cursor = arg3;
          *code_cursor++ = (short)0x05DDu; //fld
          *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //2.0
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

          *code_cursor++ = (short)0xC1DEu; //faddp

          double *result = data_cursor;

          *code_cursor++ = (short)0x1DDDu; //fstp
          *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //
          code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

          *code_cursor++ = (short)0x90C3u; //ret

          void (*function)() = (void (*)())code;

          //1-(-2)+2=5
          function();

          return *result;
        }
      };
    }

    Это код для класса на С++.

    И:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Runtime.InteropServices;
    using smallcodelib;

    namespace test_smallcodelib
    {
      class Program
      {
        static void Main(string[] args)
        {
          Console.WriteLine("Заклинание кода! (* Выход)");
          while (!Console.ReadLine().Equals("*"))
          {
            double arg1;
            double arg2;
            double arg3;

            Console.Write("arg1?: "); arg1 = Convert.ToDouble(Console.ReadLine());
            Console.Write("arg2?: "); arg2 = Convert.ToDouble(Console.ReadLine());
            Console.Write("arg3?: "); arg3 = Convert.ToDouble(Console.ReadLine());

            double result = CodeMagics.ExecuteMagic(arg1, arg2, arg3);

            Console.WriteLine(String.Format("Result of arg1 - arg2 + arg3 = {0}", result));
          }
        }
      }
    }

    * This source code was highlighted with Source Code Highlighter.


    Это уже на C#!

    Проверьте! Все работает!

    Ясное дело, что тут больше кода на С++, однако если у интересующихся есть определенный талант и интерес помучаться в данной области, то можно написать некоторую обертку на С++, которая будет генерировать такой код динамически, а использовать эту обертку уже из C#, наполняя ее необходимыми переменными и параметрами, ну и т.д. Можно получить довольно интересную штуку.

    Добавлю еще пару приятностей.
    Статья написана применительно программирования сопроцессора. На самом деле можно писать все, что душе угодно, для этого надо изучить архитектуру памяти и процессора ЭВМ, инструкции. Технологически продвинутые программисты, знающие что такое SSE (а она уже чуть ли не 5), могут писать код, используя все новшества процессорных технологий и самое приятное – это использовать это в C#. Все ограничивается фантазией =). Удачи в начинаниях!

    Хочу выразить огромную благодарность своему друг Пётру Канковски, который в свое время помог мне во всем этом разобраться! У него есть свой сайт-вики, где он и его коллеги и друзья обсуждают различный способы оптимизации кода и др. [http://www.strchr.com/]

    UPD: Здесь есть простой пример такого же принципа генерации нативного кода, но уже полностью на C#. Спасибо lastmsu за наводку на Marshal.GetDelegateForFunctionPointer().

    Благодарю за внимание! Удачи!

    Комментарии 48

      +1
      ХабраРедактор не забыл про кат, а вы забыли
        +4
        А где тесты сравнения скорости вычисления вашего «заклятого» кода и обычного, через операторы +-*/ ???
          0
          Ммм. Не ставил задачи протестировать разницу в скорости. Но сразу могу сказать, что «заклятый» код выполняет непосредственно сопроцессор в связке с процессором. Код на C# претерпит jit-компиляцию, которая скорее всего приведет к такой же конструкции.
          Однако цель статьи не демонстрация преимущества скорости конкретного примера, а скорее демонстрация того, как можно использовать дополнительные возможности процессора, чего не может компилятор C#. Например, Вы можете писать на инструкциях SSE 2,3,4 и более, 3DNow (технология AMD) и прочих. В принципе, можно воспользоваться утилитой .NET ngen.exe, чтобы сгенерировать образы нативных кодов для Ваших .NET сборок, но не всегда это получается.
          Кроме этого, в этой статье только пример того, как это делается, я упоминал, что при наличии интереса можно развить это направление и динамически генерировать себе любой код. При это не надо никаких MSIL инъекций и прочего, пишите на нативном коде и используйте его в своих программах на C#
            0
            Например, Вы можете писать на инструкциях SSE 2,3,4 и более, 3DNow (технология AMD) и прочих.

            Насчёт SSE лучше смотрите в сторону Mono.Simd.
            Надеюсь и в родном Microsoft .Net такое со временем появится.
            В принципе, можно воспользоваться утилитой .NET ngen.exe, чтобы сгенерировать образы нативных кодов для Ваших .NET сборок, но не всегда это получается.

            В большинстве случаев это и не нужно.
            Если действительно есть «узкое место» в производительности, намного логичнее реализовать его в native dll.
            … можно развить это направление и динамически генерировать себе любой код. При это не надо никаких MSIL инъекций и прочего, пишите на нативном коде и используйте его в своих программах на C#

            Протестируйте производительность своего варианта.
            После этого Вы и сами поймёте почему практическая ценность Вашего решения близится к 0.
              0
              с появлением лямбда выражений, и если нужны операции обработки большой коллекции, перед первым вызовом можно генерировать подобный код на основе лямбда дерева, а дальше только вызывать его для обработки элемента коллекции. будет больше чем занятно) и что-то мне кажется, что кто-то уже должен был подобное реализовать…
          +2
          Практическая ценность?
            +1
            Кодогенерация на нативном коде. Быстро работает, есть ощущение, что процессор делает то, что Вы ему сказали =)
            Мне очень пригодилось для мат.расчетов в программе, которую я написал. Вводите формулу, программа ее парсит и генерирует нативный код. Первая версия генерировала с помощью Reflection API, но потом отказался ради увеличения скорости.
              +4
              «есть ощущение, что процессор делает то, что Вы ему сказали» это чесание ЧСВ, а не практическая ценнность.

              Практическая ценность это что-то вроде. Написание кода заняло 20 часов, расчёты без данной оптимизации выполнялись 1000 часов, ускорение 23%, съэкономлено 1000 * 0.23 — 20 = 210 часов.
              +6
              Ну как же? Вот решили Вас, предположим, уволить, и Вам требуется оставить после себя код, обладающий минимальной совместимостью и максимальной сложностью сопровождения. Что будете делать? =)
              +6
              а) Зачем это? В C# есть float и JIT-тер скорее всего умеет использовать SSE.
              б) Зачем C++? Можно было подключить функции к C#.
              в) Есть Mono.SIMD
                –9
                А) JIT-компилятор возможно знает SSE и прочие (сомневаюсь правда). Статья для тех, кто хочет делать это своими руками.
                Б) С++ затем, что в нем есть указатели на функции, что позволяет передать управление в начало массива. Да, можно подключить функцию, но ведь и можно на С++ написать обертку (я упоминал об этом) и генерировать любой код в любое удобное для Вас время. Причем код нативный с удобными для Вас инструкциями процессора.
                С) Когда я этим начал заниматься, Mono был на стадии зачатия, даже MonoDevelop еще не существовала.
                  +1
                  а) знает
                  б) > есть указатели на функции
                  GetDelegateForFunctionPointer
                  в) не следите…
                    0
                    jit-компилятор отлично знает про SSE и прочие
                    • НЛО прилетело и опубликовало эту надпись здесь
                        –1
                        Ну как бы в статье написано как не компилить на Си, а заливать код в память (причем с учетом «архитектуры») и выполнять оттуда. Это несколько другое.
                        А чтобы jit-у лучше жилось на текущей машине, то можно воспользоваться ngen.exe, которая создаст для Вашей сборки образ нативного кода, который будет учитывать разные особенности машины, и .NET будет этот оптимизированный образ использовать при выполнении кода из Вашей сборки.
                  • НЛО прилетело и опубликовало эту надпись здесь
                      0
                      Согласен. А у Вас есть вариант как динамически сгенерировать нативный код и тут же его подключить к основному языку разработки (в моем случае C#)?
                      • НЛО прилетело и опубликовало эту надпись здесь
                          0
                          C# не предназначен для работы с нативным кодом. Если вам нужен MSIL (который замечательно компилируется JIT-ом в MMX, SSE, 3DNow! и прочие расширения в зависимости от текущего процессора), то это Reflection.Emit.

                          Если бы вы написали разборщик выражений, который бы через Reflection.Emit генерировал MSIL — пользы было бы больше.
                        +3
                        рискну утверждать, что интегрированный FPU в CPU был в процессорах Intel уже в Pentium (если не раньше).
                          +2
                          в 486
                          +4
                          угу, 386DX и 486DX
                            0
                            У 386DX встроенного сопра не было. От SX он отличался шириной шины данных.
                          +2
                          Да, утверждение, что он появился в PIII, меня тоже неслабо развеселило…
                          0
                          вообще, если все это нужно для реализации математических вычислений, то еще лет 7 назад Intel и AMD уже распространяли собственные библиотеки с реализацией всех основных функций, максимально использующие возможности процессоров

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

                          но в целом, если алгоритм нормально написан, то его можно реализовать просто на C#, с небольшими вставками MSIL-ассемблера, и скорость работы будет приемлемая
                          опять же, для SSE можно вызывать библиотеки от производителя процессора
                            0
                            В плюсах встроенный ассемблер (__asm) не жует MMX?
                              0
                              Он, например, не доступен на x64 (по крайней мере в VC)
                                –1
                                А как насчет раздельной компиляции с последующей линковкой?

                                Воспользоваться нормальным ассемблером, сделать объектный файл и слинковать его с плюсовым кодом в виде DLL. А дальше DLLImport.
                                0
                                на то он и Assembler. Он всё жуёт.
                                0
                                Генерацией на MSIL я баловался раньше. Это вообще говоря намного проще.
                                Но как ни крути, jit не всегда улавливает мысли разработчика. Я честно не знаю, не видел ядра jit-компилятора. Но вот вопрос: если мы работаем с огромным массивом данных (double) (миллиарды элементов), то мы можем обрабатывать сразу по 2-4 элемента в такт с помощью SSE2 — 4; вот и скажите, jit-компилятор соптимизирует это в реально возможный код на машинных кодах (кстати, не все компиляторы С++ это могу, если не все. Приходится это делать в отдельном проекте на ассемблере и объектный файл прикручивать к проекту С++)?
                                  0
                                  Нет, если это дает реальный прирост производительности — всегда пожалуйста.

                                  Но для этого надо померять ее, производительность то.
                                  +1
                                  Меня терзают смутные сомнения, что JIT-компилер отлично знает об особенностях текущей платформы, на которой выполняется код, и сразу генерирует оптимизированный под нее код. Это какбэ одна из задач JIT-компилятора.
                                    +1
                                    Это хорошо для обучения программированию FPU. Практической ценности здесь нет.
                                    Оптимизация, о которой вы говорите — это МИКРО-оптимизация, которая влияет разве что при брутальном переборе в 8 потоков чего нибудь супер-математического.
                                      0
                                      На хабре неуместны статьи, помогающие тем, у кого редкие цели и математические задачи?
                                        0
                                        Т.е. не так я хотел сказать, пардон.
                                        «Не имеют практической ценности статьи, помогающие достичь непопулярных целей, с математической основой?»
                                        И как это понять, «хорошо для обучения программированию FPU», но «практической ценности нет», а зачем тогда обучение?
                                        Задачи всякие бывают, и часто упирающиеся в вычисления, а не то, что попсовей, типа растолкать куда-то записи по таблицам в БД. При этом программист может предпочитать свой любимый C#, и, я думаю, наверняка сейчас многие с интересом прочтут эту статью, и возможно «давно ее ждали».
                                      0
                                      Хотелось бы чтобы каждый из Вас, прочитав статью, извлек из нее что-то полезное для себя.
                                      Надеюсь, кому-нибудь это поможет в решении той или иной задачи (не важно какого проекта).
                                        +1
                                        Спасибо lostmsu за наводку на Marshal.GetDelegateForFunctionPointer().

                                        Таким образом, мы можем исключить С++ и переписать все на C#.

                                        Вот здесь есть пример использования генерации нативного кода непосредственно на C#.
                                        Пройдя по ссылке увидите сам способ реализации, а как заполнять массив опкодами я рассказал в моем посте.
                                          0
                                          Да не за что.
                                          Я на самом деле тоже люблю велосипеды изобретать.
                                          Сейчас, например, занимаюсь реализацией CLR на F#. Транслятором JIT-to-Native.
                                          Низкоуровневый уже есть. Как раз float'ы не поддерживает.

                                          Правда вчера решил для этого попробовать приспособить llvm, а не генерировать сразу двоичный код.
                                            0
                                            В смысле, реализацией?
                                            Вы пишите свою версию среды?
                                              0
                                              Да, меня заинтересовал проект Singularity и, поскольку его лицензия не достаточно открытая, я решил реализовать нечто подобное. Пару лет назад учавствовал в проекте Cosmos, но они пошли на мой взгяд странным путём и поэтому я сейчас пишу свою версию.

                                              Если удастся прикрутить llvm, может даже получиться что-то стоящее.
                                        • НЛО прилетело и опубликовало эту надпись здесь
                                            0
                                            извращение это — заниматься низкоуровневым программированием из языка высокого уровня
                                              0
                                              То есть вы считаете, что компилятор не стоит писать на OCaml, например?
                                                0
                                                да
                                              +2
                                              Бред — с тем же успехом можно было написать «Программирование сопроцессора из плагина Winamp/Foobar/utorrent/С++/VB/Java»
                                                0
                                                А мне нравится.

                                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                Самое читаемое