Làm thế nào để tối ưu hóa cho hiểu và vòng lặp trong Scala?


131

Vì vậy, Scala được cho là nhanh như Java. Tôi đang xem lại một số vấn đề của Project Euler trong Scala mà ban đầu tôi đã giải quyết bằng Java. Cụ thể Bài toán 5: "Số dương nhỏ nhất chia hết cho tất cả các số từ 1 đến 20 là bao nhiêu?"

Đây là giải pháp Java của tôi, mất 0,7 giây để hoàn thành trên máy của tôi:

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

Đây là "bản dịch trực tiếp" của tôi sang Scala, mất 103 giây (lâu hơn 147 lần!)

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

Cuối cùng, đây là nỗ lực của tôi trong lập trình chức năng, mất 39 giây (lâu hơn 55 lần)

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

Sử dụng Scala 2.9.0.1 trên Windows 7 64 bit. Làm cách nào để cải thiện hiệu suất? Tôi có làm điều gì sai? Hay là Java nhanh hơn rất nhiều?


2
Bạn có biên dịch hoặc giải thích bằng cách sử dụng scala shell?
AhmetB - Google

Có một cách tốt hơn để làm điều này hơn là sử dụng phân chia thử nghiệm ( Gợi ý ).
hammar

2
bạn không thể hiện cách bạn định thời gian này. Bạn đã thử chỉ thời gian runphương pháp?
Aaron Novstrup

2
@hammar - vâng, chỉ cần thực hiện theo cách viết và viết: viết ra các yếu tố chính cho mỗi số bắt đầu bằng số cao, sau đó bỏ qua các yếu tố mà bạn đã có cho các số cao hơn, vì vậy bạn kết thúc với (5 * 2 * 2) * (19) * (3 * 3) * (17) * (2 * 2) * () * (7) * (13) * () * (11) = 232792560
Luigi Plinge

2
+1 Đây là câu hỏi thú vị nhất tôi từng thấy trong vài tuần về SO (đó cũng là câu trả lời hay nhất tôi từng thấy trong một thời gian dài).
Mia Clarke

Câu trả lời:


111

Vấn đề trong trường hợp cụ thể này là bạn trở về từ trong biểu thức for. Điều đó đến lượt nó được dịch thành một ném NonLocalReturnException, được bắt tại phương thức kèm theo. Trình tối ưu hóa có thể loại bỏ foreach nhưng chưa thể loại bỏ cú ném / bắt. Và ném / bắt là đắt tiền. Nhưng vì lợi nhuận lồng nhau như vậy rất hiếm trong các chương trình Scala, trình tối ưu hóa vẫn chưa giải quyết trường hợp này. Có công việc đang diễn ra để cải thiện trình tối ưu hóa, hy vọng sẽ giải quyết vấn đề này sớm.


9
Khá nặng nề rằng một sự trở lại trở thành một ngoại lệ. Tôi chắc chắn rằng nó được ghi lại ở đâu đó, nhưng nó có một chút ma thuật ẩn giấu không thể hiểu được. Đó thực sự là cách duy nhất?
skrebbel

10
Nếu sự trở lại xảy ra từ bên trong một bao đóng, nó dường như là lựa chọn khả dụng tốt nhất. Trả về từ các bao đóng bên ngoài tất nhiên được dịch trực tiếp để trả về các hướng dẫn trong mã byte.
Martin Oderky

1
Tôi chắc chắn rằng tôi đang xem xét một cái gì đó, nhưng tại sao không biên dịch trả lại từ bên trong một bao đóng để đặt một cờ boolean kèm theo và giá trị trả về, và kiểm tra xem sau khi kết thúc cuộc gọi đóng lại?
Luke Hutteman

9
Tại sao thuật toán chức năng của anh ta vẫn chậm hơn 55 lần? Có vẻ như nó không phải chịu đựng hiệu suất khủng khiếp như vậy
Elijah

