Java — язык программирования общего назначения. Общего назначения — значит можно писать почти любые программы. Вот я и попытался написать программу, которую обычно пишут на С или C++. Под катом я попытаюсь рассказать, как я декодировал спутниковые снимки с Метеор-М №2.


DSP in Java


Предисловие


Когда я впервые заинтересовался декодированием спутниковых сигналов, то был, прямо говоря, удивлён. Сейчас софт для декодирования сигналов выглядит так же, как и библиотеки общего назначения лет 20 назад. Каждый пишет, что хочет, в каком хочет формате и совершенно не заботится о результатах. Яркий тому пример — декодирование сигнала с Метеор-М. Обычно алгоритм получения картинки выглядит следующим образом:


  1. Записать сигнал
  2. Запустить SDR# с определёнными плагинами и настройками и демодулировать сигнал. На выходе получается бинарный файл.
  3. Запустить LRPToffLineDecoder и на вход передать бинарный файл, полученный ранее.
  4. Из LRPToffLineDecoder сохранить картинку куда-нибудь.

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


  1. Весь софт работает под Windows
  2. Нельзя запускать в headless режиме. Софт — это формочки с кнопочками.
  3. Невозможно получить обратную связь по приёму сигнала. Нет никаких метрик, по которым можно было бы оценить хорошо или плохо была принята картинка.

Из-за этого я забросил вэб формочки кровавого энтепрайза и начал долгое погружение в гремучий мир DSP. На полное декодирование сигнала у меня ушло 2 месяца свободного времени.


Разбор сигнала логически можно представить как две фазы:


  1. Демодуляция. Преобразование аудио-сигнала в информацию.
  2. Декодирование. Преобразование информации в понятный пользователю вид.

Демодуляция


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


Итак, нужно демодулировать QPSK сигнал на Java. То ли никому это неинтересно, то ли Java медленная, но я не нашёл ни одной библиотеки, которые бы это делали. Поэтому я взял существующий проект демодуляции QPSK сигнала для GNURadio и начал переписывать блоки на Java.


QPSK demodulation


Блоки демодуляции


WavFile source и Float to Complex. Чтение из .wav файла IQ сигнала. Значения записаны в файле последовательно. Сначала реальная часть, потом мнимая, потом реальная и так далее. В GNURadio есть свои типы данных и каждый блок рассчин для работы только с определёнными. Так как у нас QPSK модуляция, то нам нужно использовать комплексные числа. Здесь ничего сложного, так как в Java есть поддержка .wav файлов — AudioSystem.getAudioInputStream(inputStream).


Low Pass Filter. Это первый фильтр, который предназначен для фильтрации низких частот. Так как наш сигнал занимает 130Mhz, то нам надо отфильтровать лишние частоты.


lowpass


На картинке выше, частоты нашего сигнала обведены зелёным. Остальные частоты справа должны быть отфильтрованы. Это делается с помощью КИХ-фильтра. Для программиста это выглядит как:


  1. Взять последние N значений, текущее и положить в массив
  2. Перемножить получившийся массив с заранее заданным (вычисляется из ЛАФЧХ фильтра). По сути, перемножение одного вектора на другой.
  3. Все значения результирующего массива сложить.
  4. Это и будет результат.

AGC. Автоматическая регулировка усиления.


agc


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


Root Raised Cosine Filter. Используется для уменьшения Intersymbol interference. Если вкратце, то при получении сигнала одни уровни переданного сигнала могут накладываться на последующие. Чтобы выделить максимальный уровень сигнала между символами, применяют этот фильтр. Работает так же как и Low Pass Filter, но использует другую ЛАФЧХ


rrcf


Costas Loop — это алгоритм фазовой подстройки частоты. Для чего он нужен? Например, для того, чтобы корректировать доплеровское смещение частоты. Так как, мы точно знаем, что фаза у нас скачет по четырём уровням, то можно сравнивать значение двух разных фаз. Если оно отличается на дельта, то корректировать частоту. Это ярче всего видно на картинках внизу.


