Tại sao được nén nhanh hơn zip trong Scala?


38

Tôi đã viết một số mã Scala để thực hiện thao tác phần tử trên bộ sưu tập. Ở đây tôi đã định nghĩa hai phương thức thực hiện cùng một nhiệm vụ. Một phương pháp sử dụng zipvà sử dụng khác zipped.

def ES (arr :Array[Double], arr1 :Array[Double]) :Array[Double] = arr.zip(arr1).map(x => x._1 + x._2)

def ES1(arr :Array[Double], arr1 :Array[Double]) :Array[Double] = (arr,arr1).zipped.map((x,y) => x + y)

Để so sánh hai phương pháp này về tốc độ, tôi đã viết đoạn mã sau:

def fun (arr : Array[Double] , arr1 : Array[Double] , f :(Array[Double],Array[Double]) => Array[Double] , itr : Int) ={
  val t0 = System.nanoTime()
  for (i <- 1 to itr) {
       f(arr,arr1)
       }
  val t1 = System.nanoTime()
  println("Total Time Consumed:" + ((t1 - t0).toDouble / 1000000000).toDouble + "Seconds")
}

Tôi gọi funphương thức và vượt qua ESES1như sau:

fun(Array.fill(10000)(math.random), Array.fill(10000)(math.random), ES , 100000)
fun(Array.fill(10000)(math.random), Array.fill(10000)(math.random), ES1, 100000)

Kết quả cho thấy phương thức có tên ES1sử dụng zippednhanh hơn phương thức ESsử dụng zip. Dựa trên những quan sát này, tôi có hai câu hỏi.

Tại sao zippednhanh hơn zip?

Có cách nào nhanh hơn để thực hiện các thao tác phần tử trên một bộ sưu tập trong Scala không?



8
Bởi vì JIT đã quyết định tối ưu hóa mạnh mẽ hơn vào lần thứ hai khi thấy "niềm vui" được gọi. Hoặc bởi vì GC đã quyết định dọn dẹp thứ gì đó trong khi ES đang chạy. Hoặc bởi vì hệ điều hành của bạn quyết định rằng nó có những thứ tốt hơn để làm trong khi thử nghiệm ES của bạn đang chạy. Có thể là bất cứ điều gì, microbenchmark này chỉ là không kết luận.
Andrey Tyukin

1
Kết quả trên máy của bạn là gì? Nhanh hơn bao nhiêu?
Peeyush Kushwaha

Với cùng kích thước và cấu hình dân số, Zipped mất 32 giây trong khi Zip mất 44 giây
12140540

3
Kết quả của bạn là vô nghĩa. Sử dụng JMH nếu bạn phải làm điểm chuẩn vi mô.
OrangeDog

Câu trả lời:


17

Để trả lời câu hỏi thứ hai của bạn:

Có cách nào nhanh hơn để thực hiện thao tác phần tử trên bộ sưu tập trong Scala không?

Sự thật đáng buồn là mặc dù nó có tính đồng nhất, năng suất được cải thiện và khả năng phục hồi các lỗi mà ngôn ngữ chức năng không nhất thiết phải là hiệu suất cao nhất - sử dụng các hàm bậc cao hơn để xác định phép chiếu được thực thi đối với các bộ sưu tập không miễn phí và vòng lặp chặt chẽ của bạn làm nổi bật điều này. Như những người khác đã chỉ ra, phân bổ lưu trữ bổ sung cho kết quả trung gian và cuối cùng cũng sẽ có chi phí chung.

Nếu hiệu suất là rất quan trọng, mặc dù không có nghĩa là phổ quát, trong trường hợp như của bạn, bạn có thể chuyển các hoạt động của Scala trở lại tương đương bắt buộc để lấy lại quyền kiểm soát trực tiếp hơn đối với việc sử dụng bộ nhớ và loại bỏ các cuộc gọi chức năng.

