Всем привет! Хочу поделиться небольшой историей допиливания HeatonResearchNeural — библиотеки разнообразных нейросетей. Сразу оговорюсь, что работаю аналитиком, а честным программистом перестал быть лет 10 назад.

Однако у меня есть собственный проект на C#, который развиваю в свободное время. Чтобы не заморачиваться написанием велосипеда когда-то скачал HeatonResearchNeural прикрутил скотчем и спокойно гонял тесты, дорабатывал логику своего кода и т.д. Для максимального ускорения заложил в архитектуру решения параллелизацию выполнения расчетов и глядя на загрузку CPU по 80-90% по телу разливалось приятное хозяйское тепло — все пашут, все при деле!

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

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

Когда профилирование указало на библиотечные функции, в душе затрепетало хладнокровное волнение. Понятно, что разработчики такой хорошей библиотеки подумали про скорость, верно? Или миром все-таки правит не тайная ложа, а явная лажа? Чтобы ответить на этот вопрос давайте рассмотрим внимательнее результат прогона 100 циклов моей программы, почти целиком состоящей из работы нейросетей:

image

Проваливаемся в Call Tree отчета и находим самые тяжелые функции:

image

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

public Matrix GetCol(int col)
        {
            if (col > this.Cols)
            {
                throw new MatrixError("Can't get column #" + col
                        + " because it does not exist.");
            }

            double[,] newMatrix = new double[this.Rows, 1];

            for (int row = 0; row < this.Rows; row++)
            {
                newMatrix[row, 0] = this.matrix[row, col];
            }

            return new Matrix(newMatrix);
        }

Лишь для передачи в DotProduct:

            for (i = 0; i < this.next.NeuronCount; i++)
            {
                Matrix.Matrix col = this.matrix.GetCol(i);
                double sum = MatrixMath.DotProduct(col, inputMatrix);

                this.next.SetFire(i, this.activationFunction.ActivationFunction(sum));
            }

Лишаем этого паразита питательных и полных витаминами гигагерц двумя точными ударами прямого слэша и передаем в DotProduct сразу всю матрицу вместе с нужным номером колонки:

//Matrix.Matrix col = this.matrix.GetCol(i);
double sum = MatrixMath.DotProduct(this.matrix, i, inputMatrix);