before costas loop


До коррекции частоты, у нас круг (почти). Это значит, что частота немного меняется каждый раз, и точки фазы не попадают в одно и то же место.


after costas loop


Здесь уже значительно лучше — видны 4 отчётливые точки фазы.


Clock recovery MM. Этот блок пытается восстановить шаг часов передатчика и отбрасывает все сэмплы, которые не относятся к изменению уровня. Примерная схема работы изображена ниже:


clock recovery in a nutshell


После того, как все лишние сэмплы выкинуты, получается хорошее QPSK созвездие.


clock recovery


Constellation Soft Decoder. Этот блок преобразует координаты в вероятность битов. Тут надо остановится поподробнее, так это очень важно в дальнейшем. Допустим все точки у которых координаты положительные будут отображаться в пару "00".


initial


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


hard decision


Если мы будем делать жёсткое решение, то координаты красной точки мы преобразуем в пару "00". При этом мы потеряем информацию о том, что точка-то почти "01" (в нижним квадранте). А эта информация на самом деле может помочь в декодировании. Есть алгоритмы, которые дают лучшие результаты, если передать эту информацию. Поэтому вместо того, чтобы принимать жёсткое решение, надо посчитать вероятность 0 и вероятность 1. Например 100% вероятность нахождения в правом верхнем квадранте будет 255,255. Использование мягких решений увеличивает поток данных в 8 раз, зато даёт более лучшие результаты при декодировании сигнала. Для того, чтобы посчитать мягкое решение, необходимо рассчитать расстояние между текущей точкой и каждой из точек созвездия.


Rail, Float to char, File sink. Эти блоки немного преобразуют результаты и записывают их в файл. Для моего декодера записывать результаты в промежуточный файл не надо. Но в целях отладки было крайне полезно сначала демодулировать сигнал из Java, а затем посмотреть может ли LRPToffLineDecoder извлечь картинку.


Декодирование


Итак, у нас есть поток 0 и 1 из которого нужно получить картинку. Для начала необходимо открыть официальную документацию, где неплохо описана структура пакетов. Некоторые моменты там были опущены, поэтому я открыл еще более официальную документацию.


Декодирование состоит из следующих этапов:


  • поиск в потоке бит синхромаркера. Каждый пакет начинается с него.
  • после того, как синхромаркер был найден, декодировать следующие 16384 бита алгоритмом Витерби
  • результат подвергнуть скрэмблированию, после чего
  • применить коды рида соломона и извлечь данные транспортного кадра
  • из последовательности транспортных кадров извлечь последовательность парциальных пакетов. Для тех, кто изучал модель OSI вопросов быть не должно.
  • из парциальных пакетов извлечь MCU формата JPEG
  • декодировать пикселы JPEG, используя коды Хаффмана и Run-length coding
  • правильно заполнить пикселами картинку с учётом пропущенных пакетов
  • наложить 3 канала друг на друга с учётом пропущенных пакетов

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


compare


К сожалению общая картина занимает 4500x2800px, поэтому я привожу сильно пожатую версию.


Оптимизации


А при чём тут Java? — скажут самые стойкие, кто смог дочитать до сюда.


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


Итак, я начну с декодирования. При запуске профайлера, ничего странного не обнаружилось:


  • одно ядро работает на полную мощность
  • самый горячий метод — это декодирование Витерби. Сложность этого алгоритма О(TS), где T — это длина массива данных, S — пространство состояний. В нашем случае у нас 8 (бит) 4 (00,01,10,11) = 32.

Помимо CPU, я запустил еще анализ аллокаций объектов. Оказывается, этот метод создаёт миллионы короткоживущих объектов. Они создают лишнюю нагрузку на GC и процессор. Посмотрим, можно ли это оптимизировать. Наивная реализация создаёт связный список для хранения решений каждого входящего бита:


    LinkedList<long[]> decisions = new LinkedList<long[]>();
    for (int i = 0; i < data.length; i += 2) {
        long[] d = new long[2]; //decision
        for (int b = 0; b < 32; b++) {
            d[b / 16] = ...;
        }
        ...
        decisions.add(d);
    }

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


    long[] decisions = new long[data.length];
    for (int i = 0; i < data.length; i += 2) {
        decisions[i + b / 16] = ...;
    }

