Является ли Scala функциональным программированием медленнее, чем традиционное кодирование?

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

Я начал с общей задачи - умножить элементы двух массивов и подвести итоги:

var first:Array[Float] ...
var second:Array[Float] ...    
var sum=0f; 
for (ix<-0 until first.length) 
    sum += first(ix) * second(ix);

Вот как я реформировал работу:

sum = first.zip(second).map{ case (a,b) => a*b }.reduceLeft(_+_)

Когда я сравнивал два подхода, второй метод занимает в 40 раз больше времени!

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

Ответ 1

Основными причинами, по которым эти два примера настолько отличаются по скорости, являются:

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

Рассмотрим медленнее по частям. Во-первых:

first.zip(second)

Создает новый массив, массив Tuple2. Он скопирует все элементы из обоих массивов в объекты Tuple2, а затем скопирует ссылку на каждый из этих объектов в третий массив. Теперь обратите внимание, что параметр Tuple2 параметризуется, поэтому он не может хранить Float напрямую. Вместо этого для каждого номера создаются новые экземпляры java.lang.Float, номера хранятся в них, а затем ссылка для каждого из них сохраняется в Tuple2.

map{ case (a,b) => a*b }

Теперь создается четвертый массив. Чтобы вычислить значения этих элементов, ему необходимо прочитать ссылку на кортеж из третьего массива, прочитать ссылку на java.lang.Float, хранящуюся в них, прочитать числа, умножить, создать новый java.lang.Float для хранения результата, а затем передайте эту ссылку назад, которая будет снова удалена, чтобы быть сохраненной в массиве (массивы не стираются).

Мы еще не закончили. Здесь следующая часть:

reduceLeft(_+_)

Это относительно безопасно, за исключением того, что он по-прежнему создает бокс/распаковку и создание java.lang.Float на итерации, так как reduceLeft получает параметр Function2, который параметризуется.

Scala 2.8 вводится функция, называемая специализацией, которая избавится от многих этих бокс/распаковки. Но рассмотрим альтернативные более быстрые версии. Мы могли бы, например, сделать map и reduceLeft за один шаг:

sum = first.zip(second).foldLeft(0f) { case (a, (b, c)) => a + b * c }

Мы могли бы использовать view (Scala 2.8) или projection (Scala 2.7), чтобы вообще не создавать промежуточные коллекции:

sum = first.view.zip(second).map{ case (a,b) => a*b }.reduceLeft(_+_)

Это последнее не спасает много, фактически, поэтому я считаю, что не строгость, если "потеряна" довольно быстро (т.е. один из этих методов строг даже в представлении). Также альтернативный способ zipping, который является нестрогим (т.е. Позволяет избежать некоторых промежуточных результатов) по умолчанию:

sum = (first,second).zipped.map{ case (a,b) => a*b }.reduceLeft(_+_)

Это дает гораздо лучший результат, чем первый. Лучше, чем foldLeft, хотя и не сильно. К сожалению, мы не можем объединить zipped с foldLeft, потому что первая не поддерживает последнюю.

Последний - самый быстрый, который я мог получить. Быстрее этого, только со специализацией. Теперь Function2 оказывается специализированным, но для Int, Long и Double. Другие примитивы были опущены, так как специализация значительно увеличивает размер кода для каждого примитива. В моих тестах, хотя Double на самом деле занимает больше времени. Это может быть результатом того, что он будет в два раза больше, или это может быть что-то, что я делаю неправильно.

Итак, в конце концов, проблема представляет собой комбинацию факторов, включая создание промежуточных копий элементов и способ, которым Java (JVM) обрабатывает примитивы и дженерики. Аналогичный код в Haskell, использующий суперкомпиляцию, будет равен чему-то, что не соответствует ассемблеру. На JVM вы должны знать о компромиссах и быть готовыми к оптимизации критического кода.

Ответ 2

Я сделал несколько вариантов этого с помощью Scala 2.8. Версия цикла, когда вы пишете, но функциональная версия немного отличается:

(xs, ys).zipped map (_ * _) reduceLeft(_ + _)

Я работал с Double вместо Float, потому что в настоящее время специализация только для Double. Затем я тестировал с массивами и векторами в качестве типа несущей. Кроме того, я тестировал варианты в штучной упаковке, которые работают на java.lang.Double вместо примитивных парных чисел для измерения эффект примитивного типа бокса и распаковки. Вот что я получил (работая на сервере Java 1.6_10 VM, Scala 2.8 RC1, 5 прогонов за тест).

loopArray               461             437             436             437             435
reduceArray             6573            6544            6718            6828            6554
loopVector              5877            5773            5775            5791            5657
reduceVector            5064            4880            4844            4828            4926

loopArrayBoxed          2627            2551            2569            2537            2546
reduceArrayBoxed        4809            4434            4496            4434            4365
loopVectorBoxed         7577            7450            7456            7463            7432
reduceVectorBoxed       5116            4903            5006            4957            5122

Первое, что нужно заметить, - это то, что на самом деле самое большое различие между примитивными циклами массива и уменьшением функциональности примитивного массива. Это примерно в 15 раз вместо 40, которые вы видели, что отражает улучшения в Scala 2.8 выше 2.7. Тем не менее, примитивные циклы массивов являются самыми быстрыми из всех тестов, тогда как примитивные массивы уменьшаются медленнее. Причина в том, что примитивные массивы Java и общие операции просто не очень подходят. Доступ к элементам примитивных массивов Java из общих функций требует много бокса/распаковки, а иногда даже требует отражения. Будущие версии Scala будут специализировать класс Array, а затем мы должны увидеть некоторое улучшение. Но прямо сейчас, что это такое.

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