4
Bây giờ, vào năm 2014, tôi đã thử nghiệm điều này một lần nữa và đối với tôi hiệu suất là như sau: java -> 0,3s; scala -> 3,6s; scala được tối ưu hóa -> 3,5 giây; chức năng scala -> 4s; Có vẻ tốt hơn nhiều so với 3 năm trước, nhưng ... Vẫn là sự khác biệt quá lớn. Chúng ta có thể mong đợi nhiều cải tiến hiệu suất? Nói cách khác, Martin, theo lý thuyết, có gì để tối ưu hóa không?
sasha.sochka

80

Vấn đề rất có thể là việc sử dụng một sự forhiểu biết trong phương pháp isEvenlyDivisible. Thay thế forbằng một whilevòng lặp tương đương sẽ loại bỏ sự khác biệt về hiệu năng với Java.

Trái ngược với các forvòng lặp của Java , sự forhiểu biết của Scala thực sự là đường cú pháp cho các phương thức bậc cao hơn; trong trường hợp này, bạn đang gọi foreachphương thức trên một Rangeđối tượng. Scala forrất chung chung, nhưng đôi khi dẫn đến hiệu suất đau đớn.

Bạn có thể muốn thử -optimizecờ trong phiên bản Scala 2.9. Hiệu suất quan sát có thể phụ thuộc vào JVM cụ thể đang sử dụng và trình tối ưu hóa JIT có đủ thời gian "làm nóng" để xác định và tối ưu hóa các điểm nóng.

Các cuộc thảo luận gần đây về danh sách gửi thư cho thấy nhóm Scala đang nỗ lực cải thiện forhiệu suất trong các trường hợp đơn giản:

Đây là vấn đề trong trình theo dõi lỗi: https://issues.scala-lang.org/browse/SI-4633

Cập nhật ngày 5/11 :

  • Là một giải pháp ngắn hạn, plugin ScalaCL (alpha) sẽ biến các vòng lặp Scala đơn giản thành các vòng lặp tương đương while.
  • Là một giải pháp dài hạn tiềm năng, các nhóm từ EPFL và Stanford đang hợp tác trong một dự án cho phép biên dịch Scala "ảo" trong thời gian chạy để có hiệu suất rất cao. Ví dụ, nhiều vòng lặp chức năng thành ngữ có thể được hợp nhất vào thời gian chạy thành mã byte JVM tối ưu hoặc đến một mục tiêu khác như GPU. Hệ thống có thể mở rộng, cho phép người dùng xác định DSL và biến đổi. Kiểm tra các ấn phẩmghi chú khóa học Stanford . Mã sơ bộ có sẵn trên Github, với một bản phát hành dự định trong những tháng tới.

6
Thật tuyệt, tôi đã thay thế cho sự hiểu biết bằng một vòng lặp while và nó chạy chính xác cùng tốc độ (+/- <1%) như phiên bản Java. Cảm ơn ... tôi gần như mất niềm tin vào Scala trong một phút! Bây giờ chỉ cần làm việc với một thuật toán chức năng tốt ... :)
Luigi Plinge

24
Điều đáng chú ý là các hàm đệ quy đuôi cũng nhanh như vòng lặp (vì cả hai đều được chuyển đổi thành mã byte rất giống hoặc giống hệt nhau).
Rex Kerr

7
Điều này đã cho tôi một lần, quá. Phải dịch một thuật toán từ sử dụng các hàm thu thập sang các vòng lặp lồng nhau (cấp 6!) Vì sự chậm chạp đáng kinh ngạc. Đây là một cái gì đó cần phải được nhắm mục tiêu rất nhiều, imho; Sử dụng phong cách lập trình đẹp là gì nếu tôi không thể sử dụng nó khi tôi cần hiệu năng (lưu ý: không nhanh)?
Raphael

7
Khi nào thì forphù hợp?
OscarRyz

@OscarRyz - phần lớn trong scala hoạt động như for (:) trong phần lớn java.
Mike Axiak

31

Để theo dõi, tôi đã thử cờ tối ưu hóa và nó đã giảm thời gian chạy từ 103 xuống còn 76 giây, nhưng tốc độ vẫn chậm hơn 107 lần so với Java hoặc vòng lặp while.

Sau đó, tôi đã xem xét phiên bản "chức năng":

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

và cố gắng tìm ra cách để thoát khỏi "forall" một cách súc tích. Tôi thất bại thảm hại và nghĩ ra

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