В итоге, на одно декодирование создаётся один объект — массив decisions. ViterbiSoft можно ещё дальше оптимизировать. Например, зная то, что размер массива всегда одинаковый, создать long[] decisions в конструкторе и переиспользовать.


Ещё одним проблемным местом оказался класс FixedLengthTagger. Этот класс содержит скользящее окно. Оно работает следующим образом:


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

Наивная реализация использовала LinkedList и алгоритм движения окна был такой:

    window.offerLast(curByte);
    if (window.size() > packet_len) {
        window.removeFirst();
    }

На самом деле это очень накладная операция, так как на каждую операцию offerLast внутри LinkedList создаётся объект Node. Он будет содержать наш байт и иметь ссылки на следующий элемент списка и предыдущий. Однако, зная, что размер нашего окна фиксированный, его можно заменить на циклический массив:


    window[windowIndex] = curByte;
    windowIndex++;
    if (windowIndex >= window.length) {
        windowIndex = 0;
    }

Для того, чтобы правильно взять его содержимое надо скопировать данные до текущего указателя первыми и после текущего указателя последними. Как то так:


    byte[] packet;
    System.arraycopy(window, windowIndex, packet, 0, window.length - windowIndex);
    System.arraycopy(window, 0, packet, window.length - windowIndex, windowIndex);

Эти две оптимизации позволили уменьшить время декодирования файла с 4 минут до 20 секунд. Для сравнения LRPToffLineDecoder делает это за 40 секунд.


Декодирование можно ещё немного оптимизировать, но в ущерб читаемости. Вместо этого можно попробовать ускорить демодуляцию.


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


profiling demodulation


Тут мало что можно сделать: память не выделяется, безумных циклов нет. Единственное что интересно проверить это компилирует ли Java JIT код в SIMD инструкции. Если вкратце, то это специальные инструкции процессора, которые работают с 128битными регистрами и обрабатывают их одной командой. Схематично это выглядит так:


simd


Как видно, такие инструкции идеально ложаться на КИХ-фильтры. GNURadio использует библиотеку VOLK, которая в зависимости от поддерживаемой архитектуры, может использовать SIMD инструкции. В Java скорей всего вызывать JNI обёртку будет значительно затратнее, чем выигрыш от использования таких инструкций. Одна надежда на JIT, который может съоптимизировать перемножение одного массива на другой. Чтобы это проверить, необходимо запустить JVM с опцией "-XX:CompileCommand=print,*FIRFilter.filterComplex". Она заставит JVM выводить ассемблерный код для метода filterComplex из класса FIRFilter.


Вот что у меня получилось при запуске с Oracle JDK 1.8.0_161 на MacBook Air:


0x0000000115994750: vmovss 0x10(%r10,%r11,4),%xmm1
0x0000000115994757: vmulss 0x10(%rcx,%r11,4),%xmm1,%xmm4
0x000000011599475e: vmulss 0x10(%r8,%r11,4),%xmm1,%xmm1
0x0000000115994765: vaddss %xmm2,%xmm4,%xmm3
0x0000000115994769: vaddss %xmm0,%xmm1,%xmm0
0x000000011599476d: movslq %r11d,%r9
0x0000000115994770: vmovss 0x1c(%r10,%r9,4),%xmm2
0x0000000115994777: vmulss 0x1c(%rcx,%r9,4),%xmm2,%xmm8
0x000000011599477e: vmulss 0x1c(%r8,%r9,4),%xmm2,%xmm1
0x0000000115994785: vmovss 0x14(%r10,%r9,4),%xmm4
0x000000011599478c: vmulss 0x14(%rcx,%r9,4),%xmm4,%xmm2
0x0000000115994793: vmulss 0x14(%r8,%r9,4),%xmm4,%xmm5
0x000000011599479a: vmovss 0x18(%r10,%r9,4),%xmm4 ;*faload
; — ru.r2cloud.jradio.blocks.FIRFilter::filterComplex@24 (line 26)

