Русский
Русский
English
Статистика
Реклама

Конвейер

Уязвимость Crosstalk

29.11.2020 16:21:40 | Автор: admin

В последние годы стало появляться большое количество сообщений о всякого рода уязвимостях в процессорах компании Intel. Самыми известными из них являются Spectre и Meltdown, основанные на ошибках в реализации спекулятивного исполнения команд. В июне 2020 года появилось сообщение о новой уязвимости, носящей название Crosstalk.

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

Спекулятивные вычисления

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

Конвейер

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

Как происходит межъядерное взаимодействие?

Существует набор инструкций процессора Интел семейства x86 с нетривиальным поведением. Такие инструкции состоят из нескольких операций, называемых микрокодом. Исследователи из Vrije Universiteit Amsterdam провели ряд экспериментов, в которых изучалась работа инструкций процессора с разными наборами аргументов. Оказывается, что в ряде случаев такой микрокод осуществляет операции чтения-записи вне ядра через внутренние шины процессора в регистры MDS (Model-Specific-Registers) с помощью операций RDMSR и WRMSR. Эти операции являются привилегированными и могут исполняться только операционной системой. Для userspace примерами таких инструкций являются CPUID, RDRAND и RDSEED.

Промежуточный буфер сохраняет результаты ранее выполненных инструкций, такие как сгенерированные DRNG случайные числа, хэши состояния bootguard и другие ценные данные. Чтение из промежуточного буфера с помощью Crosstalk позволяет получать доступ к данным, к которым обращался пользователь, и которые остались в этом буфере после выполнения таких запросов, как например RDRAND и RDSEED.

RDRAND и RDSEED

Инструкция RDRAND возвращает случайные числа, полученные от digital random number generator (DRNG), и может быть вызвана из пользовательского пространства. DRNG выводит случайные начальные состояния и передает их генератору случайных битов, который заполняет глобальную очередь случайных чисел. Инструкция RDSEED обеспечивает доступ к более качественному источнику энтропии, т.к. предназначена для программных RNG.

Внутренние буферы процессора

Забегая немного назад в списке уязвимостей, стоит отметить RIDL, которая позволяет создавать утечки информации из разных источников, таких как кэши и буферы процессора: Line Fill Buffer, Load Ports, Store Buffer.

Line Fill Buffer (LFB) используется для отслеживания кэш миссов L1 Cache (невыполненных запросов памяти) и передачи кэш-линий за пределами L1 Cache. Например, при кэш миссе, вместо блокировки кэша, операция помещается в LFB и обрабатывается асинхронно. Это позволяет кэшу обслуживать другие запросы. Промежуточный буфер получает данные от ядра из LFB.

Store Buffer отслеживает запросы на запись данных.

Load Ports используются конвейером процессора при загрузке данных из памяти или I/O операций. Когда выполняется микрокод загрузки, данные сначала сохраняются в Load Ports перед передачей в регистры.

Детектирование Crosstalk

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

FLUSH + RELOAD

inline int probe(char *adrs) {  volatile unsigned long time;  asm __volatile__ (    "  mfence             \n"    "  lfence             \n"    "  rdtsc              \n"    "  lfence             \n"    "  movl %%eax, %%esi  \n"    "  movl (%1), %%eax   \n"    "  lfence             \n"    "  rdtsc              \n"    "  subl %%esi, %%eax  \n"    "  clflush 0(%1)      \n"    : "=a" (time)    : "c" (adrs)    :  "%esi", "%edx");  return time;}

В качестве примера рассмотрим RIDL атаку с использованием LFB, выполняемую в четыре этапа. Сначала злоумышленник создает массив FLUSH + RELOAD, содержащий одно значение для каждой строки кэша (обычно байт) и выполняет операцию FLUSH, чтобы гарантировать, что ни одна из этих строк не находится в кэше. Затем злоумышленник предлагает программе-жертве прочитать или записать секретные данные или обеспечить удаление таких данных из кэша. В любом случае, процессор перемещает данные в LFB. Затем злоумышленник выполняет загрузку данных (операцию load), вызывающую исключение или pagefault. При этом, такая операция считается успешной, данные сохраняются в LFB. Затем спекулятивно исполняемый код злоумышленника использует данные, соответствующие индексу в массиве FLUSH + RELOAD. Соответствующая строка кэша будет загружена в кэш конвейером, когда он выполнит спекулятивный код. Наконец, загрузив каждый элемент массива и определив время загрузки, злоумышленник может определить, какой из них был в кеше. Индекс в кэше - это секретные данные, полученный из LFB.

CPUID

