Як оптимізувати для розуміння та циклів у Scala?


131

Тож Скала повинен бути таким же швидким, як і 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 просто набагато швидша?


2
Ви збираєте чи інтерпретуєте за допомогою шкала шкала?
AhmetB - Google

Є кращий спосіб зробити це, ніж використовувати пробний поділ ( Підказка ).
Хаммар

2
ти не показуєш, як ти це робиш. Ви спробували просто призначати runметод?
Аарон Новструп

2
@hammar - так, просто це зробив ручкою та папером: запишіть прості коефіцієнти для кожного числа, починаючи з високого, а потім перекресліть фактори, які у вас вже є для більшої кількості, так що ви закінчите з (5 * 2 * 2) * (19) * (3 * 3) * (17) * (2 * 2) * () * (7) * (13) * () * (11) = 232792560
Луїджі Плінг

2
+1 Це найцікавіше запитання, яке я бачив за тижні на SO (це також найкраща відповідь, яку я бачив за досить довгий час).
Міа Кларк

Відповіді:


111

Проблема в цьому конкретному випадку полягає в тому, що ви повертаєтеся зсередини для вираження. Це, у свою чергу, перетворюється на кидок NonLocalReturnException, який потрапляє при використанні методу укладання Оптимізатор може усунути випередження, але поки не може усунути кидок / улов. І кидати / ловити дорого. Але оскільки такі вкладені повернення рідкісні в програмах Scala, оптимізатор ще не розглядав цей випадок. Йде робота над вдосконаленням оптимізатора, який, сподіваємось, скоро вирішить це питання.


9
Досить важкий, що повернення стає винятком. Я впевнений, що це десь задокументовано, але в ньому є монета незбагненної прихованої магії. Це справді єдиний спосіб?
skrebbel

10
Якщо повернення відбувається зсередини закриття, здається, це найкращий доступний варіант. Зрозуміло, що повернення із зовнішніх закриттів, звичайно, перекладаються безпосередньо для повернення інструкцій у байт-коді.
Мартін Одерський

1
Я впевнений, що я щось пропускаю, але чому б не замість цього скласти повернення зсередини закриття, щоб встановити вкладений булевий прапор та return-значення, і перевірити, що після закриття-виклику повертається?
Люк Гуттеман

9
Чому його функціональний алгоритм все ще в 55 разів повільніше? Не схоже, що він повинен страждати від такого жахливого виступу
Ілля

4
Зараз, у 2014 році, я перевірив це ще раз, і для мене продуктивність така: java -> 0,3s; scala -> 3,6s; шкала оптимізована -> 3,5s; масштаб функціональний -> 4s; Виглядає набагато краще, ніж 3 роки тому, але ... Все-таки різниця занадто велика. Чи можемо ми очікувати збільшення поліпшення продуктивності? Іншими словами, Мартіне, чи є щось теоретично для можливих оптимізацій?
sasha.sochka

80

Проблема, швидше за все, полягає у використанні forрозуміння в методі isEvenlyDivisible. Заміна forна еквівалентний whileцикл повинна усунути різницю в продуктивності з Java.

На відміну від forциклів Java , forрозуміння Scala насправді є синтаксичним цукром для методів вищого порядку; у цьому випадку ви викликаєте foreachметод на Rangeоб’єкті. Скала forдуже загальна, але іноді призводить до болісного виконання.

Ви можете спробувати -optimizeпрапор у Scala версії 2.9. Спостережувані показники роботи можуть залежати від конкретного використовуваного JVM, а оптимізатор JIT має достатній час «прогрівання» для виявлення та оптимізації гарячих точок.

Нещодавні дискусії у списку розсилки свідчать про те, що команда Scala працює над підвищенням forефективності у простих випадках:

Ось проблема в трекері помилок: https://isissue.scala-lang.org/browse/SI-4633

Оновлення 5/28 :

  • Як короткочасне рішення плагін ScalaCL (альфа) перетворить прості петлі Scala в еквівалент whileциклів.
  • Як потенційне довгострокове рішення, команди з EPFL та Stanford співпрацюють над проектом, що дозволяє складати "віртуальну" Scala під час виконання для дуже високої продуктивності. Наприклад, кілька ідіоматичних функціональних циклів під час виконання роботи можуть бути злиті в оптимальний байт-код JVM або в іншу ціль, таку як GPU. Система розширюється, що дозволяє визначені користувачем DSL та перетворення. Перегляньте публікації та конспекти Стенфорда . Попередній код доступний на Github, реліз якого планується в найближчі місяці.

6
Чудово, я замінив для розуміння цикл час, і він працює точно з тією ж швидкістю (+/- <1%), що і версія Java. Дякую ... Я майже на хвилину втратив віру в Скалу! Тепер просто треба працювати над хорошим функціональним алгоритмом ... :)
Луїджі Плінг

24
Варто зазначити, що хвостові рекурсивні функції також настільки ж швидкі, як і петлі (оскільки обидва перетворюються на дуже схожий або однаковий байт-код).
Рекс Керр