Trong ví dụ cụ thể của bạn, các zippedkhoản tiền có thể được thực hiện một cách bắt buộc bằng cách phân bổ trước một mảng cố định, có thể thay đổi có kích thước chính xác (vì zip dừng khi một trong các bộ sưu tập hết các phần tử), sau đó thêm các phần tử vào chỉ mục thích hợp (kể từ khi truy cập các phần tử mảng theo chỉ số thứ tự là một hoạt động rất nhanh).

Thêm chức năng thứ ba, ES3vào bộ thử nghiệm của bạn:

def ES3(arr :Array[Double], arr1 :Array[Double]) :Array[Double] = {
   val minSize = math.min(arr.length, arr1.length)
   val array = Array.ofDim[Double](minSize)
   for (i <- 0 to minSize - 1) {
     array(i) = arr(i) + arr1(i)
   }
  array
}

Trên i7 của tôi, tôi nhận được các lần phản hồi sau:

OP ES Total Time Consumed:23.3747857Seconds
OP ES1 Total Time Consumed:11.7506995Seconds
--
ES3 Total Time Consumed:1.0255231Seconds

Thậm chí còn ghê gớm hơn là thực hiện đột biến tại chỗ ngắn hơn hai mảng, điều này rõ ràng sẽ làm hỏng nội dung của một trong các mảng và sẽ chỉ được thực hiện nếu mảng ban đầu lại không cần thiết:

def ES4(arr :Array[Double], arr1 :Array[Double]) :Array[Double] = {
   val minSize = math.min(arr.length, arr1.length)
   val array = if (arr.length < arr1.length) arr else arr1
   for (i <- 0 to minSize - 1) {
      array(i) = arr(i) + arr1(i)
   }
  array
}

Total Time Consumed:0.3542098Seconds

Nhưng rõ ràng, sự đột biến trực tiếp của các yếu tố mảng không theo tinh thần của Scala.


2
Không có gì song song trong mã của tôi ở trên. Mặc dù vấn đề cụ thể này có thể xảy ra song song (vì nhiều luồng có thể hoạt động trên các phần khác nhau của mảng), nhưng sẽ không có nhiều điểm trong thao tác đơn giản như vậy chỉ với 10k phần tử - chi phí tạo và đồng bộ hóa các luồng mới có thể vượt trội hơn bất kỳ lợi ích nào . Thành thật mà nói, nếu bạn yêu cầu mức tối ưu hóa hiệu suất này, có khả năng bạn nên viết các loại thuật toán này trong Rust, Go hoặc C.
StuartLC

3
Nó sẽ giống như scala hơn và nhanh hơn để sử dụng Array.tabulate(minSize)(i => arr(i) + arr1(i))để tạo mảng của bạn
Sarvesh Kumar Singh

1
@SarveshKumarSingh cái này chậm hơn nhiều. Mất gần 9 giây
12140540

1
Array.tabulatenên nhanh hơn nhiều so với ziphoặc zippedở đây (và nằm trong điểm chuẩn của tôi).
Travis nâu

1
@StuartLC "Hiệu suất sẽ chỉ tương đương nếu chức năng bậc cao hơn bằng cách nào đó được mở ra và nội tuyến." Điều này không thực sự chính xác. Ngay cả của bạn forcũng được chuyển sang một lệnh gọi hàm cao hơn ( foreach). Lambda sẽ chỉ được khởi tạo một lần trong cả hai trường hợp.
Travis Brown

50

Không có câu trả lời nào khác đề cập đến lý do chính cho sự khác biệt về tốc độ, đó là zipped phiên bản tránh được 10.000 phân bổ. Như một vài câu trả lời khác cần lưu ý, zipphiên bản liên quan đến một mảng trung gian, trong khi zippedphiên bản thì không, nhưng việc phân bổ một mảng cho 10.000 phần tử không phải là điều khiến zipphiên bản tệ hơn nhiều, đó là 10.000 bộ dữ liệu ngắn. đang được đưa vào mảng đó. Chúng được đại diện bởi các đối tượng trên JVM, vì vậy bạn đang thực hiện một loạt các phân bổ đối tượng cho những thứ mà bạn sẽ vứt bỏ ngay lập tức.