Судя по всему SIMD инструкции не используются.


Ещё одним интересным местом стало вычисление sincos. Блок Costasloop вычисляет значение синуса и косинуса для одного и того же угла (фазы) для каждого входящего значения. В CPU есть специальная команда — fsincos. Она вычисляет одновременно синус и косинус угла. Однако, в Java такой функции нет. Да и реализовывать её непонятно как: надо либо возвращать double[] (а это сильно ударит по GC), либо возвращать одно значение как результат работы функции, а другое через изменяемый параметр (double в функцию Java передаётся копированием, а Double опять же ударит по GC). Наивная же имплементация выглядит так:


    float sinImg = (float) Math.sin(-d_phase);
    float cosReal = (float) Math.cos(-d_phase);

Можно попробовать вспомнить тригонометрию и заменить на:


    float sinImg = (float) Math.sin(-d_phase);
    float cosReal = (float) Math.sqrt(1 - sinImg * sinImg) + calculate sign;

Переход с Math.cos на Math.sqrt позволил сократить время демодуляции с 6 минут до 5 минут и 30 секунд. Тригонометрические операции можно ещё больше ускорить, если использовать таблицы поиска. Однако, я пока не готов исследовать зависимость результатов декодирования от точности таблиц. Может быть кто-нибудь поможет с этим?


Заключение