theo đó, giải pháp 5 dòng xảo quyệt của tôi đã cân bằng đến 12 dòng. Tuy nhiên, phiên bản này chạy trong 0,71 giây , cùng tốc độ với phiên bản Java gốc và nhanh hơn 56 lần so với phiên bản trên bằng cách sử dụng "forall" (40,2 giây)! (xem EDIT bên dưới để biết tại sao điều này nhanh hơn Java)

Rõ ràng bước tiếp theo của tôi là dịch ngược lại sang Java, nhưng Java không thể xử lý nó và ném StackOverflowError với n khoảng 22000.

Sau đó, tôi gãi đầu một chút và thay thế "trong khi" với đệ quy đuôi nhiều hơn một chút, giúp tiết kiệm một vài dòng, chạy nhanh như vậy, nhưng hãy đối mặt với nó, khó đọc hơn:

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

Vì vậy, đệ quy đuôi của Scala chiến thắng cả ngày, nhưng tôi ngạc nhiên rằng một cái gì đó đơn giản như vòng lặp "for" (và phương thức "forall") về cơ bản đã bị phá vỡ và phải được thay thế bằng "whiles" không liên tục và dài dòng, hoặc đệ quy đuôi . Rất nhiều lý do tôi đang thử Scala là vì cú pháp ngắn gọn, nhưng sẽ không tốt nếu mã của tôi sẽ chạy chậm hơn 100 lần!

EDIT : (đã xóa)

EDIT OF EDIT : Sự khác biệt trước đây giữa thời gian chạy 2,5 và 0,7 hoàn toàn là do liệu JVM 32 bit hay 64 bit đang được sử dụng. Scala từ dòng lệnh sử dụng bất cứ thứ gì được đặt bởi JAVA_HOME, trong khi Java sử dụng 64 bit nếu có sẵn bất kể. IDE có cài đặt riêng của họ. Một số phép đo ở đây: Thời gian thực hiện Scala trong Eclipse


1
phương thức isDivis có thể được viết là : def isDivis(x: Int, i: Int): Boolean = if (i > 20) true else if (x % i != 0) false else isDivis(x, i+1). Lưu ý rằng trong Scala if-other là một biểu thức luôn trả về một giá trị. Không cần từ khóa return ở đây.
kiritsuku

3
Phiên bản cuối cùng của bạn ( P005_V3) có thể được thực hiện ngắn hơn, khai báo nhiều hơn và IMHO rõ ràng hơn bằng cách viết:def isDivis(x: Int, i: Int): Boolean = (i > 20) || (x % i == 0) && isDivis(x, i+1)
Blaisorblade

@Blaisorblade Không. Điều này sẽ phá vỡ tính đệ quy đuôi, được yêu cầu dịch sang một vòng lặp while trong mã byte, do đó làm cho việc thực thi nhanh.
gzm0

4
Tôi thấy quan điểm của bạn, nhưng ví dụ của tôi vẫn là đệ quy đuôi kể từ && và || sử dụng đánh giá ngắn mạch, như được xác nhận bằng cách sử dụng @tailrec: gist.github.com/Blaisorblade/5672562
Blaisorblade

8

Câu trả lời về sự hiểu biết là đúng, nhưng nó không phải là toàn bộ câu chuyện. Bạn nên lưu ý rằng việc sử dụng returntrong isEvenlyDivisiblekhông miễn phí. Việc sử dụng trả về bên trong for, buộc trình biên dịch scala tạo ra một trả về không cục bộ (nghĩa là trả về bên ngoài chức năng của nó).

Điều này được thực hiện thông qua việc sử dụng một ngoại lệ để thoát khỏi vòng lặp. Điều tương tự cũng xảy ra nếu bạn xây dựng các tóm tắt kiểm soát của riêng mình, ví dụ:

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

Điều này in "Hi" chỉ một lần.

Lưu ý rằng returntrong foolối ra foo(đó là những gì bạn mong đợi). Vì biểu thức ngoặc là một hàm theo nghĩa đen, mà bạn có thể thấy trong chữ ký của loopđiều này buộc trình biên dịch tạo ra một trả về không cục bộ, nghĩa là, returnbuộc bạn phải thoát foo, không chỉ là body.