Phần còn lại của câu trả lời này chỉ đi vào chi tiết hơn một chút về cách bạn có thể xác nhận điều này.

Điểm chuẩn tốt hơn

Bạn thực sự muốn sử dụng một khung công tác như jmh để thực hiện bất kỳ loại điểm chuẩn nào có trách nhiệm trên JVM, và ngay cả phần có trách nhiệm cũng khó, mặc dù việc thiết lập jmh không quá tệ. Nếu bạn có mộtproject/plugins.sbt như thế này:

addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.3.7")

Và một build.sbt như thế này (Tôi đang sử dụng 2.11.8 vì bạn đề cập đến những gì bạn đang sử dụng):

scalaVersion := "2.11.8"

enablePlugins(JmhPlugin)

Sau đó, bạn có thể viết điểm chuẩn của bạn như thế này:

package zipped_bench

import org.openjdk.jmh.annotations._

@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.Throughput))
class ZippedBench {
  val arr1 = Array.fill(10000)(math.random)
  val arr2 = Array.fill(10000)(math.random)

  def ES(arr: Array[Double], arr1: Array[Double]): Array[Double] =
    arr.zip(arr1).map(x => x._1 + x._2)

  def ES1(arr: Array[Double], arr1: Array[Double]): Array[Double] =
    (arr, arr1).zipped.map((x, y) => x + y)

  @Benchmark def withZip: Array[Double] = ES(arr1, arr2)
  @Benchmark def withZipped: Array[Double] = ES1(arr1, arr2)
}

Và chạy nó với sbt "jmh:run -i 10 -wi 10 -f 2 -t 1 zipped_bench.ZippedBench" :

Benchmark                Mode  Cnt     Score    Error  Units
ZippedBench.withZip     thrpt   20  4902.519 ± 41.733  ops/s
ZippedBench.withZipped  thrpt   20  8736.251 ± 36.730  ops/s

Điều đó cho thấy rằng zippedphiên bản nhận được thông lượng nhiều hơn khoảng 80%, có thể ít nhiều giống với số đo của bạn.

Đo lường phân bổ

Bạn cũng có thể yêu cầu jmh đo lường phân bổ với -prof gc:

Benchmark                                                 Mode  Cnt        Score       Error   Units
ZippedBench.withZip                                      thrpt    5     4894.197 ±   119.519   ops/s
ZippedBench.withZip:·gc.alloc.rate                       thrpt    5     4801.158 ±   117.157  MB/sec
ZippedBench.withZip:·gc.alloc.rate.norm                  thrpt    5  1080120.009 ±     0.001    B/op
ZippedBench.withZip:·gc.churn.PS_Eden_Space              thrpt    5     4808.028 ±    87.804  MB/sec
ZippedBench.withZip:·gc.churn.PS_Eden_Space.norm         thrpt    5  1081677.156 ± 12639.416    B/op
ZippedBench.withZip:·gc.churn.PS_Survivor_Space          thrpt    5        2.129 ±     0.794  MB/sec
ZippedBench.withZip:·gc.churn.PS_Survivor_Space.norm     thrpt    5      479.009 ±   179.575    B/op
ZippedBench.withZip:·gc.count                            thrpt    5      714.000              counts
ZippedBench.withZip:·gc.time                             thrpt    5      476.000                  ms
ZippedBench.withZipped                                   thrpt    5    11248.964 ±    43.728   ops/s
ZippedBench.withZipped:·gc.alloc.rate                    thrpt    5     3270.856 ±    12.729  MB/sec
ZippedBench.withZipped:·gc.alloc.rate.norm               thrpt    5   320152.004 ±     0.001    B/op
ZippedBench.withZipped:·gc.churn.PS_Eden_Space           thrpt    5     3277.158 ±    32.327  MB/sec
ZippedBench.withZipped:·gc.churn.PS_Eden_Space.norm      thrpt    5   320769.044 ±  3216.092    B/op
ZippedBench.withZipped:·gc.churn.PS_Survivor_Space       thrpt    5        0.360 ±     0.166  MB/sec
ZippedBench.withZipped:·gc.churn.PS_Survivor_Space.norm  thrpt    5       35.245 ±    16.365    B/op
ZippedBench.withZipped:·gc.count                         thrpt    5      863.000              counts
ZippedBench.withZipped:·gc.time                          thrpt    5      447.000                  ms