Поскольку я использовал одни и те же блоки для демодуляции сигнала, то можно сравнить время работы Java программы и GNURadio. Как и ожидалось, GNURadio быстрее: ~2 минуты против 5.5 минут. Да, Java почти в 2 раза медленее. Но, если учесть, что файл записывался в течении 15 минут, то производительности Java вполне хватит, чтобы в реальном времени демодулировать сигнал.

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


  1. Stecenko
    08.03.2018 18:48
    +1

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

    Upd. Сорри, автор написал что не готов это исследовать.


    1. dernasherbrezon Автор
      08.03.2018 19:02

      Да, там компромисс между точностью, объёму памяти (миллион float это ~3мб), и скоростью работы.


      В идеале точность надо определять в зависимости от разрядности ADC


      1. Stecenko
        08.03.2018 19:08

        Планируется отличное от писи железо? 4 Мб под такое дело не много, джава все равно сразу сотню под себя при запуске отбирает.


        1. dernasherbrezon Автор
          08.03.2018 19:13

          Я запускаю это под raspberrypi на урезанной версии Java. Сейчас heap занимает ~8мб, поэтому увеличится в полтора раза. 12мб опять же немного, так что надо подумать :)


    1. Suvitruf
      08.03.2018 19:08

      Сразу Doom вспоминается. Там всё это предвычислено в tables.c было.


      1. khim
        09.03.2018 10:24

        Doom в другую эпоху был. Процессоры были медленными, память быстрой…


    1. khim
      09.03.2018 10:23

      Прикрутить предвычисленную таблицу на миллион значений — доступ к массиву по индексу должен быть быстрее вычисления тригонометрической функции.
      Далеко не факт. Миллион значений — это уже L2. Туда уже не так быстро сходить.


      1. Stecenko
        09.03.2018 13:21
        +2

        Я набросал по-быстрому, у меня получилось, что у меня на core I5-3550, 8 Gb, jdk1.8.0_131,
        10^8 Math.sin выполнилось за 8.1 сек,
        10^8 MyMath.sin (по предрассчитанной таблице на миллион значений) выполнилось за 4.1 сек;

        Main
        public class Main {
            public static void main(String[] args) {
                final int TEST_SIZE = 100000000;
                double [] testArr =initTestArr(TEST_SIZE);
        
                System.out.println("прогрев " + MyMath.sin(Math.PI/3));
        
                long startTime;
                long endTime;
                double summ=0;
                startTime = System.currentTimeMillis();
                    for (int i=0; i<TEST_SIZE; i++){
                        summ += Math.sin(testArr[i]);
                    }
                endTime = System.currentTimeMillis();
                System.out.println("Math " + (endTime-startTime) + " " + summ );
        
                float summf = 0;
                startTime = System.currentTimeMillis();
                for (int i=0; i<TEST_SIZE; i++){
                    summf += MyMath.sin(testArr[i]);
                }
                endTime = System.currentTimeMillis();
                System.out.println("MyMath " + (endTime-startTime) + " " + summf);
        
            }
        
            private static double[] initTestArr(int size){
                double[] arr = new double[size];
                for (int i=0; i< size; i++){
                    arr[i]=Math.random()* Math.PI;
                }
                return arr;
            }
        }
        


  1. Ryppka
    08.03.2018 21:01
    +1

    Автору респект за проделанную работу, самому приходилось заниматься чем-то схожим в контексте обработки звука, надо было быстро добавить небольшой кусок в большую программу на Java.
    Но, имхо, считать «прогретая» Java должна не хуже C/C++, но только если заморачиваться с предаллокацией и статическими буферами, а тогда это не сильно проще, чем на тех же C/C++ все написать.


    1. crazy_llama
      09.03.2018 17:44

      По моему опыту, примерно эквивалентный код на прогретой java работает медленнее на 15-25%, и то не всегда. В данном примере (эта статья), думаю главная проблема в том, что код библиотеки уже устоявшийся и где-то оптимизированный (в отличие от ее порта). Поэтому разница такая.
      Но, в любом случае, как сказал автор — все равно хватает.


  1. zillant
    08.03.2018 22:50

    Вами проделана отличная работа. У меня появилось несколько вопросов. Получается, что вы сами алгоритмы синхронизации самостоятельно преобразовывали в код?
    Почему gnu radio, а не Matlab?


    1. dernasherbrezon Автор
      08.03.2018 23:13

      GNURadio уже много где используется. Например, SATNOGS и gqrx. А это значит, что любой кто знаком с этими системами может спокойно использовать блоки из моей библиотеки. Причём поскольку блоки бинарно совместимы, то можно брать параметры из GNURadio схем и подставлять в Java код — результат будет бинарно совместим (*)


  1. AIring
    09.03.2018 00:25

    А какой GC и с какими опциями использовали?


    1. dernasherbrezon Автор
      09.03.2018 00:27
      +1

      По умолчанию. После того как я уменьшил количество генерируемого мусора на порядки, у меня стало генерироваться примерно 7 объектов в секунду. Это достаточно мало, чтобы дальше оптимизировать.


  1. beeruser
    09.03.2018 04:13

    0x0000000115994757: vmulss 0x10(%rcx,%r11,4),%xmm1,%xmm4
    0x000000011599475e: vmulss 0x10(%r8,%r11,4),%xmm1,%xmm1
    0x0000000115994765: vaddss %xmm2,%xmm4,%xmm3
    0x0000000115994769: vaddss %xmm0,%xmm1,%xmm0

    Судя по всему SIMD инструкции используются.

    Это не SIMD, а скалярные инструкции AVX.

    vaddss — Scalar Single precision
    vaddps — Packed Single precision (вот это уже SIMD)


    1. dernasherbrezon Автор
      09.03.2018 10:39

      Обновил. Похоже меня смутили xmm регистры. А зачем JVM делает операции на этих регистрах если используется только младшие 32 бита?


      1. khim
        09.03.2018 11:02

        Не «зачем», а «почему». Потому что дизайнерам CPU показалось логичным, что модули, работающие с плавучкой, не будут использовать «целочисленные» регистры, потому доступа ни к каким других регистрам у них нет.


      1. khim
        09.03.2018 11:08

        Или вы хотели спросить почему %st не используется и x87? Тут скорее надо спросить почему они вообще в 64-битном режиме поддерживаются!

        Инженеры из AMD говорят, что они, всё-таки, позволяют использовать 80-битные числа, так что некоторый смысл в них есть… но ровно по этой же причине — они медленнее.

        Так что их не нужно использовать никогда, когда вы не используете 80-битную плавучку… то есть практически — никогда. Intel мечтает их вообще убить, но я думаю мечты так и останутся мечтами…


      1. beeruser
        09.03.2018 11:45

        Зачем? Трёхадресный код AVX проще и удобнее чем раритетный стековый FPU.


  1. Newen
    09.03.2018 10:39

    Simd всё-таки не используется, *ss это скалярные инструкции.


  1. yarston
    09.03.2018 13:46

    Деление довольно долгая операция, если важна скорость, лучше по возможности на сдвиг заменить.
    Набросал тут 3 варианта, замена деления и чатичное раскрытие цикла ускоряет его почти в 3 раза.

    public static void main(String[] args) {
            
            int iters = Integer.MAX_VALUE;
            long time = System.currentTimeMillis();
            long a = 0;
            for (int i = 0; i < iters; i++) {
                a += i / 16;
            }
            System.out.print((System.currentTimeMillis() - time) + "ms\n");
    
            time = System.currentTimeMillis();
            for (int i = 0; i < iters; i++) {
                a += i >> 4;
            }
            System.out.print((System.currentTimeMillis() - time) + "ms\n");
            
            iters /= 4;
            time = System.currentTimeMillis();
            for (int i = 0; i < iters; i++) {
                a += i >> 4;
                a += i >> 4;
                a += i >> 4;
                a += i >> 4;
            }
            System.out.print((System.currentTimeMillis() - time) + "ms\n");
        }
    

    Результат:

    2108ms
    1040ms
    720ms


    1. dernasherbrezon Автор
      09.03.2018 18:27

      2 Раза запустил декодирование реальной картинки с делением. Получил:
      43 и 36 секунд.


      Без деления:
      35 и 17 секунд.


      Разброс получился большой, поэтому я добавил JMH. Получившийся тест даёт:


      "b >> 4":
      Benchmark Mode Cnt Score Error Units
      ViterbiSoftBenchmark.plain thrpt 200 532.796 ± 12.010 ops/s


      "b / 16":
      Benchmark Mode Cnt Score Error Units
      ViterbiSoftBenchmark.plain thrpt 200 556.942 ± 6.103 ops/s


      То есть приближенные результаты. Есть идеи? Вот текущая версия — ViterbiSoft


      1. khim
        09.03.2018 18:59

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

        Так что… чего вы, собственно, хотите тут увидеть?


        1. dernasherbrezon Автор
          09.03.2018 19:02

          yarston увидел ускорение. Самое простое это запустить код и проверить.


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


          1. khim
            09.03.2018 19:20

            Превращеним деления в умножение занимается не JIT. Этим ещё компилятор должен озаботится на этапе компиляции в байткод.

            Может у него особый ольдскульный компилятор, я не знаю.


            1. yarston
              09.03.2018 19:33

              У меня openjdk version 1.8.0_151, вышла несколько месяцев назад, никак не лет.


              1. khim
                09.03.2018 20:11

                Тогда вообще неясно откуда у вас разница между сдвигами и делением. Есть ощущение, что вы неправильно «прогрели» вашу функцию или ещё как-то сглупили.

                Полного кода в вашем комментарии нет, так что «навскидку» сказать сложно.


                1. yarston
                  09.03.2018 20:36

                  Да, перепроверил, в прогреве дело было, идентично выходит.


  1. afiskon
    10.03.2018 12:24

    Спасибо за проделанный труд и статью, очень интересно.

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


    1. dernasherbrezon Автор
      10.03.2018 19:23

      Я проходил курс на курсере. Но это не поможет понять как работают алгоритмы. Скорее всего даст некоторое представление о математике процесса и теории.