Как оптимизировать для-понятий и циклов в Scala?

Итак, Scala должен быть таким же быстрым, как Java. Я пересматриваю некоторые проблемы Project Euler в Scala, которые я изначально занимался на Java. В частности, проблема 5: "Какое наименьшее положительное число равномерно делится на все числа от 1 до 20?"

Здесь мое решение Java, которое занимает 0,7 секунды для завершения работы на моей машине:

public class P005_evenly_divisible implements Runnable{
    final int t = 20;

    public void run() {
        int i = 10;
        while(!isEvenlyDivisible(i, t)){
            i += 2;
        }
        System.out.println(i);
    }

    boolean isEvenlyDivisible(int a, int b){
        for (int i = 2; i <= b; i++) {
            if (a % i != 0) 
                return false;
        }
        return true;
    }

    public static void main(String[] args) {
        new P005_evenly_divisible().run();
    }
}

Здесь мой "прямой перевод" на Scala, который занимает 103 секунды (в 147 раз больше!)

object P005_JavaStyle {
    val t:Int = 20;
    def run {
        var i = 10
        while(!isEvenlyDivisible(i,t))
            i += 2
        println(i)
    }
    def isEvenlyDivisible(a:Int, b:Int):Boolean = {
        for (i <- 2 to b)
            if (a % i != 0)
                return false
        return true
    }
    def main(args : Array[String]) {
        run
    }
}

Наконец, моя попытка функционального программирования, которая занимает 39 секунд (в 55 раз)

object P005 extends App{
    def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}
    def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
    println (find (2))
}

Использование Scala 2.9.0.1 на Windows 7 64-бит. Как повысить производительность? Я делаю что-то неправильно? Или Java намного быстрее?

Ответы

Ответ 1

Проблема в этом конкретном случае заключается в том, что вы возвращаетесь изнутри выражения for. Это, в свою очередь, преобразуется в бросок NonLocalReturnException, который вызывается приложенном методе. Оптимизатор может исключить foreach, но еще не может устранить бросок/улов. И бросить/поймать дорого. Но так как такие вложенные возвращения редки в программах Scala, оптимизатор еще не обратился к этому случаю. Продолжается работа над улучшением оптимизатора, который, надеюсь, вскоре решит эту проблему.

Ответ 2

Проблема, скорее всего, заключается в использовании понимания for в методе isEvenlyDivisible. Замена for эквивалентным циклом while должна исключать разницу в производительности с Java.

В отличие от циклов Java for, Scala for понимание на самом деле является синтаксическим сахаром для методов более высокого порядка; в этом случае вы вызываете метод foreach для объекта Range. Scala for очень общий, но иногда приводит к болезненной работе.

Возможно, вы захотите попробовать флаг -optimize в Scala версии 2.9. Наблюдаемая производительность может зависеть от конкретной используемой JVM, а оптимизатор JIT имеет достаточно "разогрева" времени для определения и оптимизации горячих точек.

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

Вот проблема в трекере ошибок: https://issues.scala-lang.org/browse/SI-4633

Обновление 5/28:

  • В качестве краткосрочного решения плагин ScalaCL преобразует простые циклы Scala в эквивалент циклов while.
  • В качестве потенциального долгосрочного решения команды из EPFL и Stanford сотрудничают в проекте, позволяя компиляцию во время выполнения "virtual" Scala для очень высокой производительности. Например, несколько идиоматических функциональных циклов могут быть слиты во время выполнения в оптимальный байт-код JVM или на другую цель, такую ​​как графический процессор. Система является расширяемой, позволяя пользователям определять DSL и преобразования. Ознакомьтесь с примечаниями publications и Stanford . Предварительный код доступен в Github с выпуском, предназначенным в ближайшие месяцы.

Ответ 3

В качестве продолжения я попробовал флаг -optimize и сократил время работы от 103 до 76 секунд, но это все еще на 107x медленнее, чем Java или цикл while.

Затем я смотрел "функциональную" версию:

object P005 extends App{
  def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}
  def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
  println (find (2))
}

и пытается выяснить, как избавиться от "forall" в сжатой манере. Я потерпел неудачу и придумал

object P005_V2 extends App {
  def isDivis(x:Int):Boolean = {
    var i = 1
    while(i <= 20) {
      if (x % i != 0) return false
      i += 1
    }
    return true
  }
  def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
  println (find (2))
}

благодаря чему мое хитрое пятилинейное решение имеет 12 линий. Однако эта версия работает 0,71 секунды, с той же скоростью, что и исходная версия Java, и в 56 раз быстрее, чем версия выше с использованием "forall" (40,2 с)! (см. ниже EDIT, почему это быстрее, чем Java)