Ở đâu gc.alloc.rate.normcó lẽ là phần thú vị nhất, cho thấy zipphiên bản được phân bổ nhiều gấp ba lầnzipped .

Thực hiện bắt buộc

Nếu tôi biết rằng phương pháp này sẽ được gọi trong bối cảnh cực kỳ nhạy cảm với hiệu suất, có lẽ tôi sẽ thực hiện nó như thế này:

  def ES3(arr: Array[Double], arr1: Array[Double]): Array[Double] = {
    val minSize = math.min(arr.length, arr1.length)
    val newArr = new Array[Double](minSize)
    var i = 0
    while (i < minSize) {
      newArr(i) = arr(i) + arr1(i)
      i += 1
    }
    newArr
  }

Lưu ý rằng không giống như các phiên bản được tối ưu hóa trong một trong những câu trả lời khác, sử dụng này whilethay vì một fortừ forvẫn sẽ desugar vào Scala bộ sưu tập các hoạt động. Chúng ta có thể so sánh việc triển khai này ( withWhile), triển khai ( nhưng không tại chỗ) của câu trả lời khác ( withFor) và hai triển khai ban đầu:

Benchmark                Mode  Cnt       Score      Error  Units
ZippedBench.withFor     thrpt   20  118426.044 ± 2173.310  ops/s
ZippedBench.withWhile   thrpt   20  119834.409 ±  527.589  ops/s
ZippedBench.withZip     thrpt   20    4886.624 ±   75.567  ops/s
ZippedBench.withZipped  thrpt   20    9961.668 ± 1104.937  ops/s

Đó thực sự là một sự khác biệt rất lớn giữa các phiên bản mệnh lệnh và chức năng, và tất cả các chữ ký phương thức này hoàn toàn giống hệt nhau và việc triển khai có cùng một ngữ nghĩa. Nó không giống như các triển khai bắt buộc đang sử dụng nhà nước toàn cầu, vv Trong khi zipzipped phiên bản phiên bản dễ đọc hơn, cá nhân tôi không nghĩ có bất kỳ ý nghĩa nào trong đó các phiên bản bắt buộc chống lại "tinh thần Scala", và tôi sẽ không ngần ngại để sử dụng chúng cho mình.

Với bảng

Cập nhật: Tôi đã thêm một tabulatetriển khai vào điểm chuẩn dựa trên một nhận xét trong câu trả lời khác:

def ES4(arr: Array[Double], arr1: Array[Double]): Array[Double] = {
  val minSize = math.min(arr.length, arr1.length)
  Array.tabulate(minSize)(i => arr(i) + arr1(i))
}

Nó nhanh hơn nhiều so với các zipphiên bản, mặc dù vẫn chậm hơn nhiều so với các phiên bản bắt buộc:

Benchmark                  Mode  Cnt      Score     Error  Units
ZippedBench.withTabulate  thrpt   20  32326.051 ± 535.677  ops/s
ZippedBench.withZip       thrpt   20   4902.027 ±  47.931  ops/s

Đây là những gì tôi mong đợi, vì vốn dĩ không có gì đắt khi gọi hàm và bởi vì việc truy cập các phần tử mảng theo chỉ mục là rất rẻ.


8