7
Це мені теж одного разу вдалося. Довелося перекласти алгоритм із використання колекційних функцій у вкладені цикли (рівень 6!) Через неймовірне уповільнення. Це те, на що потрібно чітко орієнтуватися, імхо; з чого використовувати хороший стиль програмування, якщо я не можу ним користуватися, коли мені потрібна гідна (зверніть увагу: не плачуть швидко)?
Рафаель

7
Коли forтоді підходить?
OscarRyz

@OscarRyz - a for scala поводиться здебільшого як (() у Java).
Майк Аксіак

31

У подальшому я спробував -оптимізувати прапор, і він скоротив час роботи зі 103 до 76 секунд, але це все-таки 107 разів повільніше, ніж Java або цикл часу.

Тоді я переглядав "функціональну" версію:

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))
}

і намагаємось розібратися, як позбутися «форалу» лаконічно. Я невдало провалився і придумав

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))
}

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

Очевидно, моїм наступним кроком було перекласти вищезазначене назад на Java, але Java не впорається з цим і кидає StackOverflowError з n навколо позначки 22000.

Потім я трохи почухав голову і замінив "поки" трохи більше хвостовою рекурсією, яка економить пару ліній, працює так само швидко, але давайте зіткнемося з цим, читати більш заплутано:

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))
}

Тож хвоста рекурсія Скали виграє день, але я здивований, що щось таке просте, як цикл "для" (і метод "forall"), по суті, зламане і має бути замінене неелегантними та багатослівними "батогами" або рекурсією хвоста. . Дуже багато причин, які я намагаюся Scala, - це через стислий синтаксис, але це не добре, якщо мій код буде працювати в 100 разів повільніше!

EDIT : (видалено)

EDIT OF EDIT : Колишні розбіжності між часом виконання 2,5 та 0,7s були повністю пов'язані з тим, чи використовуються 32-бітні або 64-бітні JVM. Scala з командного рядка використовує все, що встановлено JAVA_HOME, тоді як Java використовує 64-розрядні, якщо вони доступні незалежно. IDE мають свої власні налаштування. Деякі вимірювання тут: Часи виконання Scala в Eclipse


1
isDivis-метод може бути записана в вигляді: def isDivis(x: Int, i: Int): Boolean = if (i > 20) true else if (x % i != 0) false else isDivis(x, i+1). Зауважте, що у Scala if-else є вираз, який завжди повертає значення. Тут не потрібно вводити ключове слово.
kiritsuku

3
Вашу останню версію ( P005_V3) можна зробити коротшою, декларативнішою та ІМХО зрозумілішою, написавши:def isDivis(x: Int, i: Int): Boolean = (i > 20) || (x % i == 0) && isDivis(x, i+1)
Blaisorblade

@Blaisorblade. Ні. Це призведе до порушення рекурсивності хвоста, що потрібно перевести у байт-код циклу time, що, в свою чергу, робить виконання швидким.
gzm0

4
Я бачу вашу думку, але мій приклад все ще є рекурсивним хвостом, оскільки && та || використовувати оцінку короткого замикання, підтверджене за допомогою @tailrec: gist.github.com/Blaisorblade/5672562
Blaisorblade

8

Відповідь про розуміння правильна, але це не вся історія. Слід зазначити, що використання 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в 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.


6

Деякі способи прискорити відкритий 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}

Я спробував декілька інших колекцій, але Список був найшвидшим (хоча все-таки на 7 разів повільніше, ніж якщо ми взагалі уникаємо функції Діапазон та вищого порядку).

Хоча я новачок у Scala, я думаю, що компілятор міг би легко реалізувати швидкий та значний приріст продуктивності, просто автоматично замінивши літерали діапазону методами (як вище) на константи діапазону у найбільш віддаленій області. Або ще краще, інтерніруйте їх, як Strings Literals на 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)  

3

Я просто хотів прокоментувати людей, які можуть втратити віру в Скалу через такі питання, що такі питання виникають у виконанні майже всіх функціональних мов. Якщо ви оптимізуєте складку в Haskell, вам часто доведеться переписувати її як цикл, оптимізований для рекурсивного хвостового виклику, інакше у вас виникнуть проблеми з продуктивністю та пам'яттю.

Я знаю, що прикро, що FP ще не оптимізовані до того, що нам не доведеться думати про такі речі, але це зовсім не проблема, яка стосується Scala.


2

Проблеми, характерні для 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
}))

Питання порівнюють ефективність певної логіки для різних мов. Оптимальний алгоритм для проблеми не має значення.
smartnut007

1

Спробуйте однолінійний параметр, наведений у рішенні Scala для проекту Euler

Наданий час принаймні швидший, ніж ваш, хоч і далеко не цикл .. :)


Це досить схоже на мою функціональну версію. Ви можете написати моє, як на def r(n:Int):Int = if ((1 to 20) forall {n % _ == 0}) n else r (n+2); r(2)4 символи коротше, ніж у Павла. :) Однак я не претендую на те, що мій код не корисний - коли я опублікував це питання, я зашифрував приблизно 30 рядків Scala.
Луїджі Плінг
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.