Очевидно, моим следующим шагом было перевести вышеупомянутое обратно в Java, но Java не может его обработать и бросает StackOverflowError с n вокруг метки 22000.

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

object P005_V3 extends App {
  def isDivis(x:Int, i:Int):Boolean = 
    if(i > 20) true
    else if(x % i != 0) false
    else isDivis(x, i+1)

  def find(n:Int):Int = if (isDivis(n, 2)) n else find (n+2)
  println (find (2))
}

Итак, рекурсия хвоста Scala выигрывает день, но я удивлен, что что-то простое, как цикл "for" (и "forall" ), по существу, нарушено и должно быть заменено неэлегантными и подробными "whiles" ", или хвостовая рекурсия. Большая причина, по которой я пытаюсь использовать Scala, объясняется сжатым синтаксисом, но это не хорошо, если мой код будет работать в 100 раз медленнее!

EDIT: (удалено)

РЕДАКТИРОВАТЬ РЕДАКТИРОВАНИЯ. Бывшие несоответствия между временем выполнения 2,5 и 0,7 были полностью связаны с использованием 32-разрядных или 64-битных JVM. Scala из командной строки использует все, что задано JAVA_HOME, в то время как Java использует 64-битные, если они доступны независимо. У IDE есть свои настройки. Некоторые измерения здесь: Scala время выполнения в Eclipse

Ответ 4

Ответ на понимание прав, но это не вся история. Следует отметить, что использование return в isEvenlyDivisible не является бесплатным. Использование возврата внутри for заставляет компилятор scala генерировать нелокальный возврат (т.е. Возвращать его функцию).

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

def loop[T](times: Int, default: T)(body: ()=>T) : T = {
    var count = 0
    var result: T = default
    while(count < times) {
        result = body()
        count += 1
    }
    result
}

def foo() : Int= {
    loop(5, 0) {
        println("Hi")
        return 5
    }
}

foo()

Это печатает "Привет" только один раз.

Обратите внимание, что return in foo завершает foo (что и следовало ожидать). Поскольку выражение в скобках является литералом функции, которое вы можете видеть в сигнатуре loop, это заставляет компилятор генерировать нелокальное возвращение, то есть return заставляет вас выйти из foo, а не только body.

В Java (т.е. JVM) единственным способом реализации такого поведения является выброс исключения.

Возвращаясь к isEvenlyDivisible:

def isEvenlyDivisible(a:Int, b:Int):Boolean = {
  for (i <- 2 to b) 
    if (a % i != 0) return false
  return true
}

if (a % i != 0) return false - это литерал функции, который имеет возврат, поэтому каждый раз, когда возвращается результат, среда выполнения должна бросать и вылавливать исключение, что приводит к довольно небольшим накладным расходам GC.

Ответ 5

Некоторые способы ускорить обнаруженный метод forall:

Оригинал: 41.3 с

def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}

Предварительное создание диапазона, поэтому мы не создаем новый диапазон каждый раз: 9.0 с

val r = (1 to 20)
def isDivis(x:Int) = r forall {x % _ == 0}

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

val rl = (1 to 20).toList
def isDivis(x:Int) = rl forall {x % _ == 0}

Я попробовал несколько других коллекций, но List был самым быстрым (хотя все же на 7x медленнее, чем если бы мы вообще избегали функции Range и более высокого порядка).

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


сноска: Массивы были примерно такими же, как Range, но, интересно, сутенерство нового метода forall (показано ниже) приводило к 24% -му быстрому исполнению на 64-битных и 8% быстрее на 32-битных. Когда я уменьшил размер вычислений, уменьшив количество факторов с 20 до 15, разница исчезла, поэтому, возможно, это эффект сбора мусора. Независимо от причины, это важно при работе под полной нагрузкой в ​​течение длительного времени.

Аналогичный сутенер для List также привел к повышению производительности на 10%.

  val ra = (1 to 20).toArray
  def isDivis(x:Int) = ra forall2 {x % _ == 0}

  case class PimpedSeq[A](s: IndexedSeq[A]) {
    def forall2 (p: A => Boolean): Boolean = {      
      var i = 0
      while (i < s.length) {
        if (!p(s(i))) return false
        i += 1
      }
      true
    }    
  }  
  implicit def arrayToPimpedSeq[A](in: Array[A]): PimpedSeq[A] = PimpedSeq(in)  

Ответ 6

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

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

Ответ 7

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

def gcd(a: Int, b: Int): Int = {
    if (a == 0)
        b
    else
        gcd(b % a, a)
}
print (1 to 20 reduce ((a, b) => {
  a / gcd(a, b) * b
}))

Ответ 8

Попробуйте использовать однострочный вкладыш в Scala для Project Euler

Указанное время, по крайней мере, быстрее вашего, хотя и далеко от цикла while.:)