Xem xét lazyZip

(as lazyZip bs) map { case (a, b) => a + b }

thay vì zip

(as zip bs) map { case (a, b) => a + b }

Scala 2.13 được thêm lazyZip vào ủng hộ.zipped

Cùng với .zipquan điểm, điều này thay thế .zipped(hiện không được chấp nhận). ( scala / bộ sưu tập-người rơm # 223 )

zipped(và do đó lazyZip) nhanh hơn zipbởi vì, như TimMike Allen giải thích , ziptheo sau mapsẽ dẫn đến hai biến đổi riêng biệt do tính nghiêm ngặt, trong khi zippedtiếp theo mapsẽ dẫn đến một biến đổi duy nhất được thực hiện trong một lần do lười biếng.

zippedđưa ra Tuple2Zippedvà phân tích Tuple2Zipped.map,

class Tuple2Zipped[...](val colls: (It1, It2)) extends ... {
  private def coll1 = colls._1
  private def coll2 = colls._2

  def map[...](f: (El1, El2) => B)(...) = {
    val b = bf.newBuilder(coll1)
    ...
    val elems1 = coll1.iterator
    val elems2 = coll2.iterator

    while (elems1.hasNext && elems2.hasNext) {
      b += f(elems1.next(), elems2.next())
    }

    b.result()
  }

chúng ta thấy hai bộ sưu tập coll1coll2được lặp đi lặp lại và trên mỗi lần lặp, hàm fđược truyền vào mapđược áp dụng trên đường đi

b += f(elems1.next(), elems2.next())

mà không phải phân bổ và chuyển đổi cấu trúc trung gian.


Áp dụng Travis' phương pháp benchmarking, đây là một sự so sánh giữa mới lazyZipvà bị phản đối zippedở đâu

@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.Throughput))
class ZippedBench {
  import scala.collection.mutable._
  val as = ArraySeq.fill(10000)(math.random)
  val bs = ArraySeq.fill(10000)(math.random)

  def lazyZip(as: ArraySeq[Double], bs: ArraySeq[Double]): ArraySeq[Double] =
    as.lazyZip(bs).map{ case (a, b) => a + b }

  def zipped(as: ArraySeq[Double], bs: ArraySeq[Double]): ArraySeq[Double] =
    (as, bs).zipped.map { case (a, b) => a + b }

  def lazyZipJavaArray(as: Array[Double], bs: Array[Double]): Array[Double] =
    as.lazyZip(bs).map{ case (a, b) => a + b }

  @Benchmark def withZipped: ArraySeq[Double] = zipped(as, bs)
  @Benchmark def withLazyZip: ArraySeq[Double] = lazyZip(as, bs)
  @Benchmark def withLazyZipJavaArray: ArraySeq[Double] = lazyZipJavaArray(as.toArray, bs.toArray)
}

cho

[info] Benchmark                          Mode  Cnt      Score      Error  Units
[info] ZippedBench.withZipped            thrpt   20  20197.344 ± 1282.414  ops/s
[info] ZippedBench.withLazyZip           thrpt   20  25468.458 ± 2720.860  ops/s
[info] ZippedBench.withLazyZipJavaArray  thrpt   20   5215.621 ±  233.270  ops/s

lazyZipdường như thực hiện tốt hơn một chút so với zippedtrên ArraySeq. Thật thú vị, thông báo hiệu suất giảm đáng kể khi sử dụng lazyZiptrên Array.


lazyZip có sẵn trong Scala 2.13.1. Hiện nay tôi đang sử dụng Scala 2.11.8
user12140540

5

Bạn phải luôn thận trọng với việc đo lường hiệu suất vì quá trình biên dịch JIT, nhưng một lý do có thể là do zippedlười biếng và trích xuất các phần tử từ các hàm ban đầu Arraytrong khi mapgọi, trong khi ziptạo một Arrayđối tượng mới và sau đó gọi mapđối tượng mới.

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.