pid_t pid = fork();if (pid == 0) {    while (1)        asm volatile(            "mov %0, %%eax\n"            "cpuid\n"            ::"r"(CPUID_LEAF):"eax","ebx","ecx","edx");}for(size_t offset = BEGIN_OFFSET; offset < BEGIN_OFFSET + 4; ++offset) {    // ...    for(size_t i(0); i < ITERS; ++i) {        flush(reloadbuffer);        tsx_leak_read_normal(leak + offset, reloadbuffer);        reload(reloadbuffer, results);    }}

На представленном листинге показано, как осуществляется запрос на примере CPUID. Эта команда позволяет получить информацию о процессоре. Такого рода запросы называются MDS. К их числу относится упомянутый ранее RIDL. Запросы проводятся с разным смещением в разделяемом буфере. Смещение вызывает ошибку при чтении страницы, так как читаемый вектор захватывает границы страницы. Затем при помощи FLUSH + RELOAD можно получить данные, прочитанные во время выполнения инструкции. Таким образом, CPUID вызывает 4 запроса вне ядра, что говорит об успешной демонстрации CROSSTALK. В следующей таблице представлены результаты различных операций, реализуемых CROSSTALK

Замедление работы процессора

Одним из примеров атаки может служить замедление работы при запросах определенного рода ресурсов. Рассмотрим инструкцию RDSEED. Объем доступной энтропии всегда ограничен, причем RDSEED возвращает 0, если нет доступной энтропии. Неуспешный вызов RDSEED не перезаписывает содержимое промежуточного буфера. Таким образом, злоумышленник может потреблять доступную энтропию, самостоятельно выполняя запросы RDRAND и RDSEED, в то время как ядро-жертва не сможет получить достаточный объем энтропии для успешного завершения вызова RDSEED. С помощью такого рода запросов можно читать данные, записанные пользователем в разделяемый буфер. Когда запрос жертвы все же вернет положительный результат, данные в разделяемом буфере перезапишутся. Но в то же время, злоумышленник может уже прочитать данные, до завершения работы вызовов FLUSH + RELOAD.

Виртуальные машины

Если злоумышленники имеют возможность писать код только внутри виртуальной машины, то операции, позволяющие получать доступ к промежуточному буферу, ограничены. Например, обычно виртуальные машины запрещают пользователю выполнять вызов CPUID, чтобы не позволять пользователю получать информацию о возможностях виртуальной машины. Тем не менее, инструкции RDRAND и RDSEED могут быть исполнены из пространства пользователя, что создает уязвимость и для виртуальных машин. Примером может стать составление запросов гипервизору, а затем чтение из промежуточного буфера в LFB. Даже если процессор оснащен защитой от MDS атак, злоумышленник может получать содержимое разделяемого буфера из соседнего потока (hyperthread), раскрывая данные жертвы, работающей на другом ядре.

Устранение уязвимости

Компания Интел предоставила решение проблемы, которое заключается в блокировании шины данных перед обновлением промежуточного буфера и снятии блокировки только после завершения очистки содержимого буфера. Таким образом, одно из ядер не сможет читать инструкции, исполняемы на другом ядре. Такого рода блокировки, в свою очередь, создают большие задержки при работе с промежуточным буфером. Чтобы увеличить производительность операций записи в буфер, было предложено ограничиться блокировками буфера только для тех операций, которые представляют угрозу для безопасности данных, такие как рассмотренные ранее RDRAND, RDSEED и EGETKEY. В то же время, существует ряд команд, способных на чтение данных вне ядра, не регулируемых локами.

Выводы

Crosstalk представляет собой уязвимость нового рода, позволяющую злоумышленникам получать доступ к данным из промежуточного буфера, разделяемого несколькими ядрами. Предыдущие способы борьбы с MDS уязвимостями не позволяют бороться с такого вида атаками (а в ряде случаев, ухудшают ситуацию). Решение проблемы позволяет полностью ограничить доступ к данным, записанным другим ядром в промежуточный буфер, но создает дополнительные накладные расходы из-за блокирования буфера. Несмотря на то, что большинство современных процессоров Интел подвержены межъядерным атакам, компании не известно ни одного примера атаки за пределами лаборатории. В то же время, на серверных процессорах высокого уровня обеспечивается защита от такого рода угроз, и некоторые наиболее современные процессоры не подвержены атакам MDS.

Подробнее..

Шпаргалка по функциональному программированию

19.03.2021 10:13:30 | Автор: admin

Привет, меня зовут Григорий Бизюкин, я преподаватель Школы разработки интерфейсов и фронтенд-разработчик в Яндексе. Давайте поговорим о функциональном программировании в мире JavaScript. Мы все про ФП что-то слышали, нам всем оно интересно, но у меня, когда я искал полезные материалы для подготовки к лекциям, сложилось такое впечатление: есть куча статей, каждая из которых либо говорит об ФП общими словами, либо раскрывает отдельный маленький кусочек темы, чего, конечно, недостаточно.



Добавим функционального света


Впервые я попробовал обобщить в одном месте самые популярные и, как мне кажется, применимые приёмы функционального программирования в лекции для ШРИ. Потом захотелось расширить материал и рассмотреть ещё больше концепций. В результате получилась эта статья. В ней мы разберём всё самое сложное простым языком с понятными примерами. Надеюсь, вам будет интересно!


Оглавление

Функциональное программирование


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


Важно понимать, что когда на глаза попадается очередная статья Почему разработчик обязательно должен знать ФП, то автор, вероятно, говорит именно о нескольких подходах из мира ФП, которые можно применить у себя на проекте, чем о том, что вам пора пересесть за Haskell.


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


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


За и против


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


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


Императивный vs декларативный


Императивный подход говорит о том, как решать задачу, декларативный что хотим получить в результате.


В жизни мы, как правило, думаем о результате. Например, когда просим маму приготовить пиццу, делаем это декларативно: Мам, хочу пиццу! Если бы мы делали это императивно, то получилось бы что-то вроде: Мама, открой холодильник, достань тесто, возьми помидоры и т. д.


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


const array = [4, 8, 15, null, 23, undefined]// императивный подходconst imperative = []for (let i = 0, len = array.length; i < len; ++i) {    if (array[i]) {        imperative.push(array[i])    }}// декларативный подходconst declarative = array.filter(Boolean)

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


Причём если вам кажется, что декларативный стиль нечто новенькое, то спешу вас заверить это не так. Вы наверняка писали css-стили, где говорили, что именно хотите получить в результате:


/* css */.button {    color: azure;}

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


Такая же история и с SQL:


-- SQLSELECT titleFROM filmsWHERE rating > 9GROUP BY director

Запрос говорит о результате, а не о том, как именно его выполнить.


Функции и процедуры


Функция понятие, близкое к математическому. Она что-то получает на вход и всегда что-то возвращает.


const f = (x) => x * Math.sin(1 / x)

Процедура, в свою очередь, вызывается ради побочных эффектов:


const print = (...args) => {    const style = 'color: orange;'    console.log('%c' + args.join('\n'), style)}

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


В JS не существует процедур, потому что то, что мы считаем процедурой, на самом деле является функцией без return. Если опустить return, функция всё равно неявно возвращает undefined и остаётся функцией.


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


Параметры и аргументы


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


// x  параметр (почти любое число)const f = (x) => x * Math.sin(1 / x)// 0.17  аргумент (конкретное число)f(0.17)

Сигнатура


Количество, тип и порядок параметров. Объявление функции в JS не содержит информации о типе параметров из-за динамической типизации. Если не используется TypeScript, эту информацию можно указать через JSDoc.


/** * @param {*} value * @param {Function|Array<string>|null} [replacer] * @param {number|string|null} [space] * @returns {string} */function toJSON (value, replacer, space) {    return JSON.stringify(value, replacer, space)}

Арность


Арность количество параметров, которые принимает функция. В JavaScript арность функции можно определить при помощи свойства length.


const awesome = (good, better, theBest) => {}awesome.length // 3

У свойства length есть особенности, которые следует учитывать:


// аргументы по умолчаниюconst defaultParams = (answer = 42) => {}defaultParams.length // 0// остаточные параметрыconst restParams = (...args) => {}restParams.length // 0// деструктуризацияconst destructuring = ({target}) => {}destructuring.length // 1

Рекурсия


Когда функция вызывает саму себя, происходит рекурсивный вызов. Для его корректной работы необходимо, чтобы внутри функции было хотя бы одно рекурсивное условие, на которое мы обязательно рано или поздно выйдем. Если этого не произойдёт, программа зациклится.


function factorial (n) {    if (n <= 1) {        return 1    }    return n * factorial(n - 1)}

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


function factorial (n, total = 1) {    if (n <= 1) {        return total    }    return factorial(n - 1, n * total)}

Несмотря на заманчивые возможности, поддержка хвостовой рекурсии до сих пор отсутствует и вряд ли появится в будущем, поэтому сведения о ней носят чисто теоретический характер. Если добавить в самое начало функции console.trace, можно убедиться, что каждый новый вызов создаёт новый кадр в стеке, несмотря на то, что условия рекурсии выполняются. Более подробно об оптимизации хвостовых вызовов можно почитать здесь.


Функция первого класса


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


// присваиватьconst assign = () => {}// передаватьconst passFn = (fn) => fn()// возвращатьconst returnFn = () => () => {}

Функция высшего порядка


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


// map, filter, reduce и т.д.[0, NaN, Infinity].filter(Boolean)// обещанияnew Promise((res) => setTimeout(res, 300))// обработчики событийdocument.addEventListener('keydown', ({code, key}) => {    console.log(code, key)})

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


Предикат


Это функция, которая возвращает логическое значение. Самый распространённый пример использование предиката внутри функций filter, some, every.


const array = [4, 8, 15, 16, 23, 42]// isEven  это предикатconst isEven = (x) => x % 2 === 0const even = array.filter(isEven)

Замыкание


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


const createCounter = tag => count => ({    inc () { ++count },    dec () { --count },    val () {        console.log(`${tag}: ${count}`)    }})const pomoCounter = createCounter('pomo')const work = pomoCounter(0)work.inc()work.val() // pomo: 1const rest = pomoCounter(4)rest.dec()rest.val() // pomo: 3

В примере внутри замыкания хранятся две переменные: tag и count. Каждый раз, когда мы создаём новую переменную внутри другой функции и возвращаем её наружу, функция находит переменную, объявленную во внешней функции, через замыкание. Если тема замыканий кажется чем-то загадочным, почитайте о них подробнее в блоге HTML Academy.


Мемоизация


Полезный приём функция кеширует результаты своего вызова:


const memo = (fn, cache = new Map) => param => {    if (!cache.has(param)) {        cache.set(param, fn(param))    }    return cache.get(param)}const f = memo((x) => x * Math.sin(1 / x))f(0.314) // вычислитьf(0.314) // взять из кеша

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


Конвейер и композиция


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


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


Конвейер


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


# вывести идентификаторы процессов с подстрокой kernelps aux | grep 'kernel' | awk '{ print $2 }'

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


const double = (n) => n * 2const increment = (n) => n + 1// без конвейерного оператораdouble(increment(double(double(5)))) // 42// с конвейерным оператором5 |> double |> double |> increment |> double // 42

Если бы у нас была функция pipe, которая аналогичным образом организовывает поток данных, через неё можно было бы записать так:


pipe(double, double, increment, double)(5) // 42

Аргумент, переданный в конвейер, последовательно проходит слева направо:


// 5 -> 10 -> 20 -> 21 -> 42

Хм, а что если запустить конвейер в другую сторону?


Композиция


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


// композиция функций в чистом видеdouble(increment(double(double(5)))) // 42

Если записать то же самое через вспомогательную функцию compose, получится:


compose(double, increment, double, double)(5)

Внешне всё осталось почти так же, но место вызова функции increment изменилось, потому что теперь цепочка вычислений стала работать справа налево:


// 42 <- 21 <- 20 <- 10 <- 5

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


// оригинальная цепочка вызововone(two(three(x)))// более естественно с точки зрения чтенияpipe(three, two, one)(x)// более естественно с точки зрения записиcompose(one, two, three)(x)

Таким образом, конвейер и композиция это два направления одного потока данных.


Преимущества


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


Создание новых абстракций


Функции чем-то похожи на кубики Лего. Когда мы строим программу, она состоит из отдельных кубиков, причём каждый из них решает свою задачу.


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


Например, у нас есть два готовых кубика: получить слова из текста, оставить только уникальные слова:


// готовые кубикиconst words = str => str    .toLowerCase().match(/[а-яё]+/g)const unique = iter => [...new Set(iter)]const text = `Съешь ещё этих мягкихфранцузских булок, да выпей же чаю`const foundWords = words(text)const uniqueWords = unique(wordsFound)

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


function getUniqueWords (text) {    return unique(words(text))}const uniqueWords = getUniqueWords(text)

Сила композиции в том, что с её помощью можно создавать новые абстракции гораздо проще и удобнее:


// создаём новую деталь через композициюconst getUniqueWords = compose(unique, words)const uniqueWords = getUniqueWords(text)

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


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


const sort = iter => [...iter].sort()// новая деталь, которая пригодится для новых построекconst getSortedUniqueWords = compose(sort, getUniqueWords)const sortedUniqueWords = getSortedUniqueWords(text)

Если речь идёт о конструировании сложных деталей, вложенную композицию можно заменить на линейную:


// вложенная композицияcompose(sort, compose(unique, words))// линейная композицияcompose(sort, unique, words)

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


Бесточечный стиль


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


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


// стиль с параметрамиfunction getUniqueWords (text) {    return unique(words(text))}// стиль без параметров (бесточечный стиль)const getUniqueWords = compose(unique, words)

При работе со стилем без параметров функция не упоминает данные, которые мы обрабатываем.


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


Ограничения


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


const translate => (lang, text) => magicSyncApi(lang, text)const getTranslatedWords = compose(translate, unique, words)getTranslatedWords(text) // упс... что-то сломалось

Здесь на помощь приходит частичное примирение и каррирование, о которых мы поговорим позже.


Пишем сами


Реализовать конвейер можно было бы так:


const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x)