Если вы устраните накладные расходы на бокс/распаковку, работая только со значениями java.lang.Double в коробке, изображение меняется. Теперь уменьшить количество массивов немного меньше, чем в 2 раза медленнее, чем цикл, а не в 15 раз больше. Это более близко аппроксимирует накладные расходы на три петли с промежуточными структурами данных, а не с плавким циклом императивной версии. Зацикливание по векторам теперь, безусловно, является самым медленным решением, тогда как сокращение по векторам немного медленнее, чем уменьшение по массивам.

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

Ответ 3

Это микробиблиотека, и это зависит от того, как компилятор оптимизирует ваш код. Здесь у вас 3 петли,

zip. карта. свернуть

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

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

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

Итак, вкратце:

  • наивные переводы могут иметь неожиданные копии и неэффективность.
  • умные компиляторы FP удаляют эту служебную информацию (но Scala еще не могут)
  • придерживаясь подхода высокого уровня, окупается, если вы хотите перенацелить свой код, например. распараллелить его

Ответ 4

Дон Стюарт имеет прекрасный ответ, но может быть неясно, как переход из одного цикла в три создает замедление в 40 раз. Я добавлю к его ответу, что Scala компилируется JVM, и компилятор Scala не только не объединяет три петли в один, но компилятор Scala почти наверняка выделяет все промежуточные массивы. Известно, что реализации JVM не предназначены для обработки ставок распределения, требуемых функциональными языками. Распределение значительных затрат в функциональных программах и что одно преобразование цикла-фьюжн, которое Дон Стюарт и его коллеги реализовали для Haskell, настолько мощные: они устраняют множество распределений. Когда у вас нет этих преобразований, плюс вы используете дорогостоящий распределитель, такой как найденный на типичной JVM, где происходит значительное замедление.

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

Функциональное программирование Scala медленнее, чем традиционное кодирование?

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

Ответ 5

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

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

class FastFloatOps(a: Array[Float]) {
  def fastMapOnto(f: Float => Float) = {
    var i = 0
    while (i < a.length) { a(i) = f(a(i)); i += 1 }
    this
  }
  def fastMapWith(b: Array[Float])(f: (Float,Float) => Float) = {
    val len = a.length min b.length
    val c = new Array[Float](len)
    var i = 0
    while (i < len) { c(i) = f(a(i),b(i)); i += 1 }
    c
  }
  def fastReduce(f: (Float,Float) => Float) = {
    if (a.length==0) Float.NaN
    else {
      var r = a(0)
      var i = 1
      while (i < a.length) { r = f(r,a(i)); i += 1 }
      r
    }
  }
}
implicit def farray2fastfarray(a: Array[Float]) = new FastFloatOps(a)

а затем эти операции будут намного быстрее. (Быстрее, если вы используете Double и 2.8.RC1, потому что тогда функции (Double,Double)=>Double будут специализированными, а не универсальными; если вы используете что-то раньше, вы можете создать свой собственный abstract class F { def f(a: Float) : Float }, а затем позвонить с помощью new F { def f(a: Float) = a*a } (a: Float) => a*a.)

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

Ответ 6

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

def multiply_and_sum(l1:List[Int], l2:List[Int], sum:Int):Int = {
    if (l1 != Nil && l2 != Nil) {
        multiply_and_sum(l1.tail, l2.tail, sum + (l1.head * l2.head))
    }
    else {
        sum
    }
}

val first = Array(1,2,3,4,5)
val second = Array(6,7,8,9,10)
multiply_and_sum(first.toList, second.toList, 0)  //Returns: 130

Ответ 7

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

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

Так зачем выбирать современный язык? Потому что он позволяет вам выразить более чистый дизайн. Более чистая конструкция приводит к повышению производительности при общей работе приложения. Даже если некоторые низкоуровневые методы могут быть медленнее. Одним из моих любимых примеров является производительность BuildR против Maven. BuildR написан на Ruby, интерпретированном, медленном, языке. Maven написан на Java. Сборка в BuildR в два раза быстрее, чем у Maven. Это объясняется главным образом дизайном BuildR, который является легким по сравнению с дизайном Maven.

Ответ 8

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

let dot xs ys = Array.zip xs ys |> Array.map (fun (x, y) -> x * y) -> Array.reduce ( * )

можно переписать с помощью fold2, чтобы избежать всех временных структур данных:

let dot xs ys = Array.fold2 (fun t x y -> t + x * y) 0.0 xs ys

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

Ответ 9

Вот решение dbyrnes с массивами (предполагая, что массивы должны использоваться) и просто итерация по индексу:

def multiplyAndSum (l1: Array[Int], l2: Array[Int]) : Int = 
{
    def productSum (idx: Int, sum: Int) : Int = 
        if (idx < l1.length)
            productSum (idx + 1, sum + (l1(idx) * l2(idx))) else 
                sum
    if (l2.length == l1.length) 
        productSum (0, 0) else 
    error ("lengths don't fit " + l1.length + " != " + l2.length) 
}


val first = (1 to 500).map (_ * 1.1) toArray                                                
val second = (11 to 510).map (_ * 1.2) toArray     
def loopi (n: Int) = (1 to n).foreach (dummy => multiplyAndSum (first, second))
println (timed (loopi (100*1000)))

Для этого требуется около 1/40 времени подбора списка. У меня не установлено 2.8, поэтому вам нужно протестировать @tailrec самостоятельно.:)