А уже внутри вместо изящного кружева:

        public static double DotProduct(Matrix a, Matrix b)
        {

            if (!a.IsVector() || !b.IsVector())
            {
                throw new MatrixError(
                        "To take the dot product, both matrixes must be vectors.");
            }

            Double[] aArray = a.ToPackedArray();
            Double[] bArray = b.ToPackedArray();

            if (aArray.Length != bArray.Length)

            {
                throw new MatrixError(
                        "To take the dot product, both matrixes must be of the same length.");
            }

            double result = 0;

            int length = aArray.Length;

            for (int i = 0; i < length; i++)
            {
                        result += aArray[i] * bArray[i];
            }
            return result;

Лепим простой, как топор, быдлокод:

        public static double DotProduct(Matrix a, int i, Matrix b)
        {

            double result = 0;

            if (!b.IsVector())
            {
                throw new MatrixError(
                        "To take the dot product, both matrixes must be vectors.");
            }

            if (a.Rows != b.Cols || b.Rows != 1)
            {
                throw new MatrixError(
                        "To take the dot product, both matrixes must be of the same length.");
            }

            
            int rows = a.Rows; // Так будет гораздо быстрее, чем если указать a.Rows прямо в условии цикла
            for (int r = 0; r < rows; r++)
            {
                result += a[r, i] * b[0, r];
            }

            return result;

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

        public double this[int row, int col]
        {
            get
            {
                //Validate(row, col);
                return this.matrix[row, col];
            }
            set
            {
                //Validate(row, col);
                if (double.IsInfinity(value) || double.IsNaN(value))
                {
                    throw new MatrixError("Trying to assign invalud number to matrix: "
                            + value);
                }
                this.matrix[row, col] = value;
            }
        }

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

image

Первые 6 секунд на графике это подготовка данных, поэтому реальное время работы сократилось с 780 до 26 секунд, при абсолютно одинаковом результате, естественно. Таким образом, ускорение получилось в 30 раз!

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

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


  1. shai_hulud
    27.10.2016 12:18
    +1

    Я думаю, что замена многомерных массивов double[x,y] на одномерные double[x*y] даст неплохой прирост производительности. Не в 30 раз конечно но даст.


    1. SystemXFiles
      27.10.2016 12:35

      Далеко не факт.
      Я не работал с C#, но на Java (понимаю что языки совершенно разные) приходилось иногда делать расчеты в многомерных массивах.
      Очень и очень редко переход с n-мерных массивов к одномерному приводили к ускорению выполнения. В особо успешных случаях ускорение было, но на грани погрешности.
      Смею предположить, что иногда компилятор или VM догадываются о том, что делает код и сами разворачивают его в один цикл.
      Вполне возможно, что C# способен на такое.


      Вообще лучше всего проверить на практике. У меня жаль под рукой C# нет


      1. shai_hulud
        27.10.2016 12:40
        +1

        >Вполне возможно, что C# способен на такое.
        Не не способен. Печаль в том что в .NET хорошо оптимизированы одномерные массивы, и вообще никак многомерные. К примеру доступ к элементу в одномерном массиве это IL opcode ld.elem, в многомерном это вызов метода на классе Array, который потом транслируется в кишки CLR. Так что разница может быть существенной.


    1. pustota_2009
      27.10.2016 13:55

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


    1. maaGames
      27.10.2016 16:51
      -2

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


    1. Flux
      28.10.2016 04:08

      Здравый смысл подсказывает что многомерные (которые T[,] а не jugged вариант T[][]) массивы внутри устроены как обычные одномерные с трансляцией индексов вида

      (i, j) -> i * dim(1) + j
      
      (возможно с выравниванием), и вряд ли дадут значительное ускорение при ручном перепиливании.
      Могут ли товарищи знакомые с кишками компилятора подтвердить или опровергнуть эту догадку?


      1. pustota_2009
        28.10.2016 08:19

        Проверил, простое заполнение небольшого двумерного массива x,y проходит несколько медленнее, чем одномерного x*y.

        int x, y, z;
        int i, j, k;

        x = 100;
        y = 10;
        z = 10000;

        double[,] a = new double[x, y];
        double[] b = new double[x * y];

        Stopwatch stA = new Stopwatch();
        stA.Start();

        for (k = 0; k < z; k++)
        for (i = 0; i < x; i++)
        for (j = 0; j < y; j++)
        a[i, j] = i * k;

        stA.Stop();

        Stopwatch stB = new Stopwatch();
        stB.Start();

        for (k = 0; k < z; k++)
        for (i = 0; i < x * y; i++)
        b[i] = i * k;

        stB.Stop();

        Results:

        stA.Elapsed.Ticks = 531087
        stB.Elapsed.Ticks = 390716


  1. forcewake
    27.10.2016 12:33
    +1

    Можно было бы еще поэксперементировать с System.Numerics.Vectors. На хабре была неплохая статья на эту тему https://habrahabr.ru/post/274605/.


    1. pustota_2009
      27.10.2016 13:57

      Спасибо, весьма интересно!


  1. Danov
    27.10.2016 13:10
    +1

    Интересно было бы про саму библиотеку прочесть в примерах. А если в двух словах, что в ней можно делать?


    1. pustota_2009
      27.10.2016 14:11

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

      https://github.com/jeffheaton/jeffheaton-book-code/tree/master/CSIntroNeuralNetworkEdition2


  1. daiver19
    27.10.2016 13:29
    +1

    Здорово получилось, конечно. Но что-то мне подсказывает, что переход на GPU обеспечит ускорение еще на порядок :)


    1. pustota_2009
      27.10.2016 14:13

      Да, GPU показывает изумительные результаты. Я пробовал делать расчеты на GPU, оказалось там очень большие затраты на ввод вывод данных (вплоть до проигрыша CPU в сумме). Специфика моего приложения в том, что обмен данными очень интенсивный, такой что смысла юзать GPU не увидел.


    1. Danov
      27.10.2016 14:29
      +1

      Прежде еще можно задействовать SIMD (System.Numerics.Vectors) и переделать double на float.


  1. old_bear
    27.10.2016 17:34
    +1

    Этот код у его автора на гитхабе лежит. Почему бы не запилить туда коммит для более всеобщей борьбы с энтропией?


  1. kanikeev
    27.10.2016 18:14
    +1

    «и глядя на загрузку CPU по 50-60% по телу бежал неприятный холодок — кто-то халтурит!» :)


    1. pustota_2009
      27.10.2016 20:41

      Да это к счастью не проблема, загрузить проц весьма легко увеличив кол-во параллельно выполняющихся потоков в 1,5-2 раза, данный пример был для сравнимости результатов при одинаковом коде приложения (менялась только библиотека).


  1. fedorro
    27.10.2016 19:18

    А статья для которой версии библиотеки? GetCol в текущей есть, но ни разу не используется. DotProduct вызывается в одном месте, не очень часто используемом. Можно попробовать сравнить с результатом, при использовании последней версии.


  1. Danov
    31.10.2016 17:31

    Интересно, какие задачи решаете с помощью этих сетей?
    И какие максимальные размеры сеток (сколько слоев)
    и входных таблиц (столбцов, строк) получается обрабатывать в этой библиотеке?


    1. pustota_2009
      04.11.2016 13:10

      Проект — макроэкономическое моделирование. Надеюсь вскоре будут результаты достойные публикации. Размер сеток на данный момент 200x70x15 (три слоя).


      1. Danov
        04.11.2016 19:44

        Продолжаю любопытствовать. А топология которая используется? MLP?


        1. pustota_2009
          05.11.2016 14:41

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