Чтобы реализовать композицию, достаточно заменить reduce на reduceRight:


const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x)

Как на практике?


На практике не так много случаев, где можно применить композицию. Кроме того, применимость ограничена отсутствием в JS встроенных механизмов нужно использовать библиотеки или самостоятельно реализовывать у себя необходимые функции. Но при этом, как ни странно, если вы используете на проекте Redux, ничего подключать не придётся, потому что композиция входит в состав библиотеки.


На проекте с Redux композиция наверняка будет использоваться для middleware, потому что createStore принимает только один усилитель (enhancer), а их, как правило, требуется хотя бы несколько.


// композиция в reduxconst store = createStore(    reducer,    compose(        applyMiddleware(...middleware),        DevTools.instrument(),    ))

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


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


const notifications = [    { text: 'Warning!', lang: 'en', closed: true },    { text: 'Внимание!', lang: 'ru', closed: false },    { text: 'Attention!', lang: 'en', closed: false }]// goodnotifications.filter((notification) => {    // ...проверить все условия})// betternotifications    .filter(isOpen)    .filter(isLang)// the bestcompose(    isLang,    isOpen)(notifications)

Частичное применение и каррирование


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


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


const sum = (x, y, z) =>    console.log(x + y + z)

Частичное применение


Преобразует функцию в одну функцию с меньшим числом параметров.


const partialSum = partial(sum, 8)partialSum(13, 21) // 42

Каррирование


Преобразует функцию в набор функций с единственным параметром.


const curriedSum = curry(sum)curriedSum(8)(13)(21) // 42

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


curriedSum(8, 13)(21) // 42curriedSum(8, 13, 21) // 42

В чём разница?


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


const partialSum = partial(sum, 42)partialSum() // NaN, потому что 42 + undefined + undefined

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


const curriedSum = curry(sum)curriedSum(8) // новая функция  sum(8)curriedSum(8)(13) // ещё одна новая функция  sum(8, 13)curriedSum(8)(13)(21) // 42, потому что набралось нужное число аргументов

Можно провести аналогию: вы делаете заказ в ресторане. Если использовать частичное применение, официант задаст вам только один вопрос о том, что вы хотите заказать, и если вы ответите хочу пиццу, то остальное он додумает сам и принесёт ту пиццу, которую посчитает нужной.


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


Решение задачи с композицией


Проблему с композицией из предыдущего примера при помощи частичного применения или каррирования можно решить вот так:


const translate => (lang, text) => magicSyncApi(lang, text)// через частичное применениеconst english = partial(translate, 'en')// через каррированиеconst english = curry(translate)('en')// создать новую деталь с возможностью переводаconst getTranslatedWords = compose(english, unique, words)getTranslatedWords(text) // теперь всё работает

Порядок данных


Частичное применение и каррирование чувствительны к порядку данных. Существует два подхода к порядку объявления параметров.


// сперва итерация, затем данные (iterate-first data-last)const translate => (lang, text) => /* */// сперва данные, затем итерация (data-first, iterate-last)const translate => (text, lang) => /* */

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


function flip (fn) {    return (...args) => fn(...args.reverse())}const curryRight = compose(curry, flip)const partialRight = compose(partial, flip)

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


Специализация


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


const fetchApi = (baseUrl, path) =>    fetch(`${baseUrl}${path}`)        .then(res => res.json())

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


// каррированиеconst fetchCurry = curry(fetchApi)const fetchUnsplash = fetchCurry('https://api.unsplash.com')const fetchRandomPhoto = fetchUnsplash(fetchApi, '/photos/random')// частичное применениеconst fetchUnsplash = partial(fetchApi, 'https://api.unsplash.com')const fetchRandomPhoto = partial(fetchUnsplash, '/photos/random')

Пишем сами


Свою версию частичного применения можно написать примерно так:


function partial (fn, ...apply) {    return (...args) => fn(...apply, ...args)}

Каррирование выглядит немного сложнее:


function curry (fn) {    return (...args) => args.length >= fn.length ?        fn(...args) : curry(fn.bind(null, ...args))}

Как на практике?


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


А ещё в JavaScript у функций есть метод .bind, который реализует частичное применение из коробки, поэтому, если порядок параметров позволяет, то вуаля:


const fetchApi = (baseUrl, endpoint) =>    fetch(`${baseUrl}${endpoint}`)        .then(res => res.json())const fetchUnsplash = fetchApi.bind(null, 'https://api.unsplash.com')const fetchRandomPhoto = fetchUnsplash.bind(null, '/photos/random')

Неизменяемые данные


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


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


// mutable glassconst takeGlass = (volume) => ({    look () { console.log(volume) },    drink (amount) {        volume = Math.max(volume - amount, 0)        return this    }})const mutable = takeGlass(100)mutable.drink(20).drink(30).look() // 50mutable.look() // 50

С неизменяемыми структурами данных совершенно другая история. Перед тем, как сделать глоток, создаётся точная копия стакана, и мы пьём уже из копии. Таким образом, после первого глотка у нас будет два стакана: один полный, из другого мы немного отпили. Исходный стакан останется без изменений. Это и есть неизменяемые данные.


// immutable glassconst takeGlass = (volume) => ({    look () { console.log(volume) },    drink (amount) {        return takeGlass(Math.max(volume - amount, 0))    }})const immutable = takeGlass(100)immutable.drink(20).drink(30).look() // 50immutable.look() // 100

Преимущества неизменяемых структур данных:
предсказуемое изменение состояния,
быстрое сравнение по ссылке,
кешируемость,
легко распараллеливать,
легко тестировать.


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


Нечаянное мутирование данных


В JavaScript запросто можно нечаянно мутировать массив или любой другой объект:


function sortArray (array) {    return array.sort()}const fruits = ['orange', 'pineapple', 'apple']const sorted = sortArray(fruits)// упс... исходный массив тоже изменилсяconsole.log(fruits) // ['apple', 'orange', 'pineapple']console.log(sorted) // ['apple', 'orange', 'pineapple']

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


const object = {}// const означает константную ссылкуobject = {} // TypeError: Assignment to constant variable// но сам объект можно беспрепятственно изменятьobject.value = 42 // мутация объекта

Все ссылочные типы: объекты, массивы и другие всегда передаются по ссылке. Во время присваивания или передачи параметра происходит копирование ссылки, но не самих данных.


const array = []// копия ссылкиconst ref = arrayref.push('apple')// ещё одна копия ссылкиconst append = (ref) => {   ref.push('orange')}append(array)// массив дважды мутирован через ссылкуconsole.log(array) // [ 'apple', 'orange' ]

А что если применить средства метапрограммирования и, например, заморозить объект? В этом случае мы всё равно сможем изменить вложенные объекты по ссылке.


const object = { val: 42, ref: {} }const frozen = Object.freeze(object)// игнорирование ошибки без 'use strict'// или же TypeError: Cannot assign to read only property...frozen.val = 23// мутирование вложенных данных по ссылкеfrozen.ref.boom = 'woops'console.log(frozen) // { val: 42, ref: { boom: 'woops' }

Вместо заморозки можно воспользоваться Proxy API, но в этом случае тоже придётся дополнительно обрабатывать вложенные структуры, потому что они всё ещё открыты для изменений.


const object = { val: 42, ref: {} }const proxy = new Proxy(object, {    set () { return true },    deleteProperty () { return true }})// изменение или удаление свойства не сработаетproxy.val = 19delete proxy.val// точно так же, как и добавление новогоproxy.newProp = 23// но вложенные объекты всё ещё мутабельныproxy.object.boom = 'woops'console.log(proxy) // { value: 42, ref: { boom: 'woops' } }

В общем, в JavaScript нельзя просто так взять и защитить данные от непреднамеренного изменения.


Затраты на копирование


С копированием данных тоже не всё так просто. В большинстве случаев работает копирование массивов и объектов встроенными средствами JavaScript:


const array = [4, 8, 15, 16, 23]const object = { val: 42 }// создать новый объект или массив[].concat(array)Object.assign({}, oject)// но через деструктуризацию удобнее[...array]{...object}

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


const object = { val: 42, ref: {} }const copy = { ...object }copy.val = 23copy.ref.boom = 'woops'console.log(object) // { val: 42, ref: { boom: 'woops' }