Trong Java (tức là JVM), cách duy nhất để thực hiện hành vi đó là đưa ra một ngoại lệ.

Quay trở lại isEvenlyDivisible:

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

Đây if (a % i != 0) return falselà một hàm theo nghĩa đen có trả về, vì vậy mỗi lần trả về được nhấn, bộ thực thi phải ném và bắt một ngoại lệ, điều này gây ra khá nhiều chi phí GC.


6

Một số cách để tăng tốc forallphương pháp tôi đã khám phá:

Bản gốc: 41,3 s

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

Bắt đầu trước phạm vi, vì vậy chúng tôi không tạo phạm vi mới mỗi lần: 9.0 s

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

Chuyển đổi thành Danh sách thay vì Phạm vi: 4,8 giây

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

Tôi đã thử một vài bộ sưu tập khác nhưng Danh sách là nhanh nhất (mặc dù vẫn chậm hơn 7 lần so với việc chúng tôi tránh hoàn toàn chức năng Phạm vi và thứ tự cao hơn).

Mặc dù tôi chưa quen với Scala, tôi đoán trình biên dịch có thể dễ dàng thực hiện mức tăng hiệu suất nhanh và đáng kể bằng cách tự động thay thế các chữ Range trong các phương thức (như trên) bằng các hằng số Range trong phạm vi ngoài cùng. Hoặc tốt hơn, thực tập chúng như chuỗi ký tự trong Java.


chú thích : Các mảng giống như Range, nhưng thật thú vị, việc tạo ra một forallphương thức mới (hiển thị bên dưới) dẫn đến thực hiện nhanh hơn 24% trên 64 bit và nhanh hơn 8% trên 32 bit. Khi tôi giảm kích thước tính toán bằng cách giảm số lượng các yếu tố từ 20 xuống 15, sự khác biệt sẽ biến mất, vì vậy có thể đó là hiệu ứng thu gom rác. Dù nguyên nhân là gì, nó có ý nghĩa khi hoạt động dưới mức đầy tải trong thời gian dài.

Một ma cô tương tự cho Danh sách cũng cho hiệu suất tốt hơn khoảng 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

Tôi chỉ muốn bình luận cho những người có thể mất niềm tin vào Scala về các vấn đề như thế này rằng những loại vấn đề này xuất hiện trong việc thực hiện tất cả các ngôn ngữ chức năng. Nếu bạn đang tối ưu hóa một nếp gấp trong Haskell, bạn thường sẽ phải viết lại nó dưới dạng một vòng lặp được tối ưu hóa cho cuộc gọi đệ quy, nếu không bạn sẽ gặp phải các vấn đề về hiệu năng và bộ nhớ.

Tôi biết rằng thật đáng tiếc khi các FP chưa được tối ưu hóa đến mức chúng ta không phải suy nghĩ về những điều như thế này, nhưng đây hoàn toàn không phải là vấn đề đối với Scala.


2

Các vấn đề cụ thể đối với Scala đã được thảo luận, nhưng vấn đề chính là sử dụng thuật toán brute-force không được hay lắm. Hãy xem xét điều này (nhanh hơn nhiều so với mã Java gốc):

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

Các câu hỏi so sánh hiệu suất của một logic cụ thể trên các ngôn ngữ. Liệu thuật toán là tối ưu cho vấn đề là không quan trọng.
smartnut007

1

Hãy thử một lớp lót được đưa ra trong giải pháp Scala cho Project Euler

Thời gian đưa ra ít nhất là nhanh hơn thời gian của bạn, mặc dù cách xa vòng lặp while .. :)


Nó khá giống với phiên bản chức năng của tôi. Bạn có thể viết của tôi là def r(n:Int):Int = if ((1 to 20) forall {n % _ == 0}) n else r (n+2); r(2), ngắn hơn 4 ký tự của Pavel. :) Tuy nhiên tôi không giả vờ mã của mình là tốt - khi tôi đăng câu hỏi này, tôi đã mã hóa tổng cộng khoảng 30 dòng Scala.
Luigi Plinge
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.