Такая же история с функциональными методами массивов map и filter создают поверхностную копию исходного массива.


const array = [null, 42, {}]const copy = array.filter(Boolean)copy[0] = 23copy[1].boom = 'woops'console.log(array) // [ null, 42, { boom: 'woops' } ]console.log(copy) // [ 23, { boom: 'woops' }

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


The problems of shared mutable state and how to avoid them
What is the most efficient way to deep clone an object in JavaScript?


Неизменяемые структуры данных (persistent data structures)


Итак, с неизменяемостью в JavaScript всё сложно, но мы можем обойти существующие ограничения при помощи специальных структур данных. Если взять библиотеку, которая реализует неизменяемые структуры и воспользоваться ей у себя на проекте, мы получим два преимущества. Во-первых, будет гораздо сложнее нечаянно мутировать данные, потому что библиотека каждый раз самостоятельно создаёт копии. Во-вторых, под капотом, скорее всего, будут разного рода оптимизации для более эффективного копирования данных, как, например, копирование при записи, когда во время чтения данных используется общая копия, а в случае изменения создаётся новый объект.


Пожалуй, две самые популярные библиотеки в мире фронтенд разработки это Immutable и Immer. При помощи Immer мы можем сделать вот что:


import produce from 'immer';const object = { ref: { data: {} } };const immutable = produce(object, (draft) => {  draft.ref.boom = 'woops';});console.log(object) // { ref: { data: {} }console.log(immutable) // { ref: { data: {}, boom: 'woops' }console.log(object.ref.data === immutable.ref.data) // true

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


Как на практике?


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


const addTodo = (state = initState, action) => {    switch (action.type) {        case ADD_TODO: {            return {                ...state,                todos: [...state.todos, action.todo]            }        }        default: {            return state;        }    }}

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


import produce from 'immer'const addTodo = (state = initState, action) =>    produce(state, draft => {        switch (action.type) {            case ADD_TODO: {                draft.todos.push(action.todo)                break            }        }    })

Чистые функции (pure functions)


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


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


Побочные эффекты (side effects)


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


function impure () {    // логирование    console.log('side effects')    // запись в файл    fs.writeFileSync('log.txt', `${new Date}\n`, 'utf8')    // запрос на сервер и т. д.    fetch('/analytics/pixel')}

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


Работа с глобальными переменными тоже побочный эффект.


function impure () {    // глобальная переменная    app.state.hasError = true}

Как правило, изменение глобальных значений непосредственно влияет на текущее состояние приложения, в то время как операции ввода/вывода меняют что-то за пределами приложения. Но в веб-разработке всё вращается вокруг DOM. Это не только доступы и изменение глобальных переменных, но ещё и операции ввода/вывода. Получается, что фронтенд один сплошной побочный эффект. Другими словами, фронтенд замечателен тем, что совмещает в себе всё самое лучшее.


function impure () {    // модификация DOM    document.getElementById('menu').hidden = true    // установка обработчика    window.addEventListener('scroll', () => {})    // запись в локальное хранилище    localStorage.setItem('status', 'ok')}

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


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


function impure (o) {    return Object.defineProperty(o, 'mark', {        value: true,        enumerable: true,    })}const object = {}const marked = impure(object)// defineProperty мутировала исходный объектconsole.log(object) // { mark: true }

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


Зависимость от параметров


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


function impure () {    // глобальная переменная    if (NODE_ENV === 'development') { /* */ }    // чтение данных из DOM    const { value } = document.querySelector('.email')    // обращение к локальному хранилищу    const id = localStorage.getItem('sessionId')    // чтение из файла и т. д.    const text = fs.readFileSync('file.txt', 'utf8')}

Внешние зависимости можно заменить на зависимость от параметров.


Непредсказуемый результат


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


function impure (min, max) {    return Math.floor(Math.random() * (max - min + 1) + min)}impure(1, 10) // 4impure(1, 10) // 2

Чтобы сделать функцию чистой, достаточно вынести неопределённость за пределы функции и передать её в качестве параметра. Например, вот так:


function pure (min, max, random = Math.random()) {    return Math.floor(random * (max - min + 1) + min)}pure(1, 10, 0.42) // 5pure(1, 10, 0.42) // 5

Теперь функция всегда возвращает один и тот же результат для одних и тех же параметров.


Преимущества чистых функций


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


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


const refTransparency = () =>    Math.pow(2, 5) + Math.sqrt(100)// вызов функцииrefTransparency()// можно раскрытьMath.pow(2, 5) + Math.sqrt(100)// и без особых трудностей понять результат32 + 10 // 42

Так почему бы всё не написать на чистых функциях?


Абсолютная и относительная чистота


Если взять и написать программу только из чистых функций, то получится:


(() => {})() // абсолютная чистота

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


// побочные эффекты выносятся за пределыconst text = fs.readFileSync('file.txt', 'utf8')// функция получает нужные данные только через параметрыfunction pure (text) {    // ... чистота}

Кроме того, чистота относительна. Функция ниже чистая или нет?


// pure или impure?function circleArea (radius) {    return Math.PI * (radius ** 2)}

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


Заключение


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


В статье мы рассмотрели базовые концепции ФП, однако на этом всё не заканчивается. Если у вас есть желание погружаться в тему дальше, советую почитать:


Жаргон функционального программирования
Functional-Light JavaScript
Mostly adequate guide to Functional Programming


Кроме того, загляните в репозиторий Awesome FP JS, вдруг найдёте что-то интересное для себя. Если же захочется целиком погрузиться в функциональную парадигму, но при этом продолжать разрабатывать фронтенд, можно посмотреть в сторону Ramda или Elm.


Спасибо за внимание!

Подробнее..

Конвейер уникальная система мониторинга и управления для конвейерного производства

31.03.2021 14:23:38 | Автор: admin

Продуктовая компания Академпарка Сибирь Телематика получила поддержку по одному из самых крупных конкурсов Фонда содействия инновациям Развитие-Цифровые технологии в размере 10 млн рублей. Средства пошли на разработку и создание программно-аппаратного комплекса мониторинга и управления технологическими процессами конвейерного производства.

Компания Сибирь Телематика с 2017 года ведет научно-исследовательские и опытно-конструкторские работы по созданию технологической платформы программно-аппаратного комплекса Конвейер. Решение призвано эффективного управлять непрерывными конвейерными производствами, используя технологии Индустрия 4.0.

В 2019 году разработку поддержал Фонд содействия инновациям в рамках конкурса Развитие-ЦТ. Благодаря этому команда создала и запустила платформу, объединяющую автономно управляемые технологические участки конвейерного производства в единую систему. Кроме того, грантовая поддержка позволила существенно расширить штат разработчиков и создать уникальное, с точки зрения технической архитектуры, решение, не имеющие аналогов в мире.

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

На сегодняшний день все большую значимость приобретают решения в области цифровизации производственных процессов в различных отраслях промышленности, в первую очередь в сфере обрабатывающих производств. Особенно актуальными являются задачи по снижению себестоимости выпускаемой продукции, уменьшению объема потерь и брака при производстве. Мы, как компания, которая изначально занималась решением индивидуальных заказных задач, направленных на оптимизацию производственных процессов, искали возможности для масштабирования создания коробочного продукта. Благодаря Фонду содействия инновациям и его представительству в Академпарке, у нас это получилось, отметил Иван Корсуков, директор компании Сибирь Телематика.

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

За счет внедрения новых технических решений, в том числе, программного-аппаратного комплекса Конвейер от ООО Сибирь Телематика, производство стеклотары в сравнении с аналогичным периодом прошлого года выросло: на 6% в тоннах со 133,2 тысяч до 141,7 тысяч, и на 10% в штуках с 437 млн до 480 млн, отметил исполняющий обязанности генерального директора ООО Сибирское стекло Антон Мор.

Кроме того, Сибирь Телематика стала профильной организацией в ассоциации заводов-производителей стекла СтеклоСоюз России и в течении ближайших трех лет планирует внедрить ПАК Конвейер на шести крупных стекольных заводах России, Белоруссии и Казахстана. Общая сумма будущих контрактов составит около 350 млн рублей.

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

Справка:

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

Подробнее..

Перевод Как писать аккуратные конвейеры для машинного обучения

17.08.2020 18:04:48 | Автор: admin
Здравствуйте, Хабр.

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


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

Сначала давайте дадим определение конвейерам машинного обучения и обсудим идею использования контрольных точек между этапами конвейера. Затем посмотрим, как можно реализовать такие контрольные точки, чтобы не выстрелить себе в ногу при переводе конвейера в продакшен. Мы также обсудим потоковую передачу данных и компромиссы, связанные с инкапсуляцией в духе объектно-ориентированного программирования (ООП), на которые приходится идти в конвейерах при указании гиперпараметров.

ЧТО ТАКОЕ КОНВЕЙЕР?

Конвейер это последовательность шагов при преобразовании данных. Он создается в соответствии со старинным паттерном проектирования канал и фильтр (вспомните, например, команды unix bash с каналами | или операторами редиректа >). Однако конвейеры это объекты в коде. Следовательно, у вас может быть класс для каждого фильтра (то есть, для каждого этапа конвейера), а также еще один класс для комбинации всех этих этапов в готовый конвейер. Некоторые конвейеры могут объединять другие конвейеры последовательно или параллельно, иметь много входов или выходов и т.д. Конвейеры машинного обучения удобно рассматривать как:

  • Канал и фильтры. На этапах конвейера обрабатываются данные, причем, этапы управляют своим внутренним состоянием, которое можно узнать из данных.
  • Компоновка. Конвейеры можно вкладывать друг в друга; например, целый конвейер можно трактовать как один этап в рамках другого конвейера. Эта конвейера не обязательно является конвейером, но конвейер как таковой по определению не менее чем этап конвейера.
  • Ориентированные ациклические графы (DAG). Вывод этапа конвейера может направляться множеству других этапов, после чего результирующие выводы могут рекомбинироваться и так далее. Отметим: несмотря на то, что конвейеры ацикличны, они могут обрабатывать множество элементов один за другим, и, если их состояние меняется (например, при использовании метода fit_transform на каждом этапе), то их можно считать рекуррентно разворачивающимися во времени, сохраняющими при этом свои состояния (по образцу RNN).Это интересный ракурс, позволяющий рассматривать конвейеры как средство для онлайнового машинного обучения, после чего конвейеры можно переводить в продакшен и обучать на более обширных данных.


Методы конвейера

Конвейеры (или этапы конвейера) обязательно должны обладать следующими двумя методами:

  • fit для обучения на данных и приобретения состояния (напр., таким состоянием являются веса нейронной сети)
  • transform (или predict) для фактической обработки данных и генерации прогноза.
  • Примечание: если этапу конвейера не требуется один из этих методов, то этап может унаследовать от NonFittableMixin или NonTransformableMixin, где будет по умолчанию предоставляться такая реализация одного из этих методов, чтобы он ничего не дела.


На этапах конвейера также могут опционально определяться следующие методы:

  • fit_transform для подгонки и последующего преобразования данных, но в один проход, что допускает потенциальную оптимизацию кода в случаях, когда два метода должны выполняться непосредственно один после другого.
  • setup который будет вызывать метод setup на каждом из таких этапов конвейера. Например, если на этапе конвейера содержится нейронная сеть TensorFlow, PyTorch или Keras, то на этих этапах могли бы создаваться собственные нейронные графы и регистрироваться для работы с GPU в методе setup до подгонки. Не рекомендуется создавать графы прямо в конструкторах этапов до подгонки; на то есть несколько причин. Например, до запуска этапы могут многократно копироваться с разными гиперпапарметрами в рамках работы алгоритма Automatic Machine Learning, который подыскивает для вас наилучшие гиперпараметры.
  • teardown, этот метод функционально противоположен setup: он сносит ресурсы.


Следующие методы предоставляются по умолчанию, обеспечивая управление гиперпараметрами:

  • get_hyperparams возвращает словарь гиперпараметров. Если конвейер содержит другие (вложенные) конвейеры, то ключи гиперпараметров сцепляются при помощи двойных нижних подчеркиваний __.
  • set_hyperparams позволяет задавать новые гиперпараметры в том же формате, в каком вы их получаете.
  • get_hyperparams_space позволяет вам получить пространство гиперпараметра, которое будет непустым, если вы определили гиперпараметр. Поэтому, все отличие от get_hyperparams в данном случае таково, что вы получаете в качестве значений статистические распределения, а не точное значение. Например, один гиперпараметр, для количества слоев, может быть RandInt(1, 3) то есть, предусматривать от 1 до 3 слоев. Можно вызвать call .rvs() с этим словарем, чтобы случайным образом выбрать значение и отправить его к set_hyperparams, попытавшись таким образом организовать обучение.
  • set_hyperparams_space может использоваться для задания нового пространства при помощи тех же классов для распределения гиперпараметров, что и в случае с get_hyperparams_space.


Переподгонка конвейера, мини-батчинг и онлайновое обучение

Для алгоритмов, использующих мини-батчинг, например, при обучении глубоких нейронных сетей (DNN) или для алгоритмов, обучающихся онлайн, например, при обучении с подкреплением (RL), для конвейеров или их этапов идеально подходит сцепление нескольких вызовов так, чтобы они следовали точно друг за другом, и на лету происходила их подгонка под размеры мини-батчей. Такая возможность поддерживается в некоторых конвейерах и на некоторых этапах конвейеров, но на определенном этапе достигнутая подгонка может сброситься из-за того, что метод fit будет вызван заново. Все зависит от того, как вы запрограммировали ваш этап конвейера. В идеале этап конвейера должен сбрасываться только после вызова метода teardown, а затем повторного вызова метода setup до следующей подгонки, и данные не сбрасывались ни между подгонками, ни в процессе преобразования.

ИСПОЛЬЗОВАНИЕ КОНТРОЛЬНХ ТОЧЕК В КОНВЕЙЕРАХ

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

За и против использования контрольных точек в конвейерах:

  • Расстановка контрольных точек может ускорить процесс работы, если этапы программирования и отладки расположены в середине или в конце конвейера. Так отпадает необходимость каждый раз заново вычислять первые этапы конвейера.
  • При выполнении оптимизации гиперпараметров (либо путем настройки вручную, либо с использованием метаобучения), вы будете только рады обойтись без пересчета первых этапов конвейера, пока занимаетесь настройкой следующих. Например, если начало вашего конвейера не содержит гиперпараметров, то оно всякий раз может быть одинаковым или почти одинаковым если гиперпараметров всего несколько. Следовательно, при работе с контрольными точками целесообразно возобновлять работу именно с тех мест, где они расставлены, если гиперпараметры и исходный код этапа, предшествующего контрольной точке, не изменились с момента последнего выполнения.
  • Возможно, вы располагаете ограниченными вычислительными ресурсами, и единственный приемлемый вариант для вас прогонять по одному этапу за раз на имеющемся аппаратном обеспечении. Можно использовать контрольную точку, затем добавить после нее еще несколько этапов, а потом данные будут использоваться с того места, на котором вы остановились, если вы захотите повторно выполнить всю структуру.


Недостатки использования контрольных точек в конвейерах:

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

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

В Computer Science есть всего две по-настоящему сложные вещи: инвалидация кэша и именование сущностей. Фил Карлтон


Совет о том, как правильно управлять состоянием и кэшем в конвейерах.

Известно, что фреймворки для программирования и паттерны проектирования могут выступать ограничивающим фактором по той простой причине, что регламентируют определенные правила. Остается надеяться, что это делается ради максимального упрощения задач по управлению кодом, чтобы вы сами избегали ошибок, а ваш код не получался грязным. Вот мои пять копеек по поводу проектирования в контексте конвейеров и управления состоянием:

ЭТАП КОНВЕЙЕРА НЕ ДОЛЖН УПРАВЛЯТЬ РАССТАНОВКОЙ КОНТРОЛЬНХ ТОЧЕК В ТЕХ ДАННХ, КОТОРЕ ВДАЮТ


Для управления этим должна применяться специальная библиотека конвейеризации, которая сможет все это сделать за вас сама.

Почему?

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

  • У вас будет простой выключатель, который позволит с легкостью полностью активировать или отменить расстановку контрольных точек перед развертыванием сделанного в продакшен.
  • Когда требуется переучить систему на новых данных, окажется, что управление кэшированием поставлено настолько хорошо, что система сама заметит: ваши данные изменились и, следовательно, имеющийся кэш следует игнорировать. Вашего вмешательства при этом совершенно не потребуется, что позволит не допустить возникновения серьезных багов.
  • Вам не придется самостоятельно иметь дело с дисками и писать операции ввода/вывода (I/O) на каждом этапе конвейера. Большинство программистов предпочитают пользоваться алгоритмами машинного обучения и строить конвейеры, а не заниматься созданием методов сериализации данных. Будем честны: вы же хотите просто запрограммировать готовенькие алгоритмы, а все остальное чтобы было сделано за вас. Верно?
  • Теперь вы можете придумывать названия для каждого из ваших конвейерных экспериментов или каждой итерации, так, чтобы при каждом рестарте в кэше создавался новый подкаталог строго на данный случай даже если вы собираетесь переиспользовать одни и те же этапы конвейера. Причем, именовать этапы экспериментов даже не требуется, поскольку с изменением данных меняется и кэширование.
  • Внутренний код классов, описывающих этапы вашего конвейера, хэшируется, после чего хэши сравниваются, чтобы посмотреть, нужно ли заново выполнить кэширование для того класса, в котором вы только что изменили код. Именно так избегаются баги, связанные с инвалидацией кэша. Ура.
  • Теперь вы можете хэшировать промежуточные результаты обработки данных и пропускать этап вычисления конвейера на этих данных, если гиперпраметры не изменились, а ваш конвейер уже преобразовал (и, следовательно, хэшировал) данные ранее. Это может упростить тонкую настройку гиперпараметров в случаях, когда некоторые этапы конвейера (в том числе, промежуточные) могут меняться. Например, первые этапы конвейера могут оставаться кэшированными, поскольку изменения их не затрагивают, а, если у вас появятся дополнительные гиперпараметры, которые потребуется настроить на дальнейших этапах конвейера, то вы всегда сможете добавить нужное количество контрольных точек после этих этапов. Тогда полученные в результате многократного кэширования этапы сохраняются с уникальным именем, вычисленным на основе хэша. Можете считать такую систему блокчейном, так как это и есть блокчейн.


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

Не врезайтесь в эту стену.

ПОТОКОВАЯ ПЕРЕДАЧА ДАННХ В КОНВЕЙЕРАХ ДЛЯ МАШИННОГО ОБУЧЕНИЯ

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

Не поймите меня неправильно, работать с конвейерами scikit-learn очень приятно. Но они не рассчитаны на потоковую передачу. Не только scikit-learn, но и большинство существующих конвейерных библиотек не используют возможностей потоковой передачи данных, тогда как могли бы. В масштабах всей экосистемы Python есть проблемы с многопоточностью. В большинстве конвейерных библиотек каждый этап является полностью блокирующим и требует преобразования всех данных сразу. Найдутся лишь считанные библиотеки, обеспечивающие потоковую обработку.

Активировать потоковую обработку может быть совсем просто: использовать класс StreamingPipeline вместо Pipeline для сцепления этапов одного за другим. При этом указывается размер мини-батча и размер очереди (во избежание чрезмерного потребления RAM, так обеспечивается более стабильная работа в продакшене). В идеале такая структура также потребовала бы многопоточных очередей с семафорами, как это описано в задаче поставщика и потребителя: чтобы организовать передачу информации от одного этапа конвейера к другому.

В нашей компании Neuraxle уже удается делать одну вещь лучше, чем она реализована в scikit-learn: речь идет о последовательных конвейерах, которыми можно пользоваться при помощи класса MiniBatchSequentialPipeline. Пока эта штука не многопоточная (но это в планах). Как минимум, она уже передает данные в конвейер в виде мини-батчей в процессе подгонки или преобразования, до сбора результатов, что позволяет работать с большими конвейерами точно как в scikit-learn, но на этот раз с применением мини-батчинга, а также с многочисленными другими возможностями, среди которых: пространства гиперпараметров, установочные методы, автоматическое машинное обучение и так далее.

Наше решение параллельной потоковой обработки данных на Python

  • Метод подгонки и/или преобразования можно вызывать много раз подряд, чтобы улучшить подгонку при помощи новых мини-батчей.
  • Многопоточные очереди внутри конвейера используются так, как в проблеме поставщика-потребителя. Между любыми двумя этапами конвейера, передаваемыми по потоковому принципу, нужна одна очередь.
  • Можно обеспечить параллельную репликацию этапов конвейера, чтобы на каждом этапе параллельно преобразовывать множество элементов. Это можно делать до того, как по всему конвейеру будут вызваны методы setup. В противном случае конвейер необходимо сериализовать, клонировать и перезагрузить с использованием механизмов, сохраняющих этапы. Код, использующий TensorFlow, и иной импортированный код, написанный на других языках, например, на C++, сложно распределить на потоки в Python, особенно если он использует память GPU. Даже joblib не так легко справляется с некоторыми из таких проблем. Поэтому благоразумно избегать подобных проблем при помощи грамотной сериализации.
  • Параметр конвейера может быть важен, а может быть и не важен для поддержания данных в правильном порядке перед отправкой их на следующий этап. По умолчанию он важен, а если нет то конвейер может продолжать обработку данных в произвольном порядке, по мере поступления, причем, так и бывает, если на разные этапы требуется разное количество времени.
  • Будет можно использовать барьерные объекты между этапами конвейера. Они будут представлять собой не настоящие этапы, а указания конвейеру, как обращаться с данными между этапами; например, должны или нет данные сохранять определенный порядок в заданных ключевых местах. Например, можно использовать барьеры, предусматривающие соблюдение порядка, не предусматривающие соблюдения порядка, либо дожидающиеся всех данных блокирующие барьеры (мы назвали такой барьер Joiner). Эти барьеры добавляют информацию о том, как обрабатывать данные между этапами или группами этапов. Например, на конкретном этапе я могу задавать или переопределять длину очереди и указывать, сколько раз нужно параллельно прогнать этап конвейера, как распараллелить этот этап.


Более того, мы хотим обеспечить возможность разделения между потоками любого объекта в Python: так он будет поддаваться сериализации и перезагрузке. В таком случае код можно будет динамически отправлять на обработку на любом воркере (это может быть другой компьютер или процесс), даже если сам нужный код на этом воркере отсутствует. Это делается при помощи цепочки сериализаторов, специфичных для каждого класса, воплощающего этап конвейера. По умолчанию на каждом из этих этапов есть сериализатор, позволяющий обрабатывать обычный код на Python, а для более заковыристого кода применять GPU и импортировать код на других языках. Модели просто сериализуются при помощи своих сейверов, а затем заново загружаются в воркер. Если воркер локальный, то объекты могут быть сериализованы на диск, расположенный в RAM, или в каталог, монтированный в RAM.

КОМПРОМИСС ПРИ ИНКАПСУЛЯЦИИ

Остается еще одна досадная вещь, присущая большинству библиотек для конвейерного машинного обучения. Речь о том, как обрабатываются гиперпараметры. Возьмем для примера scikit-learn. Пространства гиперпараметров (они же статистические распределения значений гиперпараметров) часто должны указываться вне конвейера с нижними подчеркиваниями в качестве разделительных знаков между этапами конвейера (конвейеров). Тогда как Случайный Поиск и Поиск по сетке позволяют исследовать сетки гиперпараметров или пространства вероятностей гиперпараметров, как это определяется в дистрибутивах scipy, сама scikit-learn не предоставляет пространства гиперпараметров по умолчанию для каждого классификатора и преобразователя. Ответственность за выполнение этих функций можно возложить на каждый из объектов конвейера. Таким образом, объект будет самодостаточен и будет содержать собственные гиперпараметры. Так не нарушается принцип единственной ответственности, принцип открытости/закрытоcти и принципы SOLID объектно-ориентированного программирования.

СОВМЕСТИМОСТЬ И ИНТЕГРАЦИЯ

Программируя конвейеры для машинного обучения, полезно держать в уме, что они должны сохранять совместимость со множеством других инструментов, в частности, scikit-learn, TensorFlow, Keras, PyTorch и многими другими библиотеками машинного и глубокого обучения.
Например, мы написали метод .tosklearn() позволяющий превращать этапы конвейера или целый конвейер в BaseEstimator базовый объект библиотеки scikit-learn. Что касается других библиотек машинного обучения, задача сводится к написанию нового класса, который наследует от нашего BaseStep и к переопределению в конкретном коде операций подгонки и преобразования, а также, возможно, установки и сноса. Также нужно определить сейвер, который будет сохранять и загружать вашу модель. Вот документация по классу BaseStep и примеры к ней.

ЗАКЛЮЧЕНИЕ

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

  • В коде для машинного обучения целесообразно использовать конвейеры, и каждый этап конвейера определять как экземпляр класса.
  • Затем вся такая структура может быть оптимизирована при помощи контрольных точек, помогающих найти наилучшие гиперпараметры и многократно выполнять код над одними и теми же данными (но, возможно, с разными гиперпараметрами или с измененным исходным кодом).
  • Также целесообразно выполнять подгонку и преобразование данных последовательно, чтобы не раздувать RAM. Затем всю такую структуру можно распараллелить, когда переключаешься с последовательного конвейера на потоковый.
  • Наконец, можно программировать собственные этапы конвейеров для этого достаточно наследовать от класса BaseStep, показанного в этой статье, и реализовать нужные вам методы.
Подробнее..

К порядку правила создания конвейеров обработки данных

30.12.2020 16:07:24 | Автор: admin

К 2020 году вы не могли не заметить, что миром правят данные. И, как только речь заходит о работе с ощутимыми объёмами, появляется необходимость в сложном многоэтапном конвейере обработки данных.

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

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

Я поделюсь несколькими простыми правилами, которые помогают нам в работе с преобразованием данных и, надеюсь, помогут и вам.

Правило наименьшего шага

Первое правило сформулировать легко: каждое отдельное взятое преобразование должно быть как можно проще и меньше.

Допустим, данные поступают на машину с POSIX-совместимой операционной системой. Каждая единица данных это JSON-объект, и эти объекты собираются в большие файлы-пакеты, содержащие по одному JSON-объекту на строку. Пускай каждый такой пакет весит около 10 Гб.

Над пакетом надо произвести три преобразования:

  1. Проверить ключи и значения каждого объекта.

  2. Применить к каждому объекту первую трансформацию (скажем, изменить схему объекта).

  3. Применить вторую трансформацию (внести новые данные).

Совершенно естественно всё это делать с помощью единственного скрипта на Python:

python transform.py < /input/batch.json > /output/batch.json

Блок-схема такого конвейера не выглядит сложной:

Проверка объектов в transform.py занимает около 10% времени, первое преобразование 70%, на остальное уходит 20% времени.

Теперь представим, что ваш стартап вырос и вам уже приходится обрабатывать сотни, а то и тысячи пакетов. И тут вы обнаружили, что в финальный этап логики обработки данных (занимающий 20% времени) закралась ошибка, и вам нужно всё выполнить заново.

В такой ситуации рекомендуется собирать конвейеры из как можно более мелких этапов:

python validate.py < /input/batch.json > /tmp/validated.jsonpython transform1.py < /input/batch.json > /tmp/transformed1.jsonpython transform2.py < /input/transformed1.json > /output/batch.json

Блок-схема превращается в симпатичный паровозик:

Выгоды очевидны:

  • конкретные преобразования проще понять;

  • каждый этап можно протестировать отдельно;

  • промежуточные результаты отлично кешируются;

  • систему легко дополнить механизмами обработки ошибок;

  • преобразования можно использовать и в других конвейерах.

Правило атомарности

К правилу наименьшего шага прилагается второе правило атомарности. Оно звучит так: каждый шаг-преобразование либо должен случиться, либо нет. Никаких промежуточных состояний данных быть не должно.

Давайте вернёмся к первому примеру. Есть входные данные, над которыми мы проводим преобразование:

python transform.py < /input/batch.json > /output/batch.json

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

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

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

В POSIX-совместимых файловых системах всегда есть атомарные операции (скажем, mv или ln), с помощью которых можно имитировать транзакции:

python transform.py < /input/batch.json > /output/batch.json.tmpmv /output/batch.json.tmp /output/batch.json

В этом примере испорченные промежуточные данные окажутся в файле *.tmp, который можно изучить позднее при проведении отладки или просто удалить.

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

Правило идемпотентности

В императивном программировании подпрограмма с побочными эффектами является идемпотентной, если состояние системы не меняется после одного или нескольких вызовов.

Википедия

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

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

python transform.py < /input/batch.json > /output/batch1.jsonpython transform.py < /input/batch.json > /output/batch2.jsondiff /input/batch1.json /output/batch2.json# файлы те жеpython transform.py < /input/batch.json > /output/batch3.jsondiff /input/batch2.json /output/batch3.json# никаких изменений

На входе у нас /input/batch.json, а на выходе /output/batch.json. И вне зависимости от того, сколько раз мы применим преобразование, мы должны получить одни и те же данные:

Так что если только transform.py не зависит от каких-то неявных входных данных, этап transform.py является идемпотентным (своего рода перезапускаемым).

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

Чем важна идемпотентность? В первую очередь это свойство упрощает обслуживание конвейера. Оно позволяет легко перезагружать подмножества данных после изменений в transform.py или входных данных в /input/batch.json. Информация будет идти по тем же маршрутам, попадёт в те же таблицы базы данных, окажется в тех же файлах и т. д.

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

Правило избыточности

Четвёртое правило: насколько возможно откладывайте удаление промежуточных данных. Зачастую это подразумевает использование дешёвого, медленного, но ёмкого хранилища для входных данных:

Пример:

python transform1.py < /input/batch.json > /tmp/batch-1.jsonpython transform2.py < /tmp/batch-1.json > /tmp/batch-2.jsonpython transform3.py < /tmp/batch-2.json > /tmp/batch-3.jsoncp /tmp/batch-3.json /output/batch.json.tmp # не атомарно!mv /output/batch.json.tmp /output/batch.json # атомарно

Сохраняйте сырые (input/batch.json) и промежуточные (/tmp/batch-1.json, /tmp/batch-2.json, /tmp/batch-3.json) данные как можно дольше по меньшей мере до завершения цикла работы конвейера.

Вы скажете мне спасибо, когда аналитики решат поменять алгоритм вычисления какой-то метрики в transform3.py и вам придётся исправлять данные за несколько месяцев.

Другими словами: избыточность избыточных данных ваш лучший избыточный друг.

Заключение

Давайте подведём итоги:

  • разбивайте конвейер на изолированные маленькие этапы;

  • стремитесь делать этапы атомарными и идемпотентными;

  • сохраняйте избыточность данных (в разумных пределах).

Так обрабатываем данные и мы в Badoo и Bumble: они приходят через сотни тщательно подготовленных этапов преобразований, 99% из которых атомарные, небольшие и идемпотентные. Мы можем позволить себе изрядную избыточность, поэтому держим данные в больших холодном и горячем хранилищах, а между отдельными ключевыми преобразованиями имеем и сверхгорячий промежуточный кеш.

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

А у вас есть свои правила обработки данных?

Подробнее..

Recovery mode Функциональное ядро на Python

01.05.2021 06:18:27 | Автор: admin

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

Конвейер обработки данныхКонвейер обработки данных

Функциональный стиль программирования очень близок к тому, как размышляет человек во время решения задачи. Пусть дано x. В целях решения задачи с этими данными необходимо выполнить серию преобразований. Сначала применить к ним f и получить результирующие данные x'. Затем к новым данным применить f2 и получить новые результирующие данные x'' и т.д.

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

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

2

|> ( fun x -> x + 5)

|> ( fun x -> x * x)

|> ( fun x -> x.ToString() )

Здесь входные данные, в данном случае число 2, последовательно обрабатываются серией лямбда-функций. Аналогичный конвейер можно имплементировать на языке Python, но для этого нужно написать специальную функцию, и, разумеется, это будет функция более высокого порядка:

# Конвейер обработки данных

def pipe(data, *fseq):

for fn in fseq:

data = fn(data)

return data

Приведенный ниже пример демонстрирует работу конвейера:

pipe(2,

lambda x: x + 5,

lambda x: x * x,

lambda x: str(x))

Число 2 проходит серию преобразований, и в результате будет получено строковое значение '49'. По сравнению с функцией reduce, в которой переданная в качестве аргумента одна единственная редуцирующая функция по очереди применяется к последовательности данных, в функции pipe наоборот последовательность функций применяется к обновляемым данным.

Функция pipe получает два аргумента: входные данные data и последовательность функций fseq. Во время первой итерации цикла for данные передаются в первую функцию из последовательности. Эта функция обрабатывает данные и возвращает результат, замещая переменную data новыми данными. Затем эти новые данные отправляются во вторую функцию и т.д. до тех пор, пока не будут выполнены все функции последовательности. По завершению своей работы функция pipe возвращает итоговые данные. Это и есть конвейер обработки данных.

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

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

def my_sum(*args): # Упаковка в список

return sum(args)

my_sum(1, 2, 3, 4, 5)

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

def fun(a, b, c, d):

print(a, b, c, d)

my_list = [1, 2, 3, 4]

fun(*my_list) # Разложение на четыре аргумента

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

Функциональная имплементация вычисления факториала числа

В приведенном ниже примере показана нерекурсивная версия алгоритма вычисления факториала (factorial) и его рекурсивной версия на основе более эффективной хвостовой рекурсии (factorial_rec). Детали имплементации обеих функций в данном случае не важны. Они приводятся в качестве примеров, на которых будет продемонстрирована работа конвейера обработки данных. Результат выполнения программы показан ниже.

1 # Эта программа демонстрирует

2 # функциональную версию функции factorial из главы 12

3

4 def main():

5 # Конвейер (ядро c нерекурсивным алгоритмом факториала)

6 pipe(int(input('Введите неотрицательное целое число: ')),

7 lambda n: (n, reduce(lambda x, y: x * y, range(1, n + 1))),

8 lambda tup:

9 print(f'Факториал числа {tup[0]} равняется {tup[1]}'))

# Вызвать главную функцию

main()

Вывод программы:

Введите неотрицательное целое число: 4 (Enter)

Факториал числа 4 равняется 24

В строке 8 лямбда-функция в последнем узле конвейера получает кортеж, состоящий из введенного пользователем числа и полученного результата.

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

1 # Эта программа демонстрирует

2 # функциональную версию функции factorial из главы 12

3

4 def get_int(msg=''):

5 return int(input(msg))

6

7 def main():

8 # Алгоритм 1. Рекурсивная версия с хвостовой рекурсией

9 def factorial_rec(n):

10 fn = lambda n, acc=1: acc if n == 0 else fn(n - 1, acc * n)

11 return n, fn(n)

12

13 # Алгоритм 2. Нерекурсивная версия

14 def factorial(n):

15 return n, reduce(lambda x, y: x * y, range(1, n + 1))

16

17 # Ввод данных

18 def indata():

19 def validate(n): # Валидация входных данных

20 if not isinstance(n, int):

21 raise TypeError("Число должно быть целым.")

22 if not n >= 0:

23 raise ValueError("Число должно быть >= 0.")

24 return n

25 msg = 'Введите неотрицательное целое число: '

26 return pipe(get_int(msg), validate)

27

28 # Вывод данных

29 def outdata():

30 def fn(data):

31 n, fact = data

32 print(f'Факториал числа {n} равняется {fact}')

33 return fn

34

35 # Конвейер (функциональное ядро)

36 pipe(indata(), # вход: - выход: int

37 factorial, # вход: int выход: кортеж

38 outdata()) # вход: кортеж выход: -

39

40 # Вызвать главную функцию

41 main()

Вывод программы:

Введите неотрицательное целое число: 4 (Enter)

Факториал числа 4 равняется 24

Функциональным ядром программы являются строки 36-38:

pipe(indata(),

factorial,

outdata())

Они представлены конвейером из трех узлов, т.е. функциями indata, factorial и outdata. Функция indata занимается получением данных от пользователя, которые затем передаются по конвейеру дальше. Функция factorial является собственно обрабатывающим алгоритмом, в данном случае нерекурсивной функцией вычисления факториала, которая получает данные, их обрабатывает и передает по конвейеру дальше. И функция outdata получает данные и показывает их пользователю. Обратите внимание, что функция indata имеет свой собственный конвейер, который состоит из получения данных от пользователя и их валидации.

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

Такая организация программного кода:

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

pipe(indata(), factorial_rec, outdata())

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

Например, рассмотрим вторую возможность отладку. В этом случае можно написать вспомогательную функцию check:

def check(data):

print(data)

return data

И затем ее вставить в конвейер, чтобы проверить результаты работы отдельных узлов конвейера:

pipe(indata(), check, factorial, check, outdata())

Если выполнить программу в таком варианте, то будут получены следующие результаты:

Вывод программы:

Введите неотрицательное целое число: 4 (Enter)

4

(4, 24)

Факториал числа 4 равняется 24

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

Приведем еще пару примеров с аналогичной организацией программного кода на основе функционального ядра в виде конвейера.

Функциональная имплементация вычисления последовательности Фибоначчи

# Эта программа демонстрирует

# функциональную версию функции fibonacci из главы 12

def main():

# Алгоритм

def fibonacci(n, x=0, y=1):

# Функция fib возвращает n-ое число последовательности.

fib = lambda n, x=0, y=1: x if n <= 0 else fib(n - 1, y, x + y)

# Функция reduce собирает результаты в список acc

acc = []

reduce(lambda _, y: acc.append(fib(y)), range(n + 1))

return n, acc

# Валидация входных данных

def validate(n):

if not isinstance(n, int):

raise TypeError("Число должно быть целым.")

if not n >= 0:

raise ValueError("Число должно быть ноль положительным.")

if n > 10:

raise ValueError("Число должно быть не больше 10.")

return n

# Ввод данных

def indata():

msg = 'Введите неотрицательное целое число не больше 10: '

return pipe(get_int(msg), validate)

# Вывод данных

def outdata():

def fn(data):

n, seq = data

msg = f'Первые {n} чисел последовательности Фибоначчи:'

print(msg)

[print(el) for el in seq]

return fn

# Конвейер (функциональное ядро)

pipe(indata(), fibonacci, outdata())

# Вызвать главную функцию.

main()

Вывод программы

Введите неотрицательное целое число не больше 10: 10 (Enter)

Первые 10 чисел последовательности Фибоначчи:

1

1

2

3

5

8

13

21

34

55

Функциональная имплементация суммирования диапазона значений последовательности

# Эта программа демонстрирует

# функциональную версию функции range_sum из главы 12

def main():

# Алгоритм

def range_sum(data):

seq, params = data

fn = lambda start, end: 0 if start > end \

else seq[start] + fn(start + 1, end)

return fn(*params)

# Ввод данных

def indata():

seq = [1, 2, 3, 4, 5, 6, 7, 8, 9]

params = (2,5) # params - это параметры start, end

return seq, params

# Вывод данных

def outdata():

def f(data):

msg = 'Сумма значений со 2 по 5 позиции равняется '

print(msg, format(data), sep='')

return f

# Конвейер (функциональное ядро)

pipe(indata(), range_sum, outdata())

# Вызвать главную функцию.

main()

Вывод программы

Сумма значений со 2 по 5 позиции равняется 18

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

Подробнее..

Перевод Что общего в работе заводского конвейера и микропроцессора?

26.03.2021 12:06:50 | Автор: admin

Промышленный робот перемещает контейнеры в воображаемом складе. Перемещение соответствует инструкциям микропроцессора, а контейнеры можно трактовать как обрабатываемые данные.

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

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

Мы рассмотрим следующие понятия:

  • Тактовая частота. Как первые процессоры становились быстрее после увеличения тактовой частоты.
  • Параллельное выполнение. Если нельзя выполнить инструкции быстрее, то как насчет параллельного выполнения?
  • Конвейеризация. Предназначена для случаев, когда нельзя увеличить тактовую частоту и количество параллельно выполняющихся задач.

Увеличение тактовой частоты


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

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

Проще всего объяснить на примере склада и робота. Наш робот подбирает контейнеры с одной стороны склада и доставляет на противоположную.


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


Робот занят перемещением оранжевого контейнера до конца линии.

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

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

Преимущества маленьких микропроцессоров


Если мы не можем сделать робота быстрее, как выполнить работу за меньшее количество времени? В терминах микропроцессоров вопрос звучит так: если мы не можем ускорить электроны, как нам заставить их оказаться в нужном месте быстрее?

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


Перемещение контейнеров на меньшее расстояние происходит быстрее.

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

Параллельное выполнение


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

Векторная обработка


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


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

Для дополнительного изучения: RISC-V Vector Instructions vs ARM and x86 SIMD.

Несколько микропроцессорных ядер


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


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

Конвейеризация


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

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

Таким образом, мы можем представить сложность инструкции как что-то, что удлиняет путь электронов. Что если мы не уменьшим размер процессора, а снизим сложность каждой инструкции? Если мы упростим каждую инструкцию, ток должен будет проходить через меньшее количество транзисторов. Один из способов сделать это разбить инструкцию на несколько маленьких.


Перемещение контейнера разделено на три шага. Каждый робот переносит контейнер только на треть пути.

Такт 1


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


Два робота простаивают первое время, пока конвейер заполняется. Первый робот перемещает оранжевый контейнер на одну треть пути. Это первый такт.

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


Первый такт закончился. Второй робот не готов взять оранжевый контейнер и передвинуть его на вторую треть пути.

Такт 2


Во втором такте второй робот начинает перемещать оранжевый контейнер, а первый робот подбирает желтый контейнер. Обратите внимание, что сотрудники склада добавили зеленый контейнер.


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

Когда второй такт завершен, оранжевый контейнер лежит перед третьим роботом. Второй робот может подобрать желтый контейнер. А третий робот подберет новый зеленый контейнер.


Второй такт завершен. Весь конвейер заполнен. В следующем такте все три робота будут работать параллельно.

Такт 3


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


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

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

Преимущества и проблемы конвейеризации


Мы можем описать это двумя важными концепциями.

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

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

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

RISC и CISC


Эта причина, по которой ранние процессоры RISC, такие как MIPS и SPARC, используемые на рабочих станциях Unix в 1990-х годах, были быстрее, чем их аналоги на x86. CISC-процессоры не учитывали конвейеризацию при проектировании, в отличие от RISC. Инструкции RISC разделены на четыре логических шага. Это позволяет построить конвейер на четыре шага. Получается, RISC-инструкция требует четыре такта на исполнение, но каждый такт завершается выполнением одной инструкции.

Дополнительное чтение: What Does RISC and CISC Mean in 2020?

Intel поняла, что нужно найти способ конвейеризации сложных инструкций переменной длины. Так были созданы микрооперации. Они разбивали сложные инструкции на несколько микроопераций, которые были достаточно простыми, чтобы работать в конвейере.

Дополнительное чтение: What the Heck is a Micro-Operation?

Дальнейшее развитие конвейеризации


Intel начала разделять свои инструкции на все меньшие части, что позволило компании резко увеличить тактовую частоту процессоров. Так, например, Pentium 4 имел зверскую частоту. Это сделало конвейеры Intel очень длинными.

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

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

Таким образом, нужно очистить конвейер и ждать еще сто тактов, прежде чем другие инструкции заполнят конвейер до конца. Это большой удар по производительности.

Вот почему у Apple были слайды со сравнением RISC-чипов PowerPC, которые они использовали до 2006 года, с процессорами Intel Pentium. Процессор Pentium имел более высокую тактовую частоту, но и гораздо более длинный конвейер. Тогда люди считали тактовую частоту эквивалентом производительности. Как вы видим, эти термины связаны, но это не совсем одно и то же.

Процессоры Pentium часто очищали очень длинные конвейеры, что сильно снижало производительность. Между тем, чипы PowerPC имели более низкую тактовую частоту, но гораздо более короткие конвейеры, что приводило к значительно меньшему падению производительности из-за условных переходов.

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

Но это, вероятно, будет темой следующей технической